137 lines
6.2 KiB
Python
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
|