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
20 changes: 13 additions & 7 deletions hueman/bias_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
24 changes: 23 additions & 1 deletion hueman/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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")
Expand All @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions tests/test_bias_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading