Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 KiB
POS Briefing — HA/Google Home Trigger Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Print the daily briefing on-demand from a Google Home button (no cron), via an MQTT bridge on steel141 that mirrors the lgtv pattern, with MAC-based printer discovery and gemma4 summaries.
Architecture: Google Home → HA script (exposed as a Scene) → MQTT broker CT 301 → pos-briefing-mqtt.service (systemd, User=claude, steel141) → pos_briefing.py → printer discovered by MAC → ESC/POS over TCP. Status/availability published back to HA.
Tech Stack: Python 3.13, paho-mqtt, python-escpos, requests, yfinance, systemd, Mosquitto, Home Assistant MQTT discovery, gemma4:26b on steel141 Ollama.
Spec: docs/superpowers/specs/2026-06-07-ha-google-home-trigger-design.md
File Structure
pos_briefing.py(modify) — printer discovery by MAC; wire intoprint_receipt; gemma4 model+prompt.config.json/config.example.json(modify) — addprinter_mac,printer_subnet; setollama_model.pos_briefing_mqtt.py(create) — MQTT bridge.pos-briefing-mqtt.service(create) — systemd unit.pos-briefing.env.example(create) — env template (MQTT_PASSWORD).install.sh(create) — copy bridge/unit/env, daemon-reload, enable.tests/test_printer_discovery.py(create) — unit tests for discovery.docs/ha-setup.md(create) — Seth's HA script + Google expose steps.
Task 1: Commit the in-tree weather rewrite (clean baseline)
The working tree already has a verified hourly-weather rewrite in pos_briefing.py plus CONTEXT.md/SESSION.md edits. Lock it in before layering new work.
Files:
-
Modify (already changed):
pos_briefing.py,CONTEXT.md,SESSION.md -
Step 1: Confirm the pipeline still runs (print suppressed)
Run: cd /home/claude/bin/POS-Automation && python3 scripts/dryrun.py
Expected: OK weather: [...], articles summarized: 5, print_receipt() suppressed — NNNN bytes.
- Step 2: Commit
git add pos_briefing.py CONTEXT.md SESSION.md
git commit -m "feat: hourly weather forecast + steel141 migration notes"
gitea push
Task 2: Switch summaries to gemma4:26b (converged prompt)
Files:
-
Modify:
config.json,config.example.json -
Modify:
pos_briefing.py:171-177(theprompt_summaryblock) -
Step 1: Point the model at gemma4:26b
In config.json and config.example.json, set:
"ollama_model": "gemma4:26b"
(Both phase-1 selection and phase-2 summary use OLLAMA_MODEL.)
- Step 2: Replace the summary prompt with the converged one
In pos_briefing.py, replace the prompt_summary = (...) assignment (lines ~171-177) with:
prompt_summary = (
"You are a professional news editor writing a daily briefing for a "
"receipt printer.\n"
f"For EACH article, write a concise factual summary ({length}).\n"
"No preamble, no opinion, do not write \"this article\".\n"
"LANGUAGE RULE: Use English. Translate non-English/Spanish articles.\n"
"Return STRICTLY a JSON list: [{\"id\": 0, \"summary\": \"...\"}, ...]\n\n"
f"CONTENT:\n{content_input}"
)
(Converged via the ask_gemma4 MCP against real FreshRSS articles 2026-06-07:
gemma4:26b @ temp 0.3 produced clean 30-50-word factual summaries; the existing
re.search(r'\[.*\]', raw, re.DOTALL) parser already tolerates ```json fences.)
- Step 3: Verify end-to-end (print suppressed)
Run: python3 scripts/dryrun.py
Expected: articles summarized: 5; inspect printed summaries are factual, ~30-50 words.
- Step 4: Commit
git add config.json config.example.json pos_briefing.py
git commit -m "feat: summarize with gemma4:26b using converged prompt"
gitea push
Task 3: Printer discovery by MAC (unit-tested)
Printer IP is DHCP-unstable. Resolve it by MAC 50:57:9c:eb:0f:07, cache last-known IP, sweep on miss.
Files:
-
Modify:
config.json,config.example.json(addprinter_mac,printer_subnet) -
Modify:
pos_briefing.py(new discovery functions + constants nearPRINTER_IP) -
Create:
tests/test_printer_discovery.py -
Step 1: Add config keys
In config.json and config.example.json add:
"printer_mac": "50:57:9c:eb:0f:07",
"printer_subnet": "192.168.0"
(printer_ip stays as a seed/fallback.)
- Step 2: Write the failing test
Create tests/test_printer_discovery.py:
import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pos_briefing as pb
def test_cache_hit_returns_cached_ip(monkeypatch, tmp_path):
cache = tmp_path / "last_ip"
cache.write_text("192.168.0.50")
monkeypatch.setattr(pb, "CACHE_FILE", cache)
monkeypatch.setattr(pb, "PRINTER_MAC", "50:57:9c:eb:0f:07")
monkeypatch.setattr(pb, "_mac_of", lambda ip: "50:57:9c:eb:0f:07" if ip == "192.168.0.50" else None)
monkeypatch.setattr(pb, "_port_open", lambda ip, port, timeout=3: True)
assert pb.discover_printer() == "192.168.0.50"
def test_sweep_finds_printer_on_cache_miss(monkeypatch, tmp_path):
cache = tmp_path / "last_ip"
monkeypatch.setattr(pb, "CACHE_FILE", cache)
monkeypatch.setattr(pb, "PRINTER_IP", "192.168.0.99") # stale seed
monkeypatch.setattr(pb, "PRINTER_MAC", "50:57:9c:eb:0f:07")
monkeypatch.setattr(pb, "PRINTER_SUBNET", "192.168.0")
monkeypatch.setattr(pb, "_mac_of", lambda ip: None) # seed/cache miss
monkeypatch.setattr(pb, "_sweep_arp", lambda: {"50:57:9c:eb:0f:07": "192.168.0.184"})
monkeypatch.setattr(pb, "_port_open", lambda ip, port, timeout=3: True)
assert pb.discover_printer() == "192.168.0.184"
assert cache.read_text().strip() == "192.168.0.184"
def test_not_found_raises(monkeypatch, tmp_path):
monkeypatch.setattr(pb, "CACHE_FILE", tmp_path / "last_ip")
monkeypatch.setattr(pb, "PRINTER_IP", "")
monkeypatch.setattr(pb, "PRINTER_MAC", "50:57:9c:eb:0f:07")
monkeypatch.setattr(pb, "_mac_of", lambda ip: None)
monkeypatch.setattr(pb, "_sweep_arp", lambda: {})
try:
pb.discover_printer()
assert False, "expected RuntimeError"
except RuntimeError as e:
assert "printer-not-found" in str(e)
- Step 3: Run it to verify it fails
Run: cd /home/claude/bin/POS-Automation && python3 -m pytest tests/test_printer_discovery.py -v
Expected: FAIL (AttributeError: module 'pos_briefing' has no attribute 'discover_printer').
(If pytest missing: pip install --user --break-system-packages pytest.)
- Step 4: Implement discovery
In pos_briefing.py, after the PRINTER_PORT line (~33) add:
from pathlib import Path
PRINTER_MAC = config.get("printer_mac", "").lower()
PRINTER_SUBNET = config.get("printer_subnet", "192.168.0")
CACHE_DIR = Path.home() / ".cache" / "pos-briefing"
CACHE_FILE = CACHE_DIR / "last_ip"
Then add these functions (near print_receipt):
def _mac_of(ip):
subprocess.run(["ping", "-c1", "-W1", ip], capture_output=True)
out = subprocess.run(["ip", "neigh", "show", ip],
capture_output=True, text=True).stdout
m = re.search(r"lladdr ([0-9a-f:]{17})", out)
return m.group(1).lower() if m else None
def _port_open(ip, port, timeout=3):
try:
with socket.create_connection((ip, port), timeout=timeout):
return True
except OSError:
return False
def _sweep_arp():
"""Concurrent ping-prime the /24, then return {mac: ip} from the neigh table."""
procs = [subprocess.Popen(["ping", "-c1", "-W1", f"{PRINTER_SUBNET}.{i}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for i in range(1, 255)]
for p in procs:
p.wait()
out = subprocess.run(["ip", "neigh", "show"],
capture_output=True, text=True).stdout
found = {}
for line in out.splitlines():
m = re.match(r"([\d.]+).*lladdr ([0-9a-f:]{17})", line)
if m:
found[m.group(2).lower()] = m.group(1)
return found
def _cache_ip(ip):
CACHE_DIR.mkdir(parents=True, exist_ok=True)
CACHE_FILE.write_text(ip)
def discover_printer():
"""Return the printer's current IP, resolved by MAC. Raise if not found."""
seeds = []
if CACHE_FILE.exists():
seeds.append(CACHE_FILE.read_text().strip())
if PRINTER_IP:
seeds.append(PRINTER_IP)
for ip in seeds:
if ip and _mac_of(ip) == PRINTER_MAC and _port_open(ip, PRINTER_PORT):
_cache_ip(ip)
return ip
if PRINTER_MAC:
ip = _sweep_arp().get(PRINTER_MAC)
if ip and _port_open(ip, PRINTER_PORT):
_cache_ip(ip)
return ip
raise RuntimeError(f"printer-not-found mac={PRINTER_MAC}")
- Step 5: Run tests to verify they pass
Run: python3 -m pytest tests/test_printer_discovery.py -v
Expected: 3 passed.
- Step 6: Live sanity check
Run: python3 -c "import pos_briefing as pb; print(pb.discover_printer())"
Expected: prints 192.168.0.184 (current printer IP).
- Step 7: Commit
git add config.json config.example.json pos_briefing.py tests/test_printer_discovery.py
git commit -m "feat: discover printer by MAC (survives DHCP IP changes)"
gitea push
Task 4: Wire discovery into print_receipt
Files:
-
Modify:
pos_briefing.py:438-446(print_receipt) -
Step 1: Use discovered IP in print_receipt
Replace print_receipt with:
def print_receipt(raw_bytes):
try:
ip = discover_printer()
except Exception as e:
print(f"[!] Printer discovery failed: {e}")
return False
try:
with socket.create_connection((ip, PRINTER_PORT), timeout=10) as sock:
sock.sendall(raw_bytes)
print(f"✓ Receipt sent to {ip}:{PRINTER_PORT}")
return True
except Exception as e:
print(f"[!] Print failed: {e}")
return False
- Step 2: Real print test (printer is on at .184)
Run: python3 pos_briefing.py
Expected: ✓ Receipt sent to 192.168.0.184:9100 and a physical receipt. (This is the
first real print — confirm paper output looks right: weather block, gemma4 summaries.)
- Step 3: Commit
git add pos_briefing.py
git commit -m "feat: print to discovered printer IP"
gitea push
Task 5: MQTT bridge script
Files:
-
Create:
pos_briefing_mqtt.py -
Step 1: Write the bridge
Create pos_briefing_mqtt.py:
#!/usr/bin/env python3
"""POS Briefing MQTT bridge for Home Assistant.
Runs on steel141 as user `claude` via systemd. Subscribes to a command topic;
on PRESS, runs pos_briefing.py as a subprocess and prints the daily briefing.
Registers an HA button via MQTT discovery and reports status/availability.
Topics:
homeassistant/button/pos_briefing/config -- discovery (retained, on connect)
posbriefing/availability -- online / offline (LWT)
posbriefing/button/press -- receives: PRESS
posbriefing/status -- idle / printing / ok / error:<msg>
"""
import os
import json
import logging
import subprocess
import threading
import paho.mqtt.client as mqtt
MQTT_HOST = os.environ.get("MQTT_HOST", "192.168.0.154")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
MQTT_USER = os.environ.get("MQTT_USER", "posbriefing")
MQTT_PASS = os.environ.get("MQTT_PASSWORD", "")
BRIEFING = "/home/claude/bin/POS-Automation/pos_briefing.py"
PRINT_TIMEOUT = 180
DISCOVERY_TOPIC = "homeassistant/button/pos_briefing/config"
AVAIL_TOPIC = "posbriefing/availability"
COMMAND_TOPIC = "posbriefing/button/press"
STATUS_TOPIC = "posbriefing/status"
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [pos-mqtt] %(levelname)s %(message)s")
log = logging.getLogger(__name__)
_busy = threading.Lock()
def publish_discovery(client):
payload = {
"name": "Print Daily Briefing",
"unique_id": "pos_briefing_button",
"command_topic": COMMAND_TOPIC,
"payload_press": "PRESS",
"availability_topic": AVAIL_TOPIC,
"payload_available": "online",
"payload_not_available": "offline",
"device": {
"identifiers": ["pos_briefing"],
"name": "POS Briefing",
"manufacturer": "Seth",
"model": "TM-m30 briefing",
},
"icon": "mdi:printer-pos",
}
client.publish(DISCOVERY_TOPIC, json.dumps(payload), retain=True)
client.publish(AVAIL_TOPIC, "online", retain=True)
client.publish(STATUS_TOPIC, "idle", retain=True)
log.info("Published MQTT discovery payload")
def run_briefing(client):
if not _busy.acquire(blocking=False):
log.warning("Briefing already running; ignoring press")
return
try:
client.publish(STATUS_TOPIC, "printing", retain=True)
log.info("Running briefing...")
r = subprocess.run(["/usr/bin/python3", BRIEFING],
capture_output=True, text=True, timeout=PRINT_TIMEOUT)
if r.returncode == 0 and "Receipt sent" in r.stdout:
client.publish(STATUS_TOPIC, "ok", retain=True)
log.info("Briefing printed OK")
else:
msg = (r.stdout + r.stderr).strip().splitlines()[-1:] or ["nonzero"]
client.publish(STATUS_TOPIC, f"error:{msg[0][:80]}", retain=True)
log.error(f"Briefing failed rc={r.returncode}: {msg}")
except subprocess.TimeoutExpired:
client.publish(STATUS_TOPIC, "error:timeout", retain=True)
log.error("Briefing timed out")
finally:
_busy.release()
def on_connect(client, userdata, flags, rc):
if rc == 0:
log.info("Connected to MQTT broker")
publish_discovery(client)
client.subscribe(COMMAND_TOPIC)
log.info(f"Subscribed to {COMMAND_TOPIC}")
else:
log.error(f"MQTT connect failed: rc={rc}")
def on_message(client, userdata, msg):
cmd = msg.payload.decode().strip().upper()
log.info(f"Received: {cmd}")
if cmd == "PRESS":
threading.Thread(target=run_briefing, args=(client,), daemon=True).start()
else:
log.warning(f"Unknown command: {cmd}")
def main():
client = mqtt.Client(client_id="pos-briefing-bridge")
client.username_pw_set(MQTT_USER, MQTT_PASS)
client.on_connect = on_connect
client.on_message = on_message
client.will_set(AVAIL_TOPIC, "offline", retain=True)
log.info(f"Connecting to {MQTT_HOST}:{MQTT_PORT}...")
client.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
try:
client.loop_forever()
finally:
client.publish(AVAIL_TOPIC, "offline", retain=True)
client.disconnect()
if __name__ == "__main__":
main()
- Step 2: Syntax check
Run: python3 -m py_compile pos_briefing_mqtt.py && echo OK
Expected: OK
- Step 3: Commit
git add pos_briefing_mqtt.py
git commit -m "feat: MQTT bridge exposing an HA button to print the briefing"
gitea push
Task 6: systemd unit, env template, installer
Files:
-
Create:
pos-briefing-mqtt.service,pos-briefing.env.example,install.sh -
Modify:
.gitignore(ignore realpos-briefing.envif ever placed locally) -
Step 1: Create the unit
pos-briefing-mqtt.service:
[Unit]
Description=POS Briefing MQTT bridge for Home Assistant
Documentation=https://git.sethpc.xyz/Seth/POS-Automation
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=claude
# MQTT_PASSWORD lives here, root-owned 0600, never committed.
EnvironmentFile=/etc/pos-briefing/pos-briefing.env
ExecStart=/usr/bin/python3 /usr/local/bin/pos_briefing_mqtt.py
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
- Step 2: Create the env template
pos-briefing.env.example:
# Copy to /etc/pos-briefing/pos-briefing.env (root:root 0600). Never commit the real file.
MQTT_HOST=192.168.0.154
MQTT_PORT=1883
MQTT_USER=posbriefing
MQTT_PASSWORD=CHANGEME
- Step 3: Create the installer
install.sh:
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
sudo install -m 0755 "$HERE/pos_briefing_mqtt.py" /usr/local/bin/pos_briefing_mqtt.py
sudo install -d -m 0755 /etc/pos-briefing
if [ ! -f /etc/pos-briefing/pos-briefing.env ]; then
sudo install -m 0600 "$HERE/pos-briefing.env.example" /etc/pos-briefing/pos-briefing.env
echo "!! Edit /etc/pos-briefing/pos-briefing.env and set MQTT_PASSWORD"
fi
sudo install -m 0644 "$HERE/pos-briefing-mqtt.service" /etc/systemd/system/pos-briefing-mqtt.service
sudo systemctl daemon-reload
sudo systemctl enable pos-briefing-mqtt.service
echo "Installed. Start with: sudo systemctl start pos-briefing-mqtt.service"
- Step 4: Make executable + commit
chmod +x install.sh
git add pos-briefing-mqtt.service pos-briefing.env.example install.sh
git commit -m "feat: systemd unit + env template + installer for MQTT bridge"
gitea push
Task 7: Create broker user, install, and trigger over MQTT
Files: none (operational)
- Step 1: Create the
posbriefingMosquitto user on CT 301
ssh pve173 'pct exec 301 -- mosquitto_passwd -b /etc/mosquitto/passwd posbriefing "<NEW_PASSWORD>"'
ssh pve173 'pct exec 301 -- systemctl reload mosquitto'
(Confirm broker host/CT: 192.168.0.154 is CT 301. If passwd path differs, check
/etc/mosquitto/mosquitto.conf password_file. Use a generated password.)
- Step 2: Put the password in the env file and install
cd /home/claude/bin/POS-Automation && ./install.sh
sudo sed -i 's/^MQTT_PASSWORD=.*/MQTT_PASSWORD=<NEW_PASSWORD>/' /etc/pos-briefing/pos-briefing.env
sudo systemctl start pos-briefing-mqtt.service
sudo systemctl status pos-briefing-mqtt.service --no-pager
Expected: active (running), log line Published MQTT discovery payload.
- Step 3: Trigger a print over MQTT (no Google needed yet)
mosquitto_pub -h 192.168.0.154 -u posbriefing -P "<NEW_PASSWORD>" \
-t posbriefing/button/press -m PRESS
journalctl -u pos-briefing-mqtt.service -n 20 --no-pager
Expected: bridge logs Running briefing... → Briefing printed OK; a physical
receipt prints; posbriefing/status goes printing → ok.
- Step 4: Confirm the HA entity appeared
In HA → Settings → Devices → MQTT, confirm device POS Briefing with button Print Daily Briefing. (No commit — operational.)
Task 8: HA script + Google Home exposure (Seth's 2-minute paste)
Files:
-
Create:
docs/ha-setup.md -
Step 1: Write the HA setup doc
Create docs/ha-setup.md:
# HA + Google Home setup — POS Briefing button
The MQTT bridge auto-creates `button.print_daily_briefing` via discovery.
Google's `google_assistant` integration does not support `button` entities, so
wrap it in a script (scripts expose as a Google **Scene** = a tappable button).
## 1. Add the script (Settings → Automations & Scenes → Scripts → edit in YAML),
or paste into `scripts.yaml`:
```yaml
print_daily_briefing:
alias: Print Daily Briefing
sequence:
- service: button.press
target:
entity_id: button.print_daily_briefing
mode: single
```
## 2. Expose it to Google (same `google_assistant:` block that exposes the LG TV):
```yaml
google_assistant:
project_id: <existing>
service_account: !include <existing>
report_state: true
exposed_domains:
- switch
entity_config:
script.print_daily_briefing:
name: Print Daily Briefing
expose: true
```
(Per lgtv DECISIONS 2026-06-06: manual integration, account-linking scopes must
include `email`+`name`, "HTTP basic auth header" OFF, Test activated by
slingshooter08. The GCP OAuth consent screen is unrelated — don't touch it.)
## 3. Re-sync: say "Hey Google, sync my devices" or in the Home app re-run setup.
The button appears as **Print Daily Briefing**; tapping it prints. Optionally show
`sensor`-style status by adding an MQTT sensor on `posbriefing/status`.
- Step 2: Commit
git add docs/ha-setup.md
git commit -m "docs: HA script + Google Home exposure steps"
gitea push
- Step 3: Hand to Seth
Tell Seth: paste the script + entity_config from docs/ha-setup.md, reload, and
say "Hey Google, sync my devices." Then tap the button to verify end-to-end.
Task 9: Cleanup + persistence
Files:
-
Modify:
CONTEXT.md,SESSION.md,DECISIONS.md(create if absent) -
Step 1: Remove the stale cron section from CONTEXT.md
Delete the ## Cron block (the 30 4 * * * line) and replace with:
## Trigger
On-demand only. Google Home button → HA script → MQTT (CT 301) →
pos-briefing-mqtt.service on steel141 → pos_briefing.py. No cron. See
docs/superpowers/specs/2026-06-07-ha-google-home-trigger-design.md.
- Step 2: Record decisions
Append to (or create) DECISIONS.md:
- 2026-06-07: On-demand trigger via MQTT bridge (lgtv pattern), not cron — Seth
wants a Google Home button. Bridge: pos-briefing-mqtt.service, User=claude.
- 2026-06-07: Printer discovered by MAC 50:57:9c:eb:0f:07 — DHCP IP is unstable
(no reservation). Cache last IP, sweep /24 on miss.
- 2026-06-07: Summaries via gemma4:26b (faster than 31b; quality verified via
ask_gemma4 MCP convergence).
- 2026-06-07: yfinance installed on steel141 (pip --user --break-system-packages).
- Step 3: Commit
git add CONTEXT.md SESSION.md DECISIONS.md
git commit -m "docs: retire cron, record MQTT/discovery/gemma4 decisions"
gitea push
Self-Review
- Spec coverage: MQTT bridge (T5-7), printer discovery (T3-4), gemma4 (T2), HA/Google (T8), deps (yfinance done; pytest/paho noted), weather rewrite (T1), cleanup (T9). All spec sections mapped.
- Open items resolved: broker user = dedicated
posbriefing(T7); model =gemma4:26b(T2). - Type/name consistency:
discover_printer,_mac_of,_port_open,_sweep_arp,_cache_ip,CACHE_FILE,PRINTER_MAC,PRINTER_SUBNETused consistently across T3/T4 and the tests. Topics consistent across T5/T7/T8. - Out of scope (unchanged): zfs/reddit/grafana = None on steel141.