SmartestHome/stream-dock/led_sync.py

315 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""Keep the dock's four knob rings showing what the room's lamps are doing.
Polls Home Assistant's REST API for the configured lights, works out the four ring
colours (`dock_leds.ring_colors`), and rewrites the akp05 device plugin's leds.toml
when — and only when — they change. Then runs STREAM_DOCK_LED_APPLY_COMMAND, which is
the hook that makes the plugin notice.
THE HONEST LIMIT, and the reason that hook exists: the akp05 device plugin reads
leds.toml when it starts and does not watch it. Nothing else can drive those LEDs
either — the plugin holds the USB device open, so a second process cannot write HID
reports to it. So this service is correct and complete on its own side, and the last
few centimetres are somebody's decision about how to make the plugin re-read: a small
upstream patch that watches the file (the right fix), or a command that restarts the
plugin (available today, but it re-initialises the device, so it is only tolerable at
the debounce intervals this service is designed around, not per detent). With the hook
empty this still runs, still keeps the file right, and simply does not light anything
new until the plugin next starts. See stream-dock/README.md §5.
Why polling and not the websocket API: this is stdlib-only, like the rest of this
repo's services, and the thing it feeds cannot react faster than its debounce anyway.
A websocket subscription is a strict improvement the day the apply hook becomes cheap.
Configuration is environment only (generated/led-sync.env, written by generate.py).
"""
from __future__ import annotations
import json
import logging
import os
import shlex
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import dock_leds # noqa: E402
LOG = logging.getLogger("stream-dock-led-sync")
HA_URL = os.environ.get("STREAM_DOCK_HA_URL", "").rstrip("/")
HA_TOKEN = os.environ.get("STREAM_DOCK_HA_TOKEN", "")
LIGHTS = [e.strip() for e in os.environ.get("STREAM_DOCK_LIGHTS", "").split(",") if e.strip()]
LEDS_PATH = Path(os.path.expanduser(
os.environ.get("STREAM_DOCK_LEDS_PATH", dock_leds.DEFAULT_LEDS_PATH)))
LED_BRIGHTNESS = int(os.environ.get("STREAM_DOCK_LED_BRIGHTNESS", "100"))
MIN_CHANNEL = int(os.environ.get("STREAM_DOCK_LED_MIN_CHANNEL", "0"))
POLL_SECONDS = float(os.environ.get("STREAM_DOCK_LED_POLL_SECONDS", "2"))
DEBOUNCE_MS = int(os.environ.get("STREAM_DOCK_LED_DEBOUNCE_MS", "400"))
APPLY_COMMAND = os.environ.get("STREAM_DOCK_LED_APPLY_COMMAND", "").strip()
# Rate limit for the apply command only — never for the file write, which is cheap and
# always reflects the current state. Every strategy except "none" re-initialises the
# device to some degree, so running one per detent of a spun dial would make the dock
# blink rather than light. The last state is never dropped: a deferred apply runs as
# soon as the interval is up.
APPLY_MIN_INTERVAL = float(os.environ.get("STREAM_DOCK_LED_APPLY_MIN_INTERVAL", "0"))
# The layer gate. The dock's lighting controls live on their own OpenDeck layer, and
# the four rings are shared hardware: on any other layer those dials mean something
# else, and painting a lamp's colour onto them there is worse than not lighting them
# at all. Empty means no gate — drive the rings always.
GATE_COMMAND = os.environ.get("STREAM_DOCK_LED_GATE_COMMAND", "").strip()
IDLE_COLORS = [c.strip() for c in os.environ.get("STREAM_DOCK_LED_IDLE_COLORS", "").split(",")
if c.strip()]
IDLE_CYCLE_SECONDS = float(os.environ.get("STREAM_DOCK_LED_IDLE_CYCLE_SECONDS", "3"))
# How long a failing Home Assistant is allowed to be quiet about it. Below this the
# service just retries; above it, it says so once and then stays quiet again, because
# a desktop service that logs a line every two seconds while HA reboots is a service
# nobody keeps enabled.
COMPLAIN_AFTER_SECONDS = 60
class Lights:
"""The lamps, and the last colour anybody saw them wearing.
The remembered colour is the point of this class. Most integrations drop
rgb_color to None when a lamp is off, and a ring set that forgets the colour every
time the light is switched off would tell you nothing about what the colour dials
are currently holding — see dock_leds' module docstring.
"""
def __init__(self, entity_ids: list[str]) -> None:
self.entity_ids = entity_ids
self.last_rgb: tuple[int, int, int] = (255, 167, 87)
self.last_brightness = 255
def read(self) -> tuple[bool, bool, tuple[int, int, int], int]:
"""(HA answered at all, any lamp on, colour, brightness).
The first flag is separate from the second on purpose: "every lamp is off" and
"I could not ask" are different facts, and only one of them means the rings are
lying. Same three-state honesty as the infra poller's ok/problem/unreachable.
"""
reachable = False
any_on = False
chosen = None
for entity_id in self.entity_ids:
state = fetch_state(entity_id)
if state is None:
continue
reachable = True
on = state.get("state") == "on"
any_on = any_on or on
if on and chosen is None:
attrs = state.get("attributes") or {}
rgb = attrs.get("rgb_color")
if rgb and len(rgb) == 3:
chosen = (tuple(int(c) for c in rgb),
int(attrs.get("brightness") or 255))
if chosen is not None:
self.last_rgb, self.last_brightness = chosen
return reachable, any_on, self.last_rgb, self.last_brightness
def fetch_state(entity_id: str) -> dict | None:
request = urllib.request.Request(
f"{HA_URL}/api/states/{entity_id}",
headers={"Authorization": f"Bearer {HA_TOKEN}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
# 404 is a configuration mistake, not a transient failure, and it deserves to
# be loud every time: it means the entity in CoreSystemConfig.json does not
# exist in this Home Assistant, which is exactly the failure the dials will
# hit too.
if exc.code == 404:
LOG.error("no such entity in Home Assistant: %s", entity_id)
else:
LOG.debug("HTTP %s fetching %s", exc.code, entity_id)
return None
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
LOG.debug("fetching %s: %s", entity_id, exc)
return None
def write_leds(colors: list[list[int]]) -> None:
"""Atomic rewrite: a half-written leds.toml read by a starting plugin is a device
that comes up with no LEDs and no explanation."""
LEDS_PATH.parent.mkdir(parents=True, exist_ok=True)
temp = LEDS_PATH.with_suffix(".toml.tmp")
temp.write_text(dock_leds.render_leds_toml(colors, LED_BRIGHTNESS))
os.replace(temp, LEDS_PATH)
def layer_active() -> bool | None:
"""Is the dock currently showing the lighting layer? None means "cannot tell".
Exit 0 yes, exit 1 no, anything else — including a command that will not run —
is None, and the caller treats that as "not ours". A service that has lost track
of which layer is up must not keep painting lamp colours onto rings that may now
belong to something else; going idle is the recoverable mistake, hijacking is not.
"""
if not GATE_COMMAND:
return True
try:
result = subprocess.run(shlex.split(GATE_COMMAND), capture_output=True,
text=True, timeout=5)
except (OSError, subprocess.SubprocessError) as exc:
LOG.debug("layer gate could not run: %s", exc)
return None
if result.returncode == 0:
return True
if result.returncode == 1:
return False
LOG.debug("layer gate exited %s: %s", result.returncode, (result.stderr or "").strip()[:200])
return None
def run_apply() -> None:
if not APPLY_COMMAND:
return
try:
result = subprocess.run(shlex.split(APPLY_COMMAND), capture_output=True,
text=True, timeout=30)
if result.returncode != 0:
LOG.warning("apply command failed (%s): %s", result.returncode,
(result.stderr or "").strip()[:200])
except (OSError, subprocess.SubprocessError) as exc:
LOG.warning("apply command could not run: %s", exc)
def main() -> int:
logging.basicConfig(level=os.environ.get("STREAM_DOCK_LOG_LEVEL", "INFO"),
format="%(levelname)s %(name)s: %(message)s")
if not HA_URL or not HA_TOKEN:
LOG.error("STREAM_DOCK_HA_URL and STREAM_DOCK_HA_TOKEN must be set "
"(generate them with stream-dock/generate.py)")
return 2
if not LIGHTS:
LOG.error("STREAM_DOCK_LIGHTS is empty — nothing to follow")
return 2
running = True
def stop(signum, frame): # noqa: ARG001
nonlocal running
running = False
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
lights = Lights(LIGHTS)
LOG.info("following %s, writing %s", ", ".join(LIGHTS), LEDS_PATH)
if GATE_COMMAND:
LOG.info("gated on: %s", GATE_COMMAND)
else:
LOG.info("no layer gate configured — driving the rings on every layer")
if APPLY_COMMAND:
LOG.info("apply: %s (at most once every %ss)", APPLY_COMMAND, APPLY_MIN_INTERVAL)
else:
LOG.info("no apply command configured — leds.toml will be kept correct, but the "
"device plugin only reads it at startup (README section 5)")
written: list[list[int]] | None = None
pending: list[list[int]] | None = None
pending_since = 0.0
last_apply = 0.0
apply_due = False
unreachable_since = 0.0
complained = False
was_active: bool | None = None
gate_complained = False
while running:
now = time.monotonic()
active = layer_active()
if active is None and not gate_complained:
LOG.warning("the layer gate is not answering — treating the lighting layer as "
"hidden and leaving the rings to their idle scheme (%s)", GATE_COMMAND)
gate_complained = True
elif active is not None:
gate_complained = False
if active is not True and was_active is True:
LOG.debug("lighting layer hidden — rings released")
if active is True and was_active is not True:
LOG.debug("lighting layer showing — rings following %s", ", ".join(LIGHTS))
was_active = active
if active is True:
# Home Assistant is only polled while the layer is actually up. A dock
# sitting on some other layer all day should not be asking about a lamp
# nobody can see the colour of.
reachable, any_on, rgb, brightness = lights.read()
if not reachable:
if unreachable_since == 0.0:
unreachable_since = now
elif not complained and now - unreachable_since > COMPLAIN_AFTER_SECONDS:
LOG.warning("Home Assistant has been unreachable for %ds — the rings are "
"showing the last state anybody saw, not the current one",
int(now - unreachable_since))
complained = True
else:
if complained:
LOG.info("Home Assistant is back")
unreachable_since, complained = 0.0, False
colors = dock_leds.ring_colors(rgb, brightness, any_on, floor=MIN_CHANNEL)
elif IDLE_COLORS:
unreachable_since, complained = 0.0, False
step = int(now / IDLE_CYCLE_SECONDS) if IDLE_CYCLE_SECONDS > 0 else 0
colors = dock_leds.idle_ring_colors(IDLE_COLORS, step)
else:
# No idle scheme configured: hands entirely off. Whatever is on the rings
# stays there, and the next time the layer comes up the colours are
# rewritten from scratch — hence dropping `written`.
written, pending = None, None
time.sleep(POLL_SECONDS)
continue
# Debounce: a dial being spun changes the colour on every service call, and
# each write is followed by whatever the apply command costs — which, if it is
# a plugin restart, is far too expensive to do per detent. Collapse a burst
# into one write once it settles.
if colors != written and colors != pending:
pending, pending_since = colors, now
if pending is not None and (now - pending_since) * 1000 >= DEBOUNCE_MS:
write_leds(pending)
LOG.debug("rings -> %s", pending)
written, pending = pending, None
apply_due = True
# The file is already current; this is only about when the (expensive) reload
# runs. A deferred apply is never dropped — it fires on a later pass with the
# newest state already on disk, which is exactly what should reach the device.
if apply_due and APPLY_COMMAND and now - last_apply >= APPLY_MIN_INTERVAL:
run_apply()
last_apply, apply_due = now, False
elif apply_due and not APPLY_COMMAND:
apply_due = False
interval = POLL_SECONDS
if apply_due and APPLY_MIN_INTERVAL > 0:
# Come back when the deferred reload is allowed to run, not a poll later.
interval = min(interval, max(0.2, APPLY_MIN_INTERVAL - (now - last_apply)))
if active is not True and IDLE_CYCLE_SECONDS > 0:
# Keep the chase on time without polling faster than it steps.
interval = min(interval, max(0.2, IDLE_CYCLE_SECONDS / 4))
if pending is not None:
interval = min(interval, 0.2)
time.sleep(interval)
LOG.info("stopping")
return 0
if __name__ == "__main__":
sys.exit(main())