Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
21 changes: 17 additions & 4 deletions hueman/bias_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``/
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions hueman/circadian_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down
35 changes: 28 additions & 7 deletions tests/test_bias_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 34 additions & 1 deletion tests/test_circadian_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading