From cb7ad8b0fff276a1ee579bab36ddb9e777859acc Mon Sep 17 00:00:00 2001 From: Mortdecai Date: Sun, 7 Jun 2026 13:01:15 -0400 Subject: [PATCH] docs: design for HA/Google Home on-demand POS briefing trigger Replaces the never-installed cron approach with an MQTT bridge mirroring the lgtv pattern: Google Home button -> HA script/scene -> MQTT -> steel141 prints. Adds MAC-based printer discovery (printer IP is DHCP-unstable) and a gemma4 summarizer swap. Includes scripts/dryrun.py (non-printing pipeline harness). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-07-ha-google-home-trigger-design.md | 120 ++++++++++++++++++ scripts/dryrun.py | 49 +++++++ 2 files changed, 169 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-ha-google-home-trigger-design.md create mode 100644 scripts/dryrun.py diff --git a/docs/superpowers/specs/2026-06-07-ha-google-home-trigger-design.md b/docs/superpowers/specs/2026-06-07-ha-google-home-trigger-design.md new file mode 100644 index 0000000..bb109b9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-ha-google-home-trigger-design.md @@ -0,0 +1,120 @@ +# POS Briefing — Home Assistant / Google Home Trigger (Design) + +Date: 2026-06-07 +Status: Proposed — awaiting Seth's review + +## Goal + +Replace the (never-installed) cron schedule with an **on-demand** trigger: tap a +**button in Google Home** → the daily briefing prints on the Epson TM-m30. No cron. + +## Background / findings (this session) + +- "Not firing" had two causes, both now moot under this design: (1) no cron entry + was ever installed on steel141 — the "schedule 4:58am/3pm" commit only edited + SESSION.md; (2) `yfinance` was missing from steel141's system python3 → + **installed** (`pip --user --break-system-packages`, matching lgtv's PEP-668 approach). +- The pipeline itself **works end-to-end on steel141** (verified via non-printing + dry run, `scripts/dryrun.py`): FreshRSS 100 items, weather (the uncommitted + hourly rewrite), finance (yfinance), and summarization all produce a ~2.4 KB + ESC/POS receipt. `zfs`/`reddit`/`grafana` return `None` on steel141 (non-fatal; + out of scope here — see Out of Scope). +- Printer **changes IP on every restart** (router DHCP, no reservation). Currently + `192.168.0.184:9100`, MAC `50:57:9c:eb:0f:07` (Epson). Always on. + +## Architecture + +Mirrors the proven `lgtv` chain (steel141 systemd MQTT bridge + HA MQTT discovery + +free manual `google_assistant` integration). + +``` +[Google Home tap] + → google_assistant integration (already wired; acct slingshooter08; exposes LG TV switch today) + → HA script "Print Daily Briefing" (exposed to Google as a Scene → momentary button) + → MQTT publish to broker CT 301 (192.168.0.154:1883) + → pos-briefing-mqtt.service on steel141 (systemd, User=claude) + → pos_briefing.py → printer discovery (by MAC) → ESC/POS over TCP :9100 + → status/availability published back to MQTT (HA shows success/fail) +``` + +## Components + +### 1. MQTT bridge — `pos_briefing_mqtt.py` + `pos-briefing-mqtt.service` (new, steel141) +- systemd `Type=simple`, `User=claude`, `EnvironmentFile=/etc/pos-briefing/pos-briefing.env` + (holds `MQTT_PASSWORD`, root-owned 0600, never committed). `Restart=on-failure`. +- Connects to broker `192.168.0.154:1883`, user `posbriefing` (or reuse an existing + broker user — TBD at install; lgtv uses a per-device user). +- On connect: publishes **MQTT discovery** for an HA **button** entity + (`homeassistant/button/pos_briefing/config`) + availability `online`; LWT → `offline`. +- Subscribes command topic `posbriefing/button/press` (payload `PRESS`). +- On press: runs the briefing **as a subprocess** + (`/usr/bin/python3 /home/claude/bin/POS-Automation/pos_briefing.py`) so a slow/long + print never blocks the MQTT loop and a script crash can't kill the bridge. + Publishes `posbriefing/status` = `printing` → `ok` / `error:`. +- Debounce: ignore a new press while one is already in flight (prevents double prints). + +Topics: +| Topic | Dir | Payload | +|-------|-----|---------| +| `homeassistant/button/pos_briefing/config` | pub (retain) | discovery JSON | +| `posbriefing/availability` | pub (retain) | `online` / `offline` (LWT) | +| `posbriefing/button/press` | sub | `PRESS` | +| `posbriefing/status` | pub (retain) | `idle` / `printing` / `ok` / `error:` | + +### 2. Printer discovery (new, in `pos_briefing.py`) — kills the DHCP-IP problem +- Resolve current printer IP by **MAC `50:57:9c:eb:0f:07`** instead of trusting a + hardcoded `printer_ip`. +- Algorithm (lgtv "cached IP, sweep on miss"): + 1. Read cached IP from `~/.cache/pos-briefing/last_ip` (seed = config `printer_ip`). + 2. `ping -c1` it, check `ip neigh` lladdr == target MAC, and `:9100` open → use it. + 3. On miss: ARP-prime sweep the /24 (`ping -c1 -W1` fan-out), then scan `ip neigh` + for the MAC → its IP. Re-cache. + 4. Still not found → raise a clear error (bridge reports `error:printer-not-found`). +- Config gains `printer_mac`; `printer_ip` becomes a seed/fallback only. +- **Belt-and-suspenders (Seth's action, router-side):** add a DHCP reservation for + the printer MAC so the IP stops moving. Discovery makes this optional for correctness. + +### 3. HA script + Google Home exposure (Seth's HA side; I provide exact YAML/steps) +- MQTT discovery auto-creates `button.pos_briefing` (no hand YAML for the entity). +- Google's `google_assistant` integration doesn't support HA `button` entities → wrap: + an HA `script.print_daily_briefing` that presses the button. Scripts map to Google's + **Scene** trait → appear as a tappable **button** in the Google Home app (momentary). +- Add the script to the same `google_assistant` expose list that already exposes the + TV switch; re-sync. (Per lgtv DECISIONS 2026-06-06: manual integration, scopes must + include email+name, "HTTP basic auth header" OFF, Test activated by slingshooter08.) + +### 4. gemma4 summarizer swap (in `pos_briefing.py`) +- Point summarization at a **gemma4 model on steel141 Ollama** (candidate + `gemma4:26b` or `gemma4:31b-it-q4_K_M`) via the existing direct Ollama HTTP call — + change `ollama_model` in config + the prompt string. +- **Prompt convergence:** done by Claude *now*, interactively, via the `ask_gemma4` + MCP tool against real FreshRSS article samples, iterating until summaries are tight + (30–50 words, factual, no preamble). The finalized prompt string is then baked into + `pos_briefing.py` (which calls Ollama directly — it cannot call the MCP at runtime). + +### 5. Deps / install +- `yfinance` — installed (done). `paho-mqtt` — present for `claude`. Confirm both + pinned/persisted. `install.sh` mirrors lgtv: copies bridge to `/usr/local/bin`, + unit to `/etc/systemd/system`, env to `/etc/pos-briefing/`, `daemon-reload`, enable. + +## Error handling +- Printer not found / unreachable → `error:printer-not-found`; no partial print. +- Briefing subprocess nonzero exit or timeout (cap ~180s) → `error:`; bridge stays up. +- MQTT disconnect → paho auto-reconnect; LWT marks `offline` so HA/Google show it down. +- Data-source failures inside the briefing remain non-fatal (dashboard prints what it has). + +## Testing +- `scripts/dryrun.py` — full pipeline, print suppressed (already used to verify). +- Bridge unit: publish `PRESS` to the command topic by hand (`mosquitto_pub`) → observe + `status` transitions and a real receipt; confirm `button.pos_briefing` appears in HA. +- Discovery: unit-test MAC→IP resolution with cache hit, cache miss+sweep, and not-found. +- End-to-end: tap the button in Google Home → receipt prints. + +## Out of scope (noted, not fixed here) +- `zfs` (steel141 isn't the tank host — `cannot open 'tank'`), `reddit`, `grafana` + returning `None` on steel141. Track separately. +- Committing the in-tree weather rewrite is folded into this work (it's verified). + +## Open items for Seth +- Broker MQTT user for the bridge: new `posbriefing` user vs reuse existing? (install-time) +- gemma4 model choice: `26b` vs `31b-it-q4_K_M` (I'll recommend after convergence). diff --git a/scripts/dryrun.py b/scripts/dryrun.py new file mode 100644 index 0000000..4d3ff12 --- /dev/null +++ b/scripts/dryrun.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Dry-run harness: exercises the full pos_briefing pipeline WITHOUT printing. + +Monkeypatches print_receipt so nothing is sent to the TM-m30 over TCP. Reports +which data sources succeeded/failed and the receipt byte length. Use to verify +the script runs end-to-end on a host before scheduling it via cron. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import pos_briefing as pb + + +def fake_print_receipt(raw): + print(f"[dry-run] print_receipt() suppressed — {len(raw)} bytes would be sent " + f"to {pb.PRINTER_IP}:{pb.PRINTER_PORT}") + return True + + +pb.print_receipt = fake_print_receipt + + +def probe(name, fn): + try: + v = fn() + ok = v is not None and v != [] and v != {} + print(f" {'OK ' if ok else 'NIL'} {name}: {repr(v)[:120]}") + return v + except Exception as e: + print(f" ERR {name}: {type(e).__name__}: {e}") + return None + + +print("=== data source probes ===") +items, token = (pb.fetch_news() if True else (None, None)) +print(f" news: {len(items) if items else 0} items, token={'yes' if token else 'no'}") +zfs = probe("zfs", pb.get_zfs_status) +reddit = probe("reddit", pb.get_reddit_top) +grafana = probe("grafana", pb.get_grafana_metrics) +weather = probe("weather", pb.get_weather) +finance = probe("finance", pb.get_financial_snapshot) + +print("=== build + (suppressed) print ===") +articles = pb.summarize_news(items, count=5, length="30-50 words") if items else [] +print(f" articles summarized: {len(articles)}") +raw = pb.build_receipt(articles, zfs, reddit, grafana, weather, finance) +pb.print_receipt(raw) +print("=== done (nothing printed) ===")