#!/usr/bin/env python3 """Feeds the eww weather-overlay widget one JSON object per change on `smarthome/weather/current`. Installed to /usr/local/bin/weather-json, consumed by eww's `deflisten` (see configs/eww/eww.yuck). Session-scoped and independent of thinclient_agent — same reasoning as now-playing-json: this dies with sway, thinclient_agent is a system service that must survive it, and wiring the two together would mean either a second inbound control channel into the agent (which mqtt_discovery.py's security boundary rules out) or restarting the agent whenever this widget restarts. MQTT credentials are just re-read from the same config.env thinclient_agent itself reads; this script never publishes anything and never touches that file. DATA CONTRACT: `smarthome/weather/current`, retained, a small JSON object `{"temperature": "...", "condition": "...", "location": "..."}` — published by a Home Assistant automation this repo does not build, see hosts/thin-client/README.md's "Idle-gallery weather overlay" section for the worked example. Household-wide, not per-thin-client, same reasoning as thinclient_agent/main.py's DIGEST_VIEWED_TOPIC: weather is one household-wide fact, not something scoped to whichever room happens to be asking. Degrades to {"available": false} — the clock/date still show, only the weather line disappears, see eww.yuck — whenever MQTT is unreachable or nothing has ever been published to the topic yet. Never blocks the rest of the widget on it. """ from __future__ import annotations import json import os import sys import paho.mqtt.client as mqtt CONFIG_PATH = os.environ.get("THINCLIENT_AGENT_CONFIG", "/etc/thinclient-agent/config.env") TOPIC = "smarthome/weather/current" def load_config(path: str) -> dict[str, str]: values: dict[str, str] = {} try: with open(path, encoding="utf-8") as handle: for line in handle: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") values[key.strip()] = value.strip().strip('"').strip("'") except OSError: pass return values def emit(state: dict) -> None: sys.stdout.write(json.dumps(state) + "\n") sys.stdout.flush() def unavailable() -> dict: return {"available": False, "temperature": "", "condition": "", "location": ""} def parse(payload: bytes) -> dict: try: data = json.loads(payload.decode("utf-8", "replace")) except ValueError: return unavailable() if not isinstance(data, dict): return unavailable() return { "available": True, "temperature": str(data.get("temperature") or ""), "condition": str(data.get("condition") or ""), "location": str(data.get("location") or ""), } def make_client() -> mqtt.Client: callback_api = getattr(mqtt, "CallbackAPIVersion", None) if callback_api is not None: return mqtt.Client(callback_api.VERSION1, client_id="weather-json") return mqtt.Client(client_id="weather-json") def main() -> int: config = load_config(CONFIG_PATH) broker_host = config.get("MQTT_BROKER_HOST", "") if not broker_host: # No broker configured at all — emit once and idle rather than spin # retrying a connection that was never going to happen. emit(unavailable()) return 0 broker_port = int(config.get("MQTT_BROKER_PORT") or 1883) client = make_client() if config.get("MQTT_USERNAME"): client.username_pw_set(config["MQTT_USERNAME"], config.get("MQTT_PASSWORD") or None) def on_connect(_client, _userdata, _flags, rc): if rc == 0: client.subscribe(TOPIC, qos=1) else: emit(unavailable()) def on_disconnect(_client, _userdata, rc): emit(unavailable()) def on_message(_client, _userdata, message): emit(parse(message.payload)) client.on_connect = on_connect client.on_disconnect = on_disconnect client.on_message = on_message emit(unavailable()) # connect_async, never a blocking connect(): a thin client with the container # host powered off must still show its clock, same "never stall the session # on a remote service" rule Phase 11.10 applies everywhere else on this image. client.connect_async(broker_host, broker_port, keepalive=60) client.loop_forever() return 0 if __name__ == "__main__": raise SystemExit(main())