SmartestHome/hosts/thin-client/agent/thinclient_agent/main.py

359 lines
14 KiB
Python

"""thinclient-agent entrypoint."""
from __future__ import annotations
import json
import logging
import os
import signal
import socket
import sys
import threading
from dataclasses import dataclass
from datetime import datetime, timezone
import paho.mqtt.client as mqtt
from .admin_canvas import AdminCanvas
from .audio_control import AudioControl
from .capture_control import CaptureControl, find_audio_card
from .digest_canvas import DETAIL_LEVELS, DigestCanvas
from .display_power import DisplayPower
from .input_control import InputControl
from .mpris_bridge import MprisBridge
from .mqtt_discovery import Discovery
from .remote_desktop import RemoteDesktop
from .sway_control import WS_ADMIN, WS_CAPTURE, WS_DIGEST, WS_MEDIA, WS_WEB, SwayControl
CONFIG_PATH = os.environ.get("THINCLIENT_AGENT_CONFIG", "/etc/thinclient-agent/config.env")
CONFIG_KEYS = (
"MQTT_BROKER_HOST",
"MQTT_BROKER_PORT",
"MQTT_USERNAME",
"MQTT_PASSWORD",
"HA_URL",
"DIGEST_WEB_URL",
"ADMIN_WEB_URL",
"KIOSK_USERNAME",
"THINCLIENT_NAME",
)
WORKSPACES = (WS_WEB, WS_DIGEST, WS_MEDIA, WS_ADMIN, WS_CAPTURE)
# How often the background thread re-scans for capture cards (hot-plugged, not
# just present at boot) and republishes the HA select's options if they changed.
# See main()'s poll_capture_devices().
CAPTURE_POLL_SECONDS = 20
# Fixed, household-wide — not under this device's own thinclient/<node_id> namespace.
# "Was the digest looked at" is a fact about the digest, not about this specific
# machine, and digest-engine (a different physical host, see
# digest-engine/viewed_tracker.py) has no reason to know or care which of possibly
# several thin clients showed it.
DIGEST_VIEWED_TOPIC = "smarthome/digest/viewed"
log = logging.getLogger("thinclient-agent")
def publish_digest_viewed(client: mqtt.Client) -> None:
# Retained so digest-engine's next run — which may start hours later, on a
# different machine — can read it without racing to be subscribed at the instant
# it's published.
payload = json.dumps(
{"viewed_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")}
)
client.publish(DIGEST_VIEWED_TOPIC, payload, qos=1, retain=True)
@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]:
digest_url = (config.get("DIGEST_WEB_URL") or "").rstrip("/")
return {
"firefox": App(
name="Firefox",
command=["/usr/local/bin/digest-browser", f"{digest_url}/full.html?detail_level=full"],
workspace=WS_DIGEST,
icon="mdi:firefox",
),
"web_browser": App(
name="web browser",
# Separate from the digest window above: minimal chrome instead of --kiosk,
# its own Firefox profile, and it lands on 1:web. See configs/firefox/.
command=["/usr/local/bin/web-browser"],
workspace=WS_WEB,
icon="mdi:web",
),
"steam_link": App(
name="Steam Link",
# Forced onto Xwayland: native Wayland black-screens/flickers on wlroots
# (project-plan Phase 11.7).
command=[
"flatpak",
"run",
"--env=SDL_VIDEODRIVER=x11",
"com.valvesoftware.SteamLink",
],
process_pattern="SteamLink",
workspace=WS_MEDIA,
focus_criteria='class="steamlink"',
icon="mdi:steam",
),
}
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 "thinclient"
friendly_name = config.get("THINCLIENT_NAME") or f"Thin client ({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("THINCLIENT_ROOM", "")
broker_host = config.get("MQTT_BROKER_HOST", "")
broker_port = int(config.get("MQTT_BROKER_PORT") or 1883)
sway = SwayControl()
canvas = DigestCanvas(sway, config.get("DIGEST_WEB_URL", ""))
admin_canvas = AdminCanvas(sway, config.get("ADMIN_WEB_URL", ""))
# The TV's own power. DISPLAY_OUTPUTS names which Sway output(s) are the TV
# ("*" on a machine driving one screen); DISPLAY_USE_CEC=false drops to DPMS
# only, for a panel whose CEC is broken or deliberately disabled.
display = DisplayPower(
sway,
cec_device=config.get("CEC_DEVICE", ""),
outputs=config.get("DISPLAY_OUTPUTS", "*"),
use_cec=str(config.get("DISPLAY_USE_CEC", "true")).strip().lower() == "true",
)
apps = build_apps(config)
audio = AudioControl(sway.session_env)
capture = CaptureControl()
remote = RemoteDesktop(sway)
keyboard = InputControl(sway.session_env)
# Applied before MQTT is even attempted: the audio preference is a local setting and
# must hold with the container host powered off (Phase 11.10).
audio.list_sinks()
audio.apply_preferred()
client = make_client(f"thinclient-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_detail_level(payload: str) -> None:
discovery.publish_detail_level(canvas.set_detail_level(payload))
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_show_digest(payload: str) -> None:
canvas.show(payload)
discovery.publish_workspace(WS_DIGEST)
# Covers both triggers of this one handler: the manual "Show digest canvas"
# button and an HA automation's voice-resolved "play my digest" request (Phase
# 11.8) — both are a real, on-screen display, unlike the compact HA-dashboard
# iframe, which never reaches this agent at all and so never marks anything
# viewed. See digest-engine/viewed_tracker.py for what reads this.
publish_digest_viewed(client)
def on_show_admin_canvas(_payload: str) -> None:
# Payload intentionally ignored — see admin_canvas.py's docstring for why
# there is nothing in it for this agent to act on. No viewed-tracking here
# either; that concept is specific to the scheduled digest.
admin_canvas.show()
discovery.publish_workspace(WS_ADMIN)
def on_display_power(payload: str) -> None:
# Driven by an HA automation on room occupancy (see hosts/thin-client's
# README): a TV showing a canvas to an empty room is the single largest
# power draw this machine is attached to. The decision stays in HA, where
# presence already lives; this only does what it is told.
discovery.publish_display_power(display.handle_command(payload))
def on_audio_output(payload: str) -> None:
discovery.publish_audio_output(audio.select(payload))
def on_capture_select(payload: str) -> None:
device = capture.select(payload)
discovery.publish_capture_source(capture.current_option())
if device is None:
# "none", or an unrecognised/unplugged-since source — nothing to show,
# same as never having picked one.
return
audio_card = find_audio_card(device.path) or ""
sway.launch_app(
["/usr/local/bin/capture-view", device.path, audio_card], workspace=WS_CAPTURE
)
discovery.publish_workspace(WS_CAPTURE)
def poll_capture_devices(stop_event: threading.Event) -> None:
last_options: list[str] | None = None
while not stop_event.wait(CAPTURE_POLL_SECONDS):
capture.list_devices()
options = capture.options()
if options != last_options:
log.info("capture source list changed: %s", options)
discovery.register_capture_select(
options, on_capture_select, capture.current_option()
)
last_options = options
def on_remote_target(payload: str) -> None:
discovery.publish_remote_target(remote.select(payload))
def on_type(payload: str) -> None:
keyboard.type_text(payload)
def on_move(direction: str) -> None:
keyboard.move(direction)
def on_click(button: str) -> None:
keyboard.click(button)
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_digest(on_show_digest, on_detail_level, DETAIL_LEVELS, canvas.detail_level)
discovery.register_admin_canvas(on_show_admin_canvas)
discovery.register_display_power(on_display_power, display.state)
discovery.register_app_launchers(apps, on_launch)
discovery.register_workspace_select(WORKSPACES, on_workspace, WS_DIGEST)
# audio.apply_preferred() already ran once at startup (before MQTT was even
# attempted, per the comment above) — re-list here so the select's options
# reflect this exact moment rather than whatever was plugged in at boot.
audio.list_sinks()
discovery.register_audio_output(audio.options(), on_audio_output, audio.current_option())
capture.list_devices()
discovery.register_capture_select(
capture.options(), on_capture_select, capture.current_option()
)
discovery.register_remote_desktop(
remote.options(), on_remote_target, remote.connect, remote.disconnect, remote.current
)
discovery.register_input_control(on_type, on_move, on_click)
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)
capture_thread: threading.Thread | None = None
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(): Phase 11.10 requires
# the kiosk to come up and play media with the container host powered off, so
# this agent must never be able to stall the session waiting on the broker.
client.connect_async(broker_host, broker_port, keepalive=60)
client.loop_start()
# Own thread rather than piggybacking on paho's loop thread (which only
# pumps MQTT I/O): a newly plugged-in capture card should appear in HA
# without waiting for a reconnect, per the plan's "active poll" choice.
capture_thread = threading.Thread(
target=poll_capture_devices, args=(stop_event,), daemon=True, name="capture-poll"
)
capture_thread.start()
log.info("thinclient-agent %s started (node_id=%s)", node_id, node_id)
try:
mpris.run_forever(stop_event)
finally:
log.info("shutting down")
if broker_host:
discovery.publish_available(False)
client.loop_stop()
client.disconnect()
if capture_thread is not None:
capture_thread.join(timeout=5)
return 0
if __name__ == "__main__":
raise SystemExit(main())