"""Turning the attached TV on and off, so an empty room does not power a panel. The television this box is plugged into draws 60-150 W while it shows a paused game or an empty Big Picture that 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. Note what this deliberately does not do: it never touches the machine's own power. Suspending a box mid-game loses the game. Turning the panel off while the machine keeps running is the whole point — see also /usr/local/bin/display-toggle, the local no-network version of this, bound to the remote's power button. TWO MECHANISMS, IN THIS ORDER ----------------------------- 1. **HDMI-CEC** (`cec-ctl`, from v4l-utils). This box 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. ONE CAVEAT SPECIFIC TO THIS IMAGE: when Steam is running under gamescope, gamescope owns the output, and `swaymsg output … power off` blanks gamescope's surface rather than the game inside it. CEC is what actually darkens the panel in that case, which is another reason it is tried first here rather than kept as the fallback. 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/steam-tv-box/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 # box 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 this machine # 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