"""Tracks whether the previous digest run was actually looked at. If it wasn't, the next run folds its content into the new one instead of quietly discarding it — see run.py's should_merge()/previous_section_document() and synth/llm_client.py's `previous_unviewed_digest` handling. The signal comes from Home Assistant/thinclient-agent over MQTT rather than a local file: the thing that knows whether a digest was actually shown is hosts/thin-client/agent/thinclient_agent/digest_canvas.py, which runs on a different physical machine (the thin client) from this one. MQTT is already this project's cross-host channel for exactly this kind of fact — the same broker Home Assistant and every thinclient-agent already use — so this reuses it rather than inventing a second one. This module only ever subscribes, never publishes. Publishing "viewed" is thinclient-agent's job (on_show_digest() in its main.py), triggered only by an actual full-canvas display (the "Show digest canvas" button or a voice-resolved "play my digest" request) — not by the compact HA-dashboard iframe view, which never reaches thinclient-agent at all and so cannot mark anything as viewed. That's deliberate: an iframe sitting open on someone's phone is a much weaker "was this looked at" signal than a thin client actually switching workspace and displaying it. """ import json import logging import os import threading LOG = logging.getLogger(__name__) VIEWED_TOPIC = "smarthome/digest/viewed" def last_viewed_at(): """ISO timestamp of the last time any thin client showed a digest, or None. None covers every failure mode identically on purpose (no broker reachable, nothing ever published, a malformed retained payload) — the caller treats "unknown" the same as "viewed", the safe default. Treating unknown as "unviewed" instead would mean a broken MQTT link silently glues every run onto the last one forever; treating it as "viewed" just means at most one run's content is dropped before someone notices the broker is unreachable, and OPNsense-style backstops elsewhere in this codebase never take the riskier default either. """ host = os.environ.get("MQTT_BROKER_HOST", "mosquitto") port = int(os.environ.get("MQTT_BROKER_PORT", "1883")) username = os.environ.get("MQTT_USERNAME") or None password = os.environ.get("MQTT_PASSWORD") or None wait_seconds = float(os.environ.get("MQTT_VIEWED_WAIT_SECONDS", "3")) try: import paho.mqtt.client as mqtt except ImportError: LOG.warning("paho-mqtt is not installed; cannot check whether the last digest was viewed") return None result = {"value": None} received = threading.Event() def on_message(_client, _userdata, message): try: payload = json.loads(message.payload.decode("utf-8")) result["value"] = payload.get("viewed_at") except (ValueError, AttributeError): LOG.warning("malformed retained payload on %s: %r", VIEWED_TOPIC, message.payload) received.set() def on_connect(client, _userdata, _flags, rc, *_args): if rc != 0: LOG.warning("could not connect to MQTT broker %s:%s (rc=%s)", host, port, rc) received.set() return client.subscribe(VIEWED_TOPIC, qos=1) # Mirrors thinclient_agent.main.make_client's own VERSION1/VERSION2 handling — # bookworm's python3-paho-mqtt is 1.6.x, but this runs in digest-engine's own # container image, which may end up with a newer pip-installed paho-mqtt. callback_api = getattr(mqtt, "CallbackAPIVersion", None) client = mqtt.Client(callback_api.VERSION1) if callback_api is not None else mqtt.Client() if username: client.username_pw_set(username, password) client.on_connect = on_connect client.on_message = on_message try: client.connect(host, port, keepalive=10) client.loop_start() # A retained message arrives within a fraction of a second of subscribing, if # one exists at all. The wait is bounded so an unreachable broker cannot stall # the whole digest run — the same "never block on a remote service" rule # thinclient-agent's own connect_async already applies to itself. received.wait(wait_seconds) except Exception: LOG.warning("could not reach MQTT broker %s:%s", host, port, exc_info=True) finally: client.loop_stop() client.disconnect() return result["value"]