SmartestHome/digest-engine/viewed_tracker.py

100 lines
4.4 KiB
Python

"""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"]