diff --git a/CLAUDE.md b/CLAUDE.md index 01f9d01..777d824 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,8 +112,10 @@ Operational gotchas (all observed live on a Bridge Pro): When the TV is on, a configured set of viewing lights holds a steady "TV mode" bias look **while the rest of the home keeps driving the circadian curve**; -when the TV turns off each light returns to its configured idle behaviour -(`circadian` rejoins the curve, `off` goes dark). This is built **into the +when the TV turns off each light returns to its configured idle behaviour. +Being on TV duty is the *only* thing that sets a bias light apart from the +rest of the home — TV off means rejoin whatever theme everyone else is on, +not switch to a separate schedule of its own. This is built **into the circadian daemon** (`circadian_daemon.py` + pure `bias_control.py`), NOT as a bridge scene — a single-grouped_light, suspend-on-override daemon can't hold a sub-zone while the rest keeps moving. See @@ -123,6 +125,13 @@ sub-zone while the rest keeps moving. See How it works (config: `circadian_daemon.bias`): - **Per-light** drive of the bias set; the daemon owns the look (`bias.lights[*].look`) and each light's `idle` (`circadian` or `off`). + `idle: circadian` follows the curve while in-window, and — since + 2026-08-08 — holds `night_look` out of window instead of going dark, if + `night_look` is configured (falls back to off if it isn't). Going fully + dark on its own overnight, independent of TV state, read as broken, not + intentional, when it first shipped (live report 2026-08-08): the whole + point is TV-on being the one exception, not a standing "viewing set" + identity with its own day/night rules. - **No bias light may sit in the daemon's driven zone** or the 60 s grouped_light tick stomps its held look. Keep the driven zone and the bias set disjoint. diff --git a/hueman/bias_control.py b/hueman/bias_control.py index 91a822e..58f660d 100644 --- a/hueman/bias_control.py +++ b/hueman/bias_control.py @@ -62,16 +62,27 @@ def bias_actions( tv_on: bool, in_window: bool, curve: DriveTo | None, + night_look: LightState | None = None, transition_ms: int, fade_off_ms: int, edge: bool = False, ) -> list[BiasHold | BiasDrive | BiasOff]: """Return the per-light action for each bias light this tick. - * ``tv_on`` -> every light holds its ``look``. - * otherwise ``idle == "circadian"`` and ``in_window`` and a ``curve`` sample is - available -> follow the curve (same brightness/mirek as the main set). - * otherwise (``idle == "off"``, out of window, or no curve) -> fade off. + * ``tv_on`` -> every light holds its ``look`` — the only case any light is + handled *differently* from the rest of the home; this is a TV-on-only + override, not a standing "viewing set" identity. + * 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"). + * otherwise (``idle == "off"``, or ``idle == "circadian"`` with no + ``night_look`` and no curve) -> fade off. ``edge`` marks a committed TV-state flip: every action then fades over the short ``spec.transition_ms`` instead of the steady-state ``transition_ms``/ @@ -85,6 +96,8 @@ def bias_actions( actions.append(BiasHold(light.name, light.look, fade)) 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)) else: actions.append(BiasOff(light.name, off_fade)) return actions diff --git a/hueman/circadian_daemon.py b/hueman/circadian_daemon.py index f7010b7..e9b600e 100644 --- a/hueman/circadian_daemon.py +++ b/hueman/circadian_daemon.py @@ -670,6 +670,7 @@ def _apply_bias(self, now: float) -> None: latch = True # clears only on a *transient* failure worth retrying for action in bias_actions( bias, tv_on=tv_on, in_window=in_window, curve=curve, + night_look=self._spec.night_look, transition_ms=self._spec.transition_ms, fade_off_ms=self._spec.fade_off_ms, edge=edge, ): diff --git a/tests/test_bias_control.py b/tests/test_bias_control.py index ba763bd..1d63a38 100644 --- a/tests/test_bias_control.py +++ b/tests/test_bias_control.py @@ -58,20 +58,41 @@ def test_tv_off_in_window_circadian_drives_off_idles() -> None: assert actions["Couch"].transition_ms == 75_000 -def test_tv_off_out_of_window_all_off() -> None: - """TV off, out of window: even circadian-idle lights go off.""" +def test_tv_off_out_of_window_no_night_look_goes_off() -> None: + """TV off, out of window, no night_look configured: circadian-idle falls back to off.""" spec = _spec(("Couch", "circadian")) actions = bias_actions(spec, tv_on=False, in_window=False, curve=None, transition_ms=75_000, fade_off_ms=90_000) assert isinstance(actions[0], BiasOff) -def test_circadian_idle_without_curve_goes_off() -> None: - """In window but no curve sample available -> circadian idle falls back to off.""" +def test_tv_off_out_of_window_joins_the_night_look() -> None: + """TV off, out of window, night_look configured: circadian-idle lights join + it instead of going dark -- the only thing that handles a light DIFFERENTLY + from the rest of the home is the TV being on; TV off should read as one + home, not a separate 'viewing set' that goes dark on its own schedule + (observed live 2026-08-08: this looked broken, not intentional).""" + look = LightState(on=True, brightness=1.0, color=Color(mode="xy", hex="ff0000")) + spec = _spec(("Couch", "circadian"), ("Play bars", "off")) + actions = {a.light: a for a in bias_actions( + spec, tv_on=False, in_window=False, curve=None, night_look=look, + transition_ms=75_000, fade_off_ms=90_000)} + assert isinstance(actions["Couch"], BiasHold) + assert actions["Couch"].look == look + assert isinstance(actions["Play bars"], BiasOff) # idle=off is unaffected either way + + +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.""" + look = LightState(on=True, brightness=1.0, color=Color(mode="xy", hex="ff0000")) spec = _spec(("Couch", "circadian")) - actions = bias_actions(spec, tv_on=False, in_window=True, curve=None, - transition_ms=75_000, fade_off_ms=90_000) - assert isinstance(actions[0], BiasOff) + with_look = bias_actions(spec, tv_on=False, in_window=True, curve=None, night_look=look, + transition_ms=75_000, fade_off_ms=90_000) + assert isinstance(with_look[0], BiasHold) and with_look[0].look == look + without_look = bias_actions(spec, tv_on=False, in_window=True, curve=None, + transition_ms=75_000, fade_off_ms=90_000) + assert isinstance(without_look[0], BiasOff) def _spec_edge(*lights: tuple[str, str], edge_ms: int = 2_000) -> BiasSpec: diff --git a/tests/test_circadian_daemon.py b/tests/test_circadian_daemon.py index 2fc6f81..cce7518 100644 --- a/tests/test_circadian_daemon.py +++ b/tests/test_circadian_daemon.py @@ -767,7 +767,40 @@ def test_bias_tick_off_out_of_window(): d._tick_once(t) lw = _light_writes(d) assert lw["Lplay"]["on"] == {"on": False} - assert lw["Lcouch"]["on"] == {"on": False} # circadian idle but out of window -> off + assert lw["Lcouch"]["on"] == {"on": False} # no night_look configured -> off + + +def test_bias_tick_off_out_of_window_joins_night_look(): + # THE LIVE 2026-08-08 REPORT: with night_look configured, an idle=circadian + # viewing light going fully dark overnight (while the rest of the home sat + # at a dim night_look) read as broken, not as "TV is off" -- the only thing + # that should set a light apart from the rest of the home is the TV being + # ON, not a separate day/night schedule of its own. + cfg = Config.parse({ + "bridge": {"host": "x", "application_key": "k"}, + "location": {"lat": 45.5152, "lon": -122.6784, "tz_offset_hours": -7}, + "motion_policies": [], + "circadian_daemon": { + "zone": "Night Guide", "interval": "60s", "transition": "75s", + "night_look": {"brightness": 1, "hex": "#ff0000"}, + "bias": { + "lights": { + "Play bars": {"look": {"mirek": 153, "brightness": 28}, "idle": "off"}, + "Couch": {"look": {"hex": "1a0a00", "brightness": 5}, "idle": "circadian"}, + }, + "triggers": {"sse": {"on_trigger": "On", "off_trigger": "Off"}}, + }, + }, + }) + d = CircadianDaemon.for_test(_FakeClient(), cfg, grouped_light_rid="GL") + d._bias_rids = {"Play bars": "Lplay", "Couch": "Lcouch"} + t = _epoch(23, 30) # past hand-off 22:34 -> out of window, TV off + d._tick_once(t) + lw = _light_writes(d) + assert lw["Lplay"]["on"] == {"on": False} # idle=off is unaffected + assert lw["Lcouch"]["on"] == {"on": True} # idle=circadian joins night_look instead + assert lw["Lcouch"]["dimming"] == {"brightness": 1.0} + assert "color" in lw["Lcouch"] def test_no_bias_writes_when_no_bias_configured():