From 807ae37151f0bd10eb34c0890fb5d9d0d943f9ee Mon Sep 17 00:00:00 2001 From: Chance Newkirk <40219357+cnewkirk@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:22:34 -0700 Subject: [PATCH] feat(bias): per-light night_brightness override for the night_look join The idle=circadian -> night_look join (251af28) gives every viewing light the exact same look, but physical mounts differ: an indirect uplight behind objects needs more lumens than a direct fixture to register as visible at all, let alone the same. Live report 2026-08-08: the couch lightstrip sits under a console table behind books and bounces light upward -- at night_look's shared 1% it was imperceptible. bias.lights[*].night_brightness (optional, 0-100) overrides just the brightness this light uses when joining night_look -- keeps night_look's colour (still reads as the same theme) but lets each fixture be calibrated for how/where it's actually mounted. Lights without an override are unaffected, same as before. 1 new pure test (override applies + a light with no override stays unchanged, both checked together), verified to fail on the pre-fix code (branch disabled, ran red, restored). Full suite (398 tests) + mypy clean. Co-Authored-By: Claude Sonnet 5 --- hueman/bias_control.py | 20 +++++++++++++------- hueman/config.py | 24 +++++++++++++++++++++++- tests/test_bias_control.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/hueman/bias_control.py b/hueman/bias_control.py index 58f660d..1c7b4cd 100644 --- a/hueman/bias_control.py +++ b/hueman/bias_control.py @@ -75,12 +75,15 @@ def bias_actions( * otherwise ``idle == "circadian"``: join whatever the rest of the home is doing. In window with a ``curve`` sample -> follow the curve (same brightness/mirek as the main set). Out of window (or in window with no - curve sample) and ``night_look`` is configured -> hold that same look, - same as the main zone's own parked state — a circadian-idle light must - never go dark on its own just because the window closed; it should read - as part of one home, not a separate "viewing" island (observed live - 2026-08-08: TV-viewing lights going fully off overnight while the rest - of the home sat at a dim night_look read as broken, not "TV is off"). + curve sample) and ``night_look`` is configured -> hold that same look + (same colour as the main zone's own parked state), at ``night_look``'s + own brightness unless the light declares its own ``night_brightness`` + (e.g. an indirect/uplight fixture that needs more lumens than a direct + one to read the same) — a circadian-idle light must never go dark on + its own just because the window closed; it should read as part of one + home, not a separate "viewing" island (observed live 2026-08-08: + TV-viewing lights going fully off overnight while the rest of the home + sat at a dim night_look read as broken, not "TV is off"). * otherwise (``idle == "off"``, or ``idle == "circadian"`` with no ``night_look`` and no curve) -> fade off. @@ -97,7 +100,10 @@ def bias_actions( elif light.idle == "circadian" and in_window and curve is not None: actions.append(BiasDrive(light.name, curve.brightness, curve.mirek, fade)) elif light.idle == "circadian" and night_look is not None: - actions.append(BiasHold(light.name, night_look, fade)) + look = night_look + if light.night_brightness is not None: + look = LightState(on=True, brightness=light.night_brightness, color=night_look.color) + actions.append(BiasHold(light.name, look, fade)) else: actions.append(BiasOff(light.name, off_fade)) return actions diff --git a/hueman/config.py b/hueman/config.py index c0206d3..e7d0023 100644 --- a/hueman/config.py +++ b/hueman/config.py @@ -703,11 +703,19 @@ class BiasLight: look: The static hold look (always on; a concrete colour, never circadian). idle: What the light does when the TV is off — ``"circadian"`` (follow the curve while in the daemon's active window) or ``"off"``. + night_brightness: Optional per-light brightness override (0-100) used + instead of ``night_look``'s own brightness when this light joins + the night theme out of window — same colour as everyone else, but + calibrated for this fixture's mount (e.g. an indirect uplight + needs more lumens than a direct one to read the same). ``None`` + uses ``night_look``'s brightness unchanged, same as every other + ``idle: circadian`` light. """ name: str look: LightState idle: str + night_brightness: float | None = None @classmethod def parse(cls, name: str, value: Any, ctx: str) -> "BiasLight": @@ -717,6 +725,8 @@ def parse(cls, name: str, value: Any, ctx: str) -> "BiasLight": colour — ``circadian`` is rejected because a hold has to be a fixed target. ``idle`` must be ``circadian`` or ``off``; a YAML-1.1 bare ``off`` (parsed as ``False``) is accepted as the string ``"off"``. + ``night_brightness`` (0-100, optional) overrides just the brightness + this light uses when it joins ``night_look``. """ d = _as_dict(value, ctx) look_d = _as_dict(_require(d, "look", ctx), f"{ctx}.look") @@ -738,7 +748,19 @@ def parse(cls, name: str, value: Any, ctx: str) -> "BiasLight": idle = "off" if raw_idle is False else str(raw_idle) if idle not in ("circadian", "off"): raise ConfigError(f"{ctx}.idle must be 'circadian' or 'off'") - return cls(name=str(name), look=LightState(on=True, brightness=bri, color=color), idle=idle) + night_bri: float | None = None + if "night_brightness" in d: + try: + night_bri = float(d["night_brightness"]) + except (TypeError, ValueError): + raise ConfigError( + f"{ctx}.night_brightness must be a number, got {d['night_brightness']!r}") + if not 0 <= night_bri <= 100: + raise ConfigError(f"{ctx}.night_brightness must be 0-100") + return cls( + name=str(name), look=LightState(on=True, brightness=bri, color=color), idle=idle, + night_brightness=night_bri, + ) @dataclass(frozen=True) diff --git a/tests/test_bias_control.py b/tests/test_bias_control.py index 1d63a38..1282ecd 100644 --- a/tests/test_bias_control.py +++ b/tests/test_bias_control.py @@ -82,6 +82,40 @@ def test_tv_off_out_of_window_joins_the_night_look() -> None: assert isinstance(actions["Play bars"], BiasOff) # idle=off is unaffected either way +def test_night_brightness_overrides_night_look_brightness_but_keeps_its_colour() -> None: + """A light with its own night_brightness (e.g. an indirect/uplight fixture + that needs more lumens than a direct one to read the same) uses that + brightness instead of night_look's own -- but still night_look's colour, + so it still reads as the same theme, just calibrated for its mount.""" + night_look = LightState(on=True, brightness=1.0, color=Color(mode="xy", hex="ff1400")) + spec = BiasSpec( + lights=( + BiasLight( + name="Couch strip", + look=LightState(on=True, brightness=24.0, color=Color(mode="ct", mirek=400)), + idle="circadian", + night_brightness=9.0, + ), + BiasLight( + name="Play bars", + look=LightState(on=True, brightness=95.0, color=Color(mode="ct", mirek=153)), + idle="circadian", + ), + ), + transition_ms=2_000, sse_on=None, sse_off=None, file_on=None, file_off=None, + probe_enabled=False, probe_host=None, probe_mode="tcp", probe_port=3001, + probe_interval_ms=5000, probe_debounce_ms=5000, + ) + actions = {a.light: a for a in bias_actions( + spec, tv_on=False, in_window=False, curve=None, night_look=night_look, + transition_ms=75_000, fade_off_ms=90_000)} + assert isinstance(actions["Couch strip"], BiasHold) + assert actions["Couch strip"].look.brightness == 9.0 # its own override + assert actions["Couch strip"].look.color == night_look.color # same theme colour + assert isinstance(actions["Play bars"], BiasHold) + assert actions["Play bars"].look == night_look # no override -> unchanged + + def test_circadian_idle_without_curve_falls_back_to_night_look_when_in_window() -> None: """In window but no curve sample available: falls back to night_look if set (same 'never go dark on your own' rule), otherwise off."""