diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..02d3ed0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +**morningedition** — a morning edition printable newspaper generator. + +This repository is in early/empty state. No build system, dependencies, or code structure has been established yet. diff --git a/diagnose.py b/diagnose.py new file mode 100644 index 0000000..058d11b --- /dev/null +++ b/diagnose.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +""" +Printer diagnostic — runs several test prints to identify character width +and font settings on the Epson TM-T88V. +""" +import subprocess, sys, time + +PRINTER = "receipt" + +ESC = b'\x1b' +GS = b'\x1d' + +INIT = ESC + b'\x40' # ESC @ — initialize +FONT_A = ESC + b'\x4d\x00' # Font A (12x24 dots) — expected 42 chars +FONT_B = ESC + b'\x4d\x01' # Font B ( 9x17 dots) — expected 56 chars +SIZE_NORM = GS + b'\x21\x00' # GS ! 0 — force 1x1 character size +CUT = GS + b'\x56\x00' # full cut + +RULER_42 = "123456789012345678901234567890123456789012" +RULER_56 = "12345678901234567890123456789012345678901234567890123456" + +def send(data: bytes): + r = subprocess.run(["lpr", "-P", PRINTER, "-o", "raw"], + input=data, capture_output=True) + if r.returncode != 0: + print("lpr error:", r.stderr.decode(), file=sys.stderr) + +def test(label, extra_init=b"", font=FONT_A, ruler=RULER_42): + body = ( + f"\n--- TEST: {label} ---\n" + f"Font: {'A (expect 42)' if font == FONT_A else 'B (expect 56)'}\n" + f"{ruler}\n" + f"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^\n" + f"The quick brown fox jumps over the lazy dog\n" + f"\n" + ).encode("ascii") + send(INIT + extra_init + font + body + b"\n\n" + CUT) + print(f" Printed: {label}") + time.sleep(3) + +print("Running printer diagnostics -- watch the paper!\n") + +print("Test 1: Font A, no GS ! override (baseline -- your current setup)") +test("Font A baseline", extra_init=b"", font=FONT_A, ruler=RULER_42) + +print("Test 2: Font A + GS ! 0x00 (force normal 1x1 character size)") +test("Font A + GS!00", extra_init=SIZE_NORM, font=FONT_A, ruler=RULER_42) + +print("Test 3: Font B (smaller -- expect 56 chars/line)") +test("Font B baseline", extra_init=b"", font=FONT_B, ruler=RULER_56) + +print("Test 4: Font B + GS ! 0x00") +test("Font B + GS!00", extra_init=SIZE_NORM, font=FONT_B, ruler=RULER_56) + +print("\nDone. Count where the ruler wraps on each strip.") +print("Report which test gave 42+ chars and we'll lock that in.") diff --git a/diagnose2.py b/diagnose2.py new file mode 100644 index 0000000..9850026 --- /dev/null +++ b/diagnose2.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Printer diagnostic v2 — tests print area width, character spacing, +and direct USB device access on Epson TM-T88V. +""" +import subprocess, sys, time, glob + +PRINTER = "receipt" + +ESC = b'\x1b' +GS = b'\x1d' + +INIT = ESC + b'\x40' +FONT_A = ESC + b'\x4d\x00' +FONT_B = ESC + b'\x4d\x01' +SIZE_NORM = GS + b'\x21\x00' # GS ! 0 — 1x1 character size +SP_ZERO = ESC + b'\x20\x00' # ESC SP 0 — zero right-side char spacing +# GS L nL nH — set left margin to 0 +MARGIN_L = GS + b'\x4c\x00\x00' +# GS W nL nH — set print area width +# 576 dots = full 80mm printable width (203dpi): 576 = 0x0240 -> nL=0x40 nH=0x02 +WIDTH_576 = GS + b'\x57\x40\x02' +# 512 dots — conservative full width +WIDTH_512 = GS + b'\x57\x00\x02' +# 480 dots (what 42 chars x ~11.4 dots rounds to) +WIDTH_480 = GS + b'\x57\xe0\x01' + +CUT = GS + b'\x56\x00' + +RULER_42 = "123456789|123456789|123456789|123456789|12" +RULER_56 = "123456789|123456789|123456789|123456789|123456789|123456" + +def send_lpr(data: bytes): + r = subprocess.run(["lpr", "-P", PRINTER, "-o", "raw"], + input=data, capture_output=True) + if r.returncode != 0: + print(" lpr error:", r.stderr.decode().strip(), file=sys.stderr) + return False + return True + +def send_usb(data: bytes): + """Try writing directly to the USB device, bypassing CUPS entirely.""" + devices = sorted(glob.glob("/dev/usb/lp*")) + if not devices: + print(" No /dev/usb/lp* devices found", file=sys.stderr) + return False + dev = devices[0] + print(f" Writing directly to {dev}") + try: + with open(dev, "wb") as f: + f.write(data) + return True + except PermissionError: + print(f" Permission denied on {dev} -- try: sudo chmod a+rw {dev}", file=sys.stderr) + return False + except Exception as e: + print(f" USB write error: {e}", file=sys.stderr) + return False + +def strip(label, init_seq, font=FONT_A, ruler=RULER_42, via_usb=False): + body = ( + f"\n[{label}]\n" + f"{ruler}\n" + f"The quick brown fox jumps over...\n" + f"\n" + ).encode("ascii") + data = init_seq + font + body + b"\n\n" + CUT + ok = send_usb(data) if via_usb else send_lpr(data) + status = "OK" if ok else "FAILED" + print(f" {status}: {label}") + time.sleep(3) + +print("=== Diagnostic v2: print area & spacing tests ===\n") + +print("Test 1: Baseline (same as v1 test 1) via lpr") +strip("T1 Font-A baseline lpr", + INIT + SIZE_NORM) + +print("Test 2: ESC SP 0 (zero right-side character spacing)") +strip("T2 Font-A + SP=0", + INIT + SIZE_NORM + SP_ZERO) + +print("Test 3: GS L 0 + GS W 576 (full printable area)") +strip("T3 Font-A + margin=0 width=576", + INIT + SIZE_NORM + SP_ZERO + MARGIN_L + WIDTH_576) + +print("Test 4: GS W 512") +strip("T4 Font-A + width=512", + INIT + SIZE_NORM + SP_ZERO + MARGIN_L + WIDTH_512) + +print("Test 5: Font B + full area reset") +strip("T5 Font-B + SP=0 + width=576", + INIT + SIZE_NORM + SP_ZERO + MARGIN_L + WIDTH_576, + font=FONT_B, ruler=RULER_56) + +print("\nTest 6: Direct USB write (bypasses CUPS entirely) -- Font A") +strip("T6 Font-A direct USB", + INIT + SIZE_NORM + SP_ZERO, + via_usb=True) + +print("\nDone.") +print("Key question: does Test 6 (direct USB) give more chars than Test 1?") +print("If yes -> CUPS is modifying the raw data even with -o raw.") +print("If all same -> printer NV memory has a constrained print area.") +print("Report what you see on each strip.") diff --git a/fixwidth.py b/fixwidth.py new file mode 100644 index 0000000..deca26b --- /dev/null +++ b/fixwidth.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +TM-T88V paper width fix tool. +Writes directly to /dev/usb/lp0, bypassing CUPS entirely. + +Step 1: Direct USB test print (safe, no NV writes) +Step 2: NV paper-width reset to 80mm (writes to printer NV memory) +""" +import sys, time + +ESC = b'\x1b' +GS = b'\x1d' + +INIT = ESC + b'\x40' +FONT_A = ESC + b'\x4d\x00' +FONT_B = ESC + b'\x4d\x01' +SIZE_NORM = GS + b'\x21\x00' +SP_ZERO = ESC + b'\x20\x00' +# GS W 576 = full 80mm printable area (576 = 0x0240) +WIDTH_576 = GS + b'\x57\x40\x02' +# GS W 512 +WIDTH_512 = GS + b'\x57\x00\x02' +CUT = GS + b'\x56\x00' + +DEV = "/dev/usb/lp0" + +RULER42 = "123456789|123456789|123456789|123456789|12" +RULER56 = "123456789|123456789|123456789|123456789|123456789|123456" + +def write_dev(data: bytes): + with open(DEV, "wb") as f: + f.write(data) + +def test_print(): + """Step 1 — safe diagnostic, no NV changes.""" + body = ( + "\n[DIRECT USB - Font A + GS W 576]\n" + f"{RULER42}\n" + "The quick brown fox jumps over the lazy dog\n" + "\n" + "[DIRECT USB - Font B + GS W 576]\n" + f"{RULER56}\n" + "The quick brown fox jumps over the lazy dog\n" + "\n" + ).encode("ascii") + + data = (INIT + SIZE_NORM + SP_ZERO + WIDTH_576 + + FONT_A + body + + b"\n\n" + CUT) + write_dev(data) + print("Test print sent directly via USB (no CUPS).") + print("Count the ruler chars on each line and report back.") + +# ── NV paper-width commands ─────────────────────────────────────────────────── +# GS ( E — Set customized value (persists in NV across power cycles) +# Format: GS ( E pL pH fn [data...] +# pL pH = little-endian length of (fn + data) +# +# TM-T88V firmware 30.xx: +# fn=2 : Set paper type +# data : 0x00 = 80mm/RP80, 0x01 = 58mm/RP58 +# +# This writes to NV — takes effect after power cycle. +def gs_e(fn: int, data: bytes) -> bytes: + payload = bytes([fn]) + data + pL = len(payload) & 0xFF + pH = (len(payload) >> 8) & 0xFF + return GS + b'\x28\x45' + bytes([pL, pH]) + payload + +NV_80MM = gs_e(0x02, b'\x00') # fn=2, param=0x00 → 80mm paper +NV_58MM = gs_e(0x02, b'\x01') # fn=2, param=0x01 → 58mm paper (for reference) + +def nv_fix(): + """Step 2 — write 80mm paper width to NV memory, then test.""" + print("Sending NV paper-width reset to 80mm...") + # Send the NV command alone first, then reinit and test + data = ( + INIT + + NV_80MM + # set 80mm in NV + INIT + # re-init so new setting takes effect now + SIZE_NORM + SP_ZERO + WIDTH_576 + FONT_A + + ( + "\n[NV SET: 80mm - if this is 42 chars wide, it worked]\n" + f"{RULER42}\n" + "The quick brown fox jumps over the lazy dog\n" + "\n" + ).encode("ascii") + + b"\n\n" + CUT + ) + write_dev(data) + print("NV write + test strip sent.") + print("If the ruler shows 42 chars: power-cycle the printer to confirm it sticks.") + print("If still 30 chars: the fn=2/0x00 command didn't apply; tell me and we'll try alternate byte sequences.") + +if __name__ == "__main__": + cmd = sys.argv[1] if len(sys.argv) > 1 else "test" + try: + if cmd == "test": + test_print() + elif cmd == "fix": + nv_fix() + elif cmd == "fix58": + # Emergency restore to 58mm if needed + write_dev(INIT + NV_58MM + INIT + b"\n[restored to 58mm]\n\n" + CUT) + print("Restored to 58mm NV setting.") + else: + print("Usage: fixwidth.py [test|fix|fix58]") + except PermissionError: + print(f"Permission denied on {DEV}") + print(f"Run: sudo chmod a+rw {DEV} then retry") + except FileNotFoundError: + print(f"{DEV} not found — run: sudo modprobe usblp") diff --git a/fixwidth2.py b/fixwidth2.py new file mode 100644 index 0000000..e11bfd7 --- /dev/null +++ b/fixwidth2.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +TM-T88V paper width NV fix — attempt 2. +Tries multiple memory switch group/value combinations. +Writes directly to /dev/usb/lp0. +""" +import sys, time + +ESC = b'\x1b' +GS = b'\x1d' + +INIT = ESC + b'\x40' +FONT_A = ESC + b'\x4d\x00' +CUT = GS + b'\x56\x00' +DEV = "/dev/usb/lp0" + +RULER = "123456789|123456789|123456789|123456789|12" + +def write_dev(data: bytes): + with open(DEV, "wb") as f: + f.write(data) + +def gs_e(fn: int, data: bytes) -> bytes: + """Build GS ( E pL pH fn [data]""" + payload = bytes([fn]) + data + pL = len(payload) & 0xFF + pH = (len(payload) >> 8) & 0xFF + return GS + b'\x28\x45' + bytes([pL, pH]) + payload + +def strip(label: str, nv_cmd: bytes): + body = ( + f"\n[{label}]\n" + f"{RULER}\n" + ).encode("ascii") + write_dev(INIT + nv_cmd + INIT + FONT_A + body + b"\n\n" + CUT) + time.sleep(4) + +# ── Memory switch (fn=3): GS ( E 03 00 03 ────────────────── +# group 1-8 control different settings; one of them holds paper width. +# Trying both 0x00 (all bits clear = 80mm) and 0x80 (bit7 = 80mm candidate). +MSW_TESTS = [ + # (label, group, value) + ("fn3 grp1 val=0x00", 3, 1, 0x00), + ("fn3 grp2 val=0x00", 3, 2, 0x00), + ("fn3 grp3 val=0x00", 3, 3, 0x00), + ("fn3 grp4 val=0x00", 3, 4, 0x00), + ("fn3 grp5 val=0x00", 3, 5, 0x00), + ("fn3 grp6 val=0x00", 3, 6, 0x00), + ("fn3 grp7 val=0x00", 3, 7, 0x00), + ("fn3 grp8 val=0x00", 3, 8, 0x00), + # Same groups, value=0x80 in case the width bit is bit 7 + ("fn3 grp6 val=0x80", 3, 6, 0x80), + ("fn3 grp7 val=0x80", 3, 7, 0x80), + # Customized values (fn=5): GS ( E 03 00 05 + ("fn5 id=0x01 val=0", 5, 0x01, 0x00), + ("fn5 id=0x02 val=0", 5, 0x02, 0x00), + ("fn5 id=0x03 val=0", 5, 0x03, 0x00), + ("fn5 id=0x04 val=0", 5, 0x04, 0x00), +] + +if __name__ == "__main__": + print("Sending NV test strips. Watch for the first one that prints 42 chars wide.") + print("Each strip has its label printed on it. Power-cycle after the winning one.\n") + for entry in MSW_TESTS: + label, fn, param, val = entry + nv = gs_e(fn, bytes([param, val])) + print(f" Trying: {label}") + strip(label, nv) + print("\nDone. Tell me which label was the first to print 42 chars wide.") + print("If none worked, all strips will be 30 chars and we need a different approach.") diff --git a/morningedition.py b/morningedition.py new file mode 100755 index 0000000..d24e8ae --- /dev/null +++ b/morningedition.py @@ -0,0 +1,552 @@ +#!/usr/bin/env python3 +""" +Morning Edition - Daily receipt printer for Bridgeland, Cypress TX +Prints to Epson TM-T88V via CUPS printer 'receipt' +Designed for 80mm paper (42 chars wide) + +Usage: + morningedition.py -- print to receipt printer + morningedition.py --preview -- dump to stdout for testing +""" + +import subprocess +import datetime +import json +import urllib.request +import urllib.error +import xml.etree.ElementTree as ET +import sys +import textwrap + +# ── Config ───────────────────────────────────────────────────────────────── +PRINTER = "receipt" +WIDTH = 30 # printer NV is locked to 58mm mode (30 chars); change to 42 after fixing printer to 80mm +ZIPCODE = "77433" +CITY_LINE = "Bridgeland, Cypress TX 77433" +NAME = "Rich" + +# ── ESC/POS commands ──────────────────────────────────────────────────────── +ESC = b'\x1b' +GS = b'\x1d' + +INIT = ESC + b'\x40' # Initialize printer +FONT_A = ESC + b'\x4d\x00' # Font A 12x24 — 42 chars/line at 80mm +BOLD_ON = ESC + b'\x45\x01' +BOLD_OFF = ESC + b'\x45\x00' +ALIGN_L = ESC + b'\x61\x00' +ALIGN_C = ESC + b'\x61\x01' +CUT = GS + b'\x56\x00' # Full cut + +# ── Weather ───────────────────────────────────────────────────────────────── + +def get_weather(): + try: + url = f"https://wttr.in/{ZIPCODE}?format=j1" + req = urllib.request.Request(url, headers={"User-Agent": "morningedition/1.0"}) + resp = urllib.request.urlopen(req, timeout=15) + data = json.loads(resp.read().decode()) + + cc = data["current_condition"][0] + day = data["weather"][0] + ast = day.get("astronomy", [{}])[0] + + return { + "temp_f" : cc["temp_F"], + "feels_f" : cc["FeelsLikeF"], + "desc" : cc["weatherDesc"][0]["value"], + "humidity" : cc["humidity"], + "wind_mph" : cc["windspeedMiles"], + "wind_dir" : cc["winddir16Point"], + "visibility" : cc["visibility"], + "uv" : cc["uvIndex"], + "hi_f" : day["maxtempF"], + "lo_f" : day["mintempF"], + "sunrise" : ast.get("sunrise", "?"), + "sunset" : ast.get("sunset", "?"), + } + except Exception as e: + print(f"[weather error] {e}", file=sys.stderr) + return None + + +def weather_art(desc): + d = desc.lower() + if any(w in d for w in ["sunny", "clear"]): + return [ + r" \ | / ", + r" .---. ", + r" --- ( * * * ) --- ", + r" `---' ", + r" / | \ ", + ] + elif "partly" in d: + return [ + r" \ .---. ", + r" --- ( sun ) ", + r" `---' )-. ", + r" .-~~~{ cloudy } ", + r" `-.__________.-' ", + ] + elif any(w in d for w in ["overcast", "cloudy"]): + return [ + r" .-~~~~-. ", + r" .-{ }-. ", + r"{ overcast } ", + r" `-.________.-' ", + r" ", + ] + elif any(w in d for w in ["thunder", "storm"]): + return [ + r" .-~~~~-. ", + r" .-{ STORM }-. ", + r" `-.________.-' ", + r" /\ ", + r" / \ ", + ] + elif any(w in d for w in ["rain", "shower", "drizzle"]): + return [ + r" .-~~~~-. ", + r" .-{ rain }-. ", + r" `-.________.-' ", + r" ' ' ' ' ' ' ' ", + r" ' ' ' ' ' ' ' ", + ] + elif any(w in d for w in ["fog", "mist", "haze"]): + return [ + r" ~ ~ ~ ~ ~ ~ ~ ~ ", + r" ~ foggy / hazy ~ ", + r" ~ ~ ~ ~ ~ ~ ~ ~ ", + r" ~ ~ ~ ~ ~ ~ ~ ~ ", + r" ~ ~ ~ ~ ~ ~ ~ ~ ", + ] + else: + return [ + r" \ | / ", + r" .---. ", + r" --- ( ) --- ", + r" `---' ", + r" / | \ ", + ] + + +def uv_label(uv): + uv = int(uv) + if uv <= 2: return f"{uv} - Low" + if uv <= 5: return f"{uv} - Moderate" + if uv <= 7: return f"{uv} - High" + if uv <= 10: return f"{uv} - Very High" + return f"{uv} - Extreme!" + + +def get_weather_tip(weather): + if not weather: + return "Have a safe and productive day!" + desc = weather["desc"].lower() + temp = int(weather["temp_f"]) + if any(w in desc for w in ["thunder", "storm"]): + return "Stay safe! Monitor weather alerts today." + if any(w in desc for w in ["rain", "shower", "drizzle"]): + return "Grab an umbrella -- Houston rain waits for no one!" + if any(w in desc for w in ["fog", "mist"]): + return "Allow extra drive time -- visibility may be low." + if temp >= 95: + return "Stay hydrated! Carry water everywhere today." + if temp <= 45: + return "Layer up -- don't let the cold catch you off guard." + if int(weather.get("uv", 0)) >= 7: + return "High UV today -- sunscreen before you head out!" + if int(weather.get("humidity", 0)) >= 85: + return "Steamy one today -- moisture is high out there." + return "Make it a great day -- you've got this!" + + +# ── News ───────────────────────────────────────────────────────────────────── + +def fetch_rss_headlines(url, count=5): + try: + req = urllib.request.Request(url, headers={"User-Agent": "morningedition/1.0"}) + resp = urllib.request.urlopen(req, timeout=15) + tree = ET.fromstring(resp.read()) + items = tree.findall(".//item")[:count] + headlines = [] + for item in items: + t = item.find("title") + if t is not None and t.text: + # Strip CDATA, collapse whitespace, ASCII-ify + text = t.text.strip() + text = text.encode("ascii", errors="replace").decode("ascii") + headlines.append(text) + return headlines + except Exception as e: + print(f"[rss error {url}] {e}", file=sys.stderr) + return [] + + +# ── Rotating daily content ─────────────────────────────────────────────────── + +QUOTES = [ + ("The best time to plant a tree was 20 years ago. The second best time is now.", + "Chinese Proverb"), + ("Every morning we are born again. What we do today matters most.", + "Buddha"), + ("With the new day comes new strength and new thoughts.", + "Eleanor Roosevelt"), + ("You have brains in your head. You have feet in your shoes. You can steer yourself any direction you choose.", + "Dr. Seuss"), + ("Make each day your masterpiece.", + "John Wooden"), + ("Do something today that your future self will thank you for.", + "Sean Patrick Flanery"), + ("Start where you are. Use what you have. Do what you can.", + "Arthur Ashe"), + ("Success is the sum of small efforts repeated day in and day out.", + "Robert Collier"), + ("Believe you can and you're halfway there.", + "Theodore Roosevelt"), + ("The secret of getting ahead is getting started.", + "Mark Twain"), + ("What lies behind us and before us are tiny matters compared to what lies within us.", + "Ralph Waldo Emerson"), + ("The only way to do great work is to love what you do.", + "Steve Jobs"), + ("Life is 10% what happens to you and 90% how you react to it.", + "Charles R. Swindoll"), + ("In the middle of every difficulty lies opportunity.", + "Albert Einstein"), + ("It always seems impossible until it's done.", + "Nelson Mandela"), + ("Don't watch the clock; do what it does. Keep going.", + "Sam Levenson"), + ("Opportunities don't happen, you create them.", + "Chris Grosser"), + ("Wake up determined. Go to bed satisfied.", + "Unknown"), + ("Your imagination is your preview of life's coming attractions.", + "Albert Einstein"), + ("The future belongs to those who believe in the beauty of their dreams.", + "Eleanor Roosevelt"), + ("It does not matter how slowly you go as long as you do not stop.", + "Confucius"), + ("Hardships often prepare ordinary people for an extraordinary destiny.", + "C.S. Lewis"), +] + +JOKES = [ + ("Why don't scientists trust atoms?", + "Because they make up everything!"), + ("Why did the scarecrow win an award?", + "He was outstanding in his field!"), + ("Why can't you give Elsa a balloon?", + "Because she'll let it go!"), + ("What do you call fake spaghetti?", + "An impasta!"), + ("Why did the bicycle fall over?", + "Because it was two-tired!"), + ("What's a skeleton's least favorite room?", + "The living room!"), + ("Why do cows wear bells?", + "Because their horns don't work!"), + ("Why don't eggs tell jokes?", + "They'd crack each other up!"), + ("What do you call a fish without eyes?", + "A fsh!"), + ("Why did the math book look so sad?", + "Because it had too many problems!"), + ("What do you get when you cross a snowman and a vampire?", + "Frostbite!"), + ("Why couldn't the leopard play hide and seek?", + "Because he was always spotted!"), + ("What do you call cheese that isn't yours?", + "Nacho cheese!"), + ("Why did the golfer bring extra socks?", + "In case he got a hole in one!"), + ("What has ears but cannot hear?", + "A cornfield!"), + ("Why did the coffee file a police report?", + "It got mugged!"), + ("What do you call a pile of cats?", + "A meowtain!"), + ("How do trees access the internet?", + "They log in!"), + ("Why was the belt arrested?", + "It was holding up a pair of pants!"), + ("What do you call a sleeping dinosaur?", + "A dino-snore!"), + ("Why did the stadium get hot after the game?", + "All the fans left!"), + ("What do you call a bear with no teeth?", + "A gummy bear!"), + ("Why did the tomato turn red?", + "Because it saw the salad dressing!"), +] + +FACTS = [ + "A group of flamingos is called a 'flamboyance.'", + "Honey never spoils. Edible honey was found in 3,000-year-old Egyptian tombs.", + "Octopuses have THREE hearts and blue blood.", + "Crows can recognize and remember human faces.", + "Bananas are technically berries. Strawberries are not.", + "A day on Venus is longer than a year on Venus.", + "Wombats are the only animals that produce cube-shaped droppings.", + "Sharks existed before trees. Sharks: 450M yrs. Trees: 350M yrs.", + "Butterflies taste with their feet.", + "The shortest war in history lasted 38-45 minutes (Anglo-Zanzibar War, 1896).", + "Oxford University is older than the Aztec Empire.", + "A snail can sleep for 3 years at a stretch.", + "Cleopatra lived closer in time to the Moon landing than to the pyramids.", + "Nintendo was founded in 1889 -- as a playing card company.", + "The scent of rain on dry earth is called 'petrichor.'", + "Humans share about 60% of their DNA with bananas.", + "Sloths can hold their breath longer than dolphins -- up to 40 minutes.", + "A bolt of lightning is 5x hotter than the surface of the Sun.", + "The human nose can detect over 1 TRILLION different scents.", + "Goats have rectangular pupils to give them near-360-degree vision.", + "A group of owls is called a 'parliament.'", + "There are more possible chess games than atoms in the observable universe.", + "Dolphins sleep with one eye open.", + "Polar bear fur is actually transparent, not white.", + "Sea otters hold hands while sleeping so they don't drift apart.", + "An ostrich's eye is bigger than its brain.", + "Male seahorses carry and give birth to the young.", + "Platypuses don't have stomachs -- food goes straight to the intestine.", + "A group of crows is called a 'murder.'", + "The fingerprints of a koala are virtually identical to human fingerprints.", + "You can't hum while holding your nose closed.", + "Cats have fewer toes on their back paws than their front paws.", +] + +HOUSTON_TIPS = [ + "IAH tip: Terminal C is usually least crowded for TSA.", + "Beltway 8 (Sam Houston Tollway) runs 24/7 -- great I-10 bypass.", + "Bridgeland: Lakeland Activity Center open 5am-10pm weekdays.", + "H-E-B on Fry Rd is restocked overnight -- mornings = freshest produce.", + "Houston weather reminder: if you don't like it, wait 20 minutes.", + "Buc-ee's on I-10 in Katy -- best beaver nuggets in Texas. Just saying.", + "Bluebonnet season peaks late March -- drive FM 1093 for the show.", + "Houston Medical Center is the largest in the world. Right in our backyard.", + "Buffalo Bayou Park has great sunrise walks if you're up early.", + "Hike-and-bike trails around Bridgeland Lake -- 60+ miles of paths.", + "Discovery Green downtown hosts free outdoor events most weekends.", + "NASA Johnson Space Center is 45 min southeast -- worth a visit!", + "Hermann Park's McGovern Lake paddle boats open at 10am weekends.", +] + +ON_THIS_DAY = { + # month-day: (year, event) + "04-03": (1973, "First handheld cell phone call made by Martin Cooper."), + "07-04": (1776, "United States Declaration of Independence adopted."), + "07-20": (1969, "Apollo 11 lands on the Moon."), + "12-17": (1903, "Wright Brothers make first successful airplane flight."), + "01-01": (2000, "Y2K passes without incident -- the world breathes a sigh of relief."), + "02-14": (1876, "Alexander Graham Bell applies for the telephone patent."), + "03-14": (1879, "Albert Einstein is born in Ulm, Germany."), + "10-31": (1517, "Martin Luther posts 95 Theses, sparking the Reformation."), + "11-22": (1963, "JFK assassinated in Dallas, TX."), + "05-05": (1961, "Alan Shepard becomes first American in space."), + "06-06": (1944, "D-Day: Allied forces land at Normandy, France."), + "08-06": (1945, "Atomic bomb dropped on Hiroshima, Japan."), + "09-11": (2001, "Terrorist attacks on the World Trade Center, Pentagon."), + "11-09": (1989, "Berlin Wall falls."), + "12-25": (0000, "Merry Christmas! Go spend time with family!"), +} + + +# ── Formatting helpers ─────────────────────────────────────────────────────── + +def ctr(text, w=WIDTH): + return text.center(w)[:w] + +def trunc(text, w=WIDTH): + """Truncate to fit on one line, no wrapping.""" + text = text.replace("\n", " ") + if len(text) > w: + return text[:w-3] + "..." + return text + +def wrap(text, w=WIDTH): + return textwrap.wrap(text.replace("\n", " "), w) + +def section(title): + return [ + "-" * WIDTH, + ctr(f"[ {title} ]"), + "-" * WIDTH, + ] + +def bullet_line(text, w=WIDTH, prefix="* "): + """Single-line bullet — truncate to fit, no wrapping.""" + return trunc(prefix + text, w) + + +# ── Receipt builder ────────────────────────────────────────────────────────── + +def build_receipt(now): + L = [] + + doy = now.timetuple().tm_yday + wday = now.weekday() # 0=Mon .. 6=Sun + is_weekend = wday >= 5 + month_day = now.strftime("%m-%d") + + # ── Masthead ────────────────────────────────────────────────────────── + L += ["=" * WIDTH] + L += [ctr("* * * MORNING EDITION * * *")] + L += ["=" * WIDTH] + L += [ctr(" __ __ ___ ____ _ _")] + L += [ctr("| \\/ || __|| _ \\| \\| |")] + L += [ctr("| |\\/| || _| | |_) | ' |")] + L += [ctr("|_| |_||___||____/|_|\\_|")] + L += ["=" * WIDTH] + L += [ctr(f"Good morning, {NAME}!")] + L += ["=" * WIDTH] + L += [""] + + # ── Date / Location ─────────────────────────────────────────────────── + L += [ctr(CITY_LINE)] + L += [ctr(now.strftime("%A, %B %d, %Y"))] + L += [ctr(now.strftime("%-I:%M %p"))] + L += [ctr(f"Day {doy} of {now.year}")] + if is_weekend: + L += [""] + L += [ctr("* * * WEEKEND EDITION * * *")] + L += [""] + + # ── On This Day ─────────────────────────────────────────────────────── + if month_day in ON_THIS_DAY: + yr, evt = ON_THIS_DAY[month_day] + L += section("ON THIS DAY") + L += [""] + if yr: + L += [ctr(f"In {yr}:")] + for line in wrap(evt): + L += [line] + L += [""] + + # ── Weather ─────────────────────────────────────────────────────────── + L += section("WEATHER - BRIDGELAND") + L += [""] + + weather = get_weather() + if weather: + art = weather_art(weather["desc"]) + for a in art: + L += [ctr(a.strip())] + L += [""] + L += [ctr(weather["desc"].upper())] + L += [""] + L += [ctr(f"Now: {weather['temp_f']}F Feels: {weather['feels_f']}F")] + L += [ctr(f"Today: Hi {weather['hi_f']}F / Lo {weather['lo_f']}F")] + L += [ctr(f"Humidity:{weather['humidity']}% UV: {uv_label(weather['uv'])}")] + L += [ctr(f"Wind: {weather['wind_mph']} mph {weather['wind_dir']}")] + L += [ctr(f"Vis: {weather['visibility']} mi")] + L += [ctr(f"Rise: {weather['sunrise']} Set: {weather['sunset']}")] + L += [""] + tip = get_weather_tip(weather) + for line in wrap(f">> {tip} <<"): + L += [ctr(line)] + else: + L += [ctr("Weather unavailable")] + L += [ctr("Check weather.gov for updates")] + + L += [""] + + # ── Houston News ────────────────────────────────────────────────────── + L += section("HOUSTON NEWS - ABC13") + L += [""] + hou_headlines = fetch_rss_headlines("https://abc13.com/feed/", 5) + if hou_headlines: + for h in hou_headlines: + L += [bullet_line(h)] + else: + L += [" (Unable to fetch headlines)"] + L += [""] + + # ── World News ──────────────────────────────────────────────────────── + L += section("WORLD NEWS - BBC") + L += [""] + bbc_headlines = fetch_rss_headlines("https://feeds.bbci.co.uk/news/rss.xml", 5) + if bbc_headlines: + for h in bbc_headlines: + L += [bullet_line(h)] + else: + L += [" (Unable to fetch headlines)"] + L += [""] + + # ── Daily Quote ─────────────────────────────────────────────────────── + L += section("THOUGHT FOR THE DAY") + L += [""] + q_text, q_author = QUOTES[doy % len(QUOTES)] + for line in wrap(f'"{q_text}"'): + L += [line] + L += [f" -- {q_author}"] + L += [""] + + # ── Joke ────────────────────────────────────────────────────────────── + L += section("MORNING CHUCKLE") + L += [""] + setup, punchline = JOKES[doy % len(JOKES)] + for line in wrap(f"Q: {setup}"): + L += [line] + L += [""] + for line in wrap(f"A: {punchline}"): + L += [line] + L += [""] + + # ── Fun Fact ────────────────────────────────────────────────────────── + L += section("DID YOU KNOW?") + L += [""] + fact = FACTS[doy % len(FACTS)] + for line in wrap(fact): + L += [line] + L += [""] + + # ── Local Houston Tip ───────────────────────────────────────────────── + L += section("LOCAL TIP") + L += [""] + for line in wrap(HOUSTON_TIPS[doy % len(HOUSTON_TIPS)]): + L += [line] + L += [""] + + # ── Footer ──────────────────────────────────────────────────────────── + L += ["=" * WIDTH] + if is_weekend: + L += [ctr("Enjoy your weekend, Rich!")] + else: + L += [ctr("Make today count!")] + L += [ctr("Bridgeland Strong!")] + L += ["=" * WIDTH] + L += [ctr(now.strftime("Printed %-I:%M %p"))] + L += [""] + L += [""] + L += [""] # feed before cut + + text = "\n".join(L) + + # Wrap text in ESC/POS framing: init → Font A → content → feed → cut + body = text.encode("ascii", errors="replace") + return INIT + FONT_A + ALIGN_L + body + b"\n\n\n" + CUT + + +# ── Print ───────────────────────────────────────────────────────────────────── + +def print_receipt(data: bytes): + cmd = ["lpr", "-P", PRINTER, "-o", "raw"] + result = subprocess.run(cmd, input=data, capture_output=True) + if result.returncode != 0: + print(f"lpr error: {result.stderr.decode()}", file=sys.stderr) + sys.exit(1) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + now = datetime.datetime.now() + data = build_receipt(now) + + if "--preview" in sys.argv: + # Strip ESC/POS bytes for preview — just print the text portion + sys.stdout.buffer.write(data[len(INIT + FONT_A + ALIGN_L):-len(b"\n\n\n" + CUT)]) + sys.stdout.buffer.write(b"\n") + else: + print_receipt(data) + print(f"Morning Edition printed at {now.strftime('%I:%M %p')} -- have a great day, {NAME}!") diff --git a/readmsw.py b/readmsw.py new file mode 100644 index 0000000..e2d5310 --- /dev/null +++ b/readmsw.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +Read TM-T88V memory switch values back from the printer via USB. +GS ( E fn=4 asks the printer to transmit its current memory switch settings. +""" +import sys, time, select + +ESC = b'\x1b' +GS = b'\x1d' +DEV = "/dev/usb/lp0" + +INIT = ESC + b'\x40' +CUT = GS + b'\x56\x00' + +def gs_e(fn: int, data: bytes = b'') -> bytes: + payload = bytes([fn]) + data + pL = len(payload) & 0xFF + pH = (len(payload) >> 8) & 0xFF + return GS + b'\x28\x45' + bytes([pL, pH]) + payload + +# fn=4: Transmit memory switch (all groups) +QUERY_MSW = gs_e(4, b'') +# fn=6: Transmit customized values +QUERY_CUSTOM = gs_e(6, b'') + +try: + f = open(DEV, "r+b", buffering=0) +except PermissionError: + print(f"Permission denied on {DEV} -- run: sudo chmod a+rw {DEV}") + sys.exit(1) + +print("Querying memory switches (fn=4)...") +f.write(INIT + QUERY_MSW) +f.flush() +time.sleep(0.5) + +# Try to read response +response = b'' +deadline = time.time() + 2.0 +while time.time() < deadline: + r, _, _ = select.select([f], [], [], 0.2) + if r: + chunk = f.read(64) + if chunk: + response += chunk + else: + if response: + break + +if response: + print(f"Memory switch response ({len(response)} bytes):") + print(" hex:", response.hex()) + print(" dec:", list(response)) + print() + # GS ( E fn=4 response format: header + 8 bytes (one per MSW group) + # Find the data bytes after any header + for i, b in enumerate(response): + print(f" byte[{i}] = 0x{b:02x} ({b:08b}b)") +else: + print("No response to fn=4 query.") + +print() +print("Querying customized values (fn=6)...") +f.write(QUERY_CUSTOM) +f.flush() +time.sleep(0.5) + +response2 = b'' +deadline = time.time() + 2.0 +while time.time() < deadline: + r, _, _ = select.select([f], [], [], 0.2) + if r: + chunk = f.read(64) + if chunk: + response2 += chunk + else: + if response2: + break + +if response2: + print(f"Customized value response ({len(response2)} bytes):") + print(" hex:", response2.hex()) + for i, b in enumerate(response2): + print(f" byte[{i}] = 0x{b:02x} ({b:08b}b)") +else: + print("No response to fn=6 query.") + +f.close()