SmartestHome/hosts/touch-panel/agent/touchpanel_agent/main.py

215 lines
7.2 KiB
Python

"""touchpanel-agent entrypoint."""
from __future__ import annotations
import logging
import os
import signal
import socket
import sys
import threading
from dataclasses import dataclass
import paho.mqtt.client as mqtt
from .mpris_bridge import MprisBridge
from .mqtt_discovery import Discovery
from .sway_control import WS_HOME, WS_SPOTIFY, WS_WEB, SwayControl
CONFIG_PATH = os.environ.get("TOUCHPANEL_AGENT_CONFIG", "/etc/touchpanel-agent/config.env")
CONFIG_KEYS = (
"MQTT_BROKER_HOST",
"MQTT_BROKER_PORT",
"MQTT_USERNAME",
"MQTT_PASSWORD",
"HA_URL",
"KIOSK_USERNAME",
"TOUCHPANEL_NAME",
)
WORKSPACES = (WS_SPOTIFY, WS_HOME, WS_WEB)
log = logging.getLogger("touchpanel-agent")
@dataclass(frozen=True)
class App:
name: str
command: list[str]
process_pattern: str | None = None
workspace: str | None = None
focus_criteria: str | None = None
icon: str = "mdi:application"
def load_config(path: str = CONFIG_PATH) -> 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 as exc:
log.warning("could not read %s (%s); falling back to the environment", path, exc)
for key in CONFIG_KEYS:
if key in os.environ:
values[key] = os.environ[key]
return values
def build_apps(_config: dict[str, str]) -> dict[str, App]:
return {
"spotify": App(
name="Spotify",
command=["/usr/local/bin/spotify-launch"],
process_pattern="com.spotify.Client",
workspace=WS_SPOTIFY,
focus_criteria='app_id="spotify"',
icon="mdi:spotify",
),
"home": App(
name="Home",
# ha-kiosk is already running (started at session boot, see
# configs/sway/config) and supervises its own restart loop — this just
# switches workspace and focuses the existing window rather than
# relaunching it, since the HA dashboard is stateful. See ha-kiosk's own
# comment for why this is deliberately not a kill-and-relaunch like the
# thin client's digest canvas.
command=["/usr/local/bin/ha-kiosk"],
process_pattern="ha-kiosk",
workspace=WS_HOME,
focus_criteria='app_id="chromium.*"',
icon="mdi:home-assistant",
),
"web_browser": App(
name="web browser",
command=["/usr/local/bin/web-browser"],
workspace=WS_WEB,
icon="mdi:web",
),
}
def make_client(client_id: str) -> mqtt.Client:
# paho-mqtt 2.x requires an explicit callback API version; bookworm's
# python3-paho-mqtt is 1.6.x and has no such argument. VERSION1 is requested when
# available so the callback signatures below are identical under both.
callback_api = getattr(mqtt, "CallbackAPIVersion", None)
if callback_api is not None:
return mqtt.Client(callback_api.VERSION1, client_id=client_id)
return mqtt.Client(client_id=client_id)
def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout,
)
config = load_config()
hostname = socket.gethostname()
node_id = "".join(c if c.isalnum() else "_" for c in hostname).strip("_") or "touchpanel"
friendly_name = config.get("TOUCHPANEL_NAME") or f"Touch panel ({hostname})"
# The HA area this device sits in, published as suggested_area — see
# docs/rooms-and-endpoints.md. Blank is fine and means "no suggestion".
room = config.get("TOUCHPANEL_ROOM", "")
broker_host = config.get("MQTT_BROKER_HOST", "")
broker_port = int(config.get("MQTT_BROKER_PORT") or 1883)
sway = SwayControl()
apps = build_apps(config)
client = make_client(f"touchpanel-agent-{node_id}")
if config.get("MQTT_USERNAME"):
client.username_pw_set(config["MQTT_USERNAME"], config.get("MQTT_PASSWORD") or None)
discovery = Discovery(client, node_id, friendly_name, room)
mpris = MprisBridge(discovery.publish_media_state, sway.session_env)
def on_launch(key: str) -> None:
app = apps[key]
sway.launch_app(
app.command,
process_pattern=app.process_pattern,
workspace=app.workspace,
focus_criteria=app.focus_criteria,
)
if app.workspace:
discovery.publish_workspace(app.workspace)
def on_workspace(payload: str) -> None:
name = payload.strip()
# Enumerated, never passed through: see the security note in mqtt_discovery.py.
if name not in WORKSPACES:
log.warning("ignoring unknown workspace %r", name)
return
sway.switch_workspace(name)
discovery.publish_workspace(name)
def on_connect(_client, _userdata, _flags, rc):
if rc != 0:
log.error("MQTT connection refused (rc=%s)", rc)
return
log.info("connected to MQTT broker %s:%s", broker_host, broker_port)
discovery.register_media_player(mpris.handle_command, mpris.set_volume)
discovery.register_app_launchers(apps, on_launch)
discovery.register_workspace_select(WORKSPACES, on_workspace, WS_HOME)
discovery.subscribe_all()
discovery.publish_available(True)
def on_disconnect(_client, _userdata, rc):
log.warning("disconnected from MQTT broker (rc=%s); paho will retry", rc)
def on_message(_client, _userdata, message):
discovery.dispatch(message.topic, message.payload.decode("utf-8", "replace"))
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
client.will_set(discovery.availability_topic, "offline", qos=1, retain=True)
stop_event = threading.Event()
def handle_signal(_signum, _frame):
stop_event.set()
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
if not broker_host:
log.error("MQTT_BROKER_HOST is not set in %s — running without HA control", CONFIG_PATH)
else:
# connect_async + loop_start, never a blocking connect(): the panel must come
# up and show Spotify/HA/the browser with the container host powered off,
# same "reactive path never depends on a remote service" rule as the thin
# client's Phase 11.10.
client.connect_async(broker_host, broker_port, keepalive=60)
client.loop_start()
log.info("touchpanel-agent %s started (node_id=%s)", node_id, node_id)
if not config.get("HA_URL"):
log.warning("HA_URL is not set — the Home workspace will have nothing to show")
try:
mpris.run_forever(stop_event)
finally:
log.info("shutting down")
if broker_host:
discovery.publish_available(False)
client.loop_stop()
client.disconnect()
return 0
if __name__ == "__main__":
raise SystemExit(main())