SmartestHome/hosts/steam-tv-box/agent/steamtv_agent/display_power.py

147 lines
6.9 KiB
Python

"""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 <name> 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