126 lines
4.4 KiB
Python
Executable File
126 lines
4.4 KiB
Python
Executable File
#!/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())
|