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

137 lines
6.2 KiB
Python

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