315 lines
14 KiB
Python
Executable File
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())
|