"""Turning the attached TV on and off, so an empty room does not power a panel. A wall-mounted Android TV driven by one of these thin clients draws 60-150 W while it shows a canvas nobody is in the room to look at. This module is what Home Assistant calls when presence says the room is occupied or empty — the decision lives in HA (an area's occupancy, the same presence system everything else here uses), and the doing lives here. TWO MECHANISMS, IN THIS ORDER ----------------------------- 1. **HDMI-CEC** (`cec-ctl`, from v4l-utils). The thin client is the HDMI *source*, so it can put the display into standby and wake it again over the HDMI cable itself. That is the one that actually saves the panel's power, and it needs no network path to the TV, no pairing, no credentials, and no account — it keeps working with the LAN down, which is this project's whole posture. Android TV and Google TV sets implement CEC as "HDMI-CEC", "Bravia Sync", "Anynet+", "Simplink" and a dozen other brand names for the same standard; it usually has to be enabled in the TV's settings once. 2. **Sway DPMS** (`swaymsg output power on|off`) as the fallback, and as a belt-and-braces companion: it stops the compositor driving pixels and drops the HDMI signal, which most panels treat as "go to sleep" on their own. It always works because it needs nothing but the compositor already running here — but on its own it may leave a TV showing a "no signal" banner rather than sleeping, which is why CEC is tried first. Both are attempted on every call unless CEC is switched off, because they fail in different ways and neither reports reliably. WHAT "OFF" HONESTLY MEANS ------------------------- Standby, not disconnected. A TV in CEC standby still draws roughly half a watt to keep listening on the HDMI line — that is what makes waking it possible at all. This turns 60-150 W of lit panel into ~0.5 W of standby; it is not a smart plug and does not pretend to be. If a set is one of the ones that ignores CEC standby entirely, you will see it immediately (the panel stays lit) — that is what the verification note in hosts/thin-client/README.md is for. SECURITY POSTURE, UNCHANGED --------------------------- This is another enumerated MQTT command, exactly like the workspace switch and the canvas buttons: HA -> MQTT -> a fixed action here. A payload never becomes an argv element — `set_power()` takes a boolean, and the device names come from local configuration, never from the message. See mqtt_discovery.py's module docstring. """ from __future__ import annotations import logging import os import shutil import subprocess log = logging.getLogger(__name__) CEC_TIMEOUT_SECONDS = 10 class DisplayPower: def __init__(self, sway, cec_device: str | None = None, outputs: str = "*", use_cec: bool = True): self.sway = sway # The CEC adapter, e.g. /dev/cec0. Most systems have exactly one and cec-ctl # finds it on its own; this is for the machine that has two. self.cec_device = cec_device or os.environ.get("CEC_DEVICE", "") # Which Sway outputs to power down. "*" is every output, which is right for a # thin client driving one TV; name an output (e.g. "HDMI-A-1") on a machine # where only one of several screens is the TV. self.outputs = outputs or "*" self.use_cec = use_cec self.state = True # --- CEC ---------------------------------------------------------------- def _cec(self, *args: str) -> bool: binary = shutil.which("cec-ctl") if not binary: log.info("cec-ctl is not installed; falling back to DPMS only") return False command = [binary] if self.cec_device: command += ["-d", self.cec_device] command += list(args) try: result = subprocess.run( command, capture_output=True, text=True, timeout=CEC_TIMEOUT_SECONDS ) except (OSError, subprocess.SubprocessError) as exc: log.warning("cec-ctl %s failed: %s", " ".join(args), exc) return False if result.returncode != 0: log.warning("cec-ctl %s: %s", " ".join(args), (result.stderr or "").strip()) return False return True # --- the one public action --------------------------------------------- def set_power(self, on: bool) -> bool: """Turn the display on or off. Returns the state it believes it left it in. Deliberately not idempotent-by-early-return: HA asking for "on" when this object already thinks it is on must still send the wake, because the TV may have been turned off with its own remote and nothing here would know. The state field is for reporting, never for skipping work. """ log.info("display: turning the panel %s", "on" if on else "off") if self.use_cec: # --to 0 addresses the TV specifically (logical address 0) rather than # broadcasting, so a soundbar or receiver on the same bus is left alone. if on: self._cec("--to", "0", "--image-view-on") # Ask to become the active source too: waking a TV that then shows a # different input is the same as not waking it. self._cec("--to", "0", "--active-source", "phys-addr=0.0.0.0") else: self._cec("--to", "0", "--standby") # Always also drive the compositor: on a set that ignores CEC this is what # stops it displaying, and on one that honours CEC it stops the thin client # rendering to a panel nobody is looking at. self.sway.swaymsg("output", self.outputs, "power", "on" if on else "off") self.state = on return self.state def handle_command(self, payload: str) -> bool: """MQTT payload -> action. Anything that isn't a known ON/OFF word is ignored rather than guessed at, per the enumerated-command rule.""" value = (payload or "").strip().upper() if value in ("ON", "TRUE", "1"): return self.set_power(True) if value in ("OFF", "FALSE", "0"): return self.set_power(False) log.warning("display: ignoring unknown power payload %r", payload) return self.state