356 lines
14 KiB
Python
356 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})"
|
|
|
|
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)
|
|
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())
|