100 lines
4.4 KiB
Python
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"]
|