Fix group member planning for low brightness

This commit is contained in:
ajp_anton
2026-06-01 04:34:32 +00:00
parent cdf1f4e3a1
commit afafd4fe93
6 changed files with 295 additions and 66 deletions
+2 -3
View File
@@ -144,9 +144,8 @@ def _deploy(
_ssh(host, "systemctl --user daemon-reload") _ssh(host, "systemctl --user daemon-reload")
if not no_start: if not no_start:
_ssh(host, "systemctl --user enable --now ha-deconz-bridge.service") _ssh(host, "systemctl --user enable ha-deconz-bridge.service")
if config_only: _ssh(host, "systemctl --user restart ha-deconz-bridge.service")
_ssh(host, "systemctl --user restart ha-deconz-bridge.service")
if restart_debug: if restart_debug:
_ssh(host, "systemctl --user restart ha-deconz-bridge-debug.service") _ssh(host, "systemctl --user restart ha-deconz-bridge-debug.service")
+28 -47
View File
@@ -13,14 +13,13 @@ from ha_deconz_bridge.app import load_config_module
from ha_deconz_bridge.backends.deconz import DeconzBackend from ha_deconz_bridge.backends.deconz import DeconzBackend
from ha_deconz_bridge.color import Color from ha_deconz_bridge.color import Color
from ha_deconz_bridge.controller import PhysicalController from ha_deconz_bridge.controller import PhysicalController
from ha_deconz_bridge.group_planner import plan_group_command
from ha_deconz_bridge.groups import ( from ha_deconz_bridge.groups import (
GroupMemberSpec, GroupMemberSpec,
GroupSpec, GroupSpec,
derive_group_modes, derive_group_modes,
fixed_brightness_scale, fixed_brightness_scale,
fixed_brightness_threshold, fixed_brightness_threshold,
fixed_color_mode,
fixed_color_payload,
group_member_mode, group_member_mode,
normalize_group_specs, normalize_group_specs,
) )
@@ -1239,16 +1238,15 @@ def _group_brightness_from_aggregate(
return 0 return 0
if requested_mode == "onoff": if requested_mode == "onoff":
return 255 if _group_onoff_is_on(group, lights, controller_states, session, model_for_light) else 0 return 255 if _group_onoff_is_on(group, lights, controller_states, session, model_for_light) else 0
reference = sum( plan = plan_group_command(
_group_member_reference_brightness( group,
member, lights,
lights[member.light_name], group_target=report_color,
requested_mode, group_mode=requested_mode,
report_color, group_brightness=255,
model_for_light, model_for_light=model_for_light,
)
for member in group.member_specs()
) )
reference = plan.reference_brightness
if reference <= 0.0: if reference <= 0.0:
return 0 return 0
return int(round(max(0.0, min(1.0, actual_color.brightness / reference)) * 255.0)) return int(round(max(0.0, min(1.0, actual_color.brightness / reference)) * 255.0))
@@ -1465,37 +1463,6 @@ def _group_debug_spec(
) )
def _member_payload(
member: GroupMemberSpec,
member_spec: VirtualLightSpec,
group_mode: str,
request: dict[str, object],
) -> dict[str, object]:
brightness = int(request.get("brightness", 255))
payload = fixed_color_payload(member, member_spec)
if payload is not None:
return {
"mode": fixed_color_mode(member, member_spec),
"brightness": int(member.fixed_brightness) if member.fixed_brightness is not None else brightness,
**payload,
}
if group_mode == "rgb" and "rgb" in member_spec.mode_set:
return {
"mode": "rgb",
"brightness": brightness,
"rgb": list(request.get("rgb", [255, 255, 255])),
}
if group_mode == "color_temp" and "color_temp" in member_spec.mode_set:
return {
"mode": "color_temp",
"brightness": brightness,
"color_temp": int(request.get("color_temp", _default_target(member_spec, "color_temp").color_temp)),
}
if member_spec.supported_color_modes == ("onoff",):
return {"mode": "onoff", "brightness": brightness}
return {"mode": "brightness", "brightness": brightness}
def _build_target(spec: VirtualLightSpec, request: dict[str, object]) -> tuple[Color, str, int]: def _build_target(spec: VirtualLightSpec, request: dict[str, object]) -> tuple[Color, str, int]:
mode = str(request.get("mode") or spec.supported_color_modes[0]) mode = str(request.get("mode") or spec.supported_color_modes[0])
if mode not in spec.supported_color_modes: if mode not in spec.supported_color_modes:
@@ -1523,8 +1490,16 @@ def _solve_group_request(
model_for_light, model_for_light,
) -> dict[str, object]: ) -> dict[str, object]:
group_target, group_mode, group_brightness = _build_target(group_spec, request) group_target, group_mode, group_brightness = _build_target(group_spec, request)
plan = plan_group_command(
group,
lights,
group_target=group_target,
group_mode=group_mode,
group_brightness=group_brightness,
model_for_light=model_for_light,
)
achieved = Color.off() achieved = Color.off()
reference_brightness = 0.0 reference_brightness = plan.reference_brightness
chroma_error = 0.0 chroma_error = 0.0
brightness_error = 0.0 brightness_error = 0.0
channel_levels: dict[str, float] = {} channel_levels: dict[str, float] = {}
@@ -1533,8 +1508,16 @@ def _solve_group_request(
for member in group.member_specs(): for member in group.member_specs():
member_spec = lights[member.light_name] member_spec = lights[member.light_name]
member_request = _member_payload(member, member_spec, group_mode, request) member_plan = plan.members[member.light_name]
member_target, member_mode, member_brightness = _build_target(member_spec, member_request) member_target = member_plan.target
member_mode = member_plan.mode
member_brightness = (
0
if not member_plan.active
else int(member_plan.fixed_brightness)
if member_plan.fixed_brightness is not None
else int(member_plan.brightness)
)
member_result = solve_light( member_result = solve_light(
model_for_light(member.light_name), model_for_light(member.light_name),
member_target, member_target,
@@ -1542,7 +1525,6 @@ def _solve_group_request(
requested_mode=member_mode, requested_mode=member_mode,
) )
achieved += member_result.achieved_color achieved += member_result.achieved_color
reference_brightness += member_result.reference_color.brightness
chroma_error = max(chroma_error, float(member_result.chroma_error)) chroma_error = max(chroma_error, float(member_result.chroma_error))
brightness_error += float(member_result.brightness_error) brightness_error += float(member_result.brightness_error)
channel_levels.update(member_result.channel_levels) channel_levels.update(member_result.channel_levels)
@@ -1582,7 +1564,6 @@ def _solve_group_request(
}, },
} }
def make_handler( def make_handler(
lights: dict[str, VirtualLightSpec], lights: dict[str, VirtualLightSpec],
initial_selection: list[str], initial_selection: list[str],
+55 -13
View File
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from ha_deconz_bridge.color import Color from ha_deconz_bridge.color import Color
from ha_deconz_bridge.events import HaCommandEvent from ha_deconz_bridge.events import HaCommandEvent
from ha_deconz_bridge.group_planner import GroupMemberPlan, plan_group_command
from ha_deconz_bridge.groups import ( from ha_deconz_bridge.groups import (
GroupMemberSpec, GroupMemberSpec,
GroupSpec, GroupSpec,
@@ -69,14 +70,23 @@ class VirtualGroupLight:
def handle_ha_command(self, event: HaCommandEvent) -> tuple[str, ...]: def handle_ha_command(self, event: HaCommandEvent) -> tuple[str, ...]:
on, brightness, mode, rgb, color_temp = self._merge_command(event) on, brightness, mode, rgb, color_temp = self._merge_command(event)
target = self._target_for_command(mode, rgb=rgb, color_temp=color_temp)
plan = plan_group_command(
self.group,
{name: member.spec for name, member in self.members.items()},
group_target=target,
group_mode=mode,
group_brightness=brightness if on else 0,
model_for_light=lambda name: self.members[name].light_model,
)
for member_spec in self.member_specs: for member_spec in self.member_specs:
member = self.members[member_spec.light_name] member = self.members[member_spec.light_name]
member.handle_ha_command( member.handle_ha_command(
self._member_command( self._member_command(
member_spec, member_spec,
member, member,
plan=plan.members[member_spec.light_name],
on=on, on=on,
brightness=brightness,
mode=mode, mode=mode,
rgb=rgb, rgb=rgb,
color_temp=color_temp, color_temp=color_temp,
@@ -150,20 +160,20 @@ class VirtualGroupLight:
member_spec: GroupMemberSpec, member_spec: GroupMemberSpec,
member: VirtualLight, member: VirtualLight,
*, *,
plan: GroupMemberPlan,
on: bool, on: bool,
brightness: int,
mode: str, mode: str,
rgb: tuple[int, int, int] | None, rgb: tuple[int, int, int] | None,
color_temp: int | None, color_temp: int | None,
) -> HaCommandEvent: ) -> HaCommandEvent:
if not on or brightness <= 0: if not on or not plan.active or plan.brightness <= 0:
return HaCommandEvent(member.spec.name, on=False) return HaCommandEvent(member.spec.name, on=False)
if member.spec.supported_color_modes == ("onoff",): if member.spec.supported_color_modes == ("onoff",):
return HaCommandEvent(member.spec.name, on=True) return HaCommandEvent(member.spec.name, on=True)
fixed_brightness = member_spec.fixed_brightness fixed_brightness = plan.fixed_brightness
member_brightness = int(fixed_brightness) if fixed_brightness is not None else brightness member_brightness = int(fixed_brightness) if fixed_brightness is not None else plan.brightness
payload = fixed_color_payload(member_spec, member.spec) payload = fixed_color_payload(member_spec, member.spec)
if payload is not None: if payload is not None:
return HaCommandEvent( return HaCommandEvent(
@@ -172,11 +182,38 @@ class VirtualGroupLight:
brightness=member_brightness, brightness=member_brightness,
**payload, **payload,
) )
if mode == "rgb" and rgb is not None and "rgb" in member.spec.mode_set: if plan.mode == "rgb" and "rgb" in member.spec.mode_set:
return HaCommandEvent(member.spec.name, on=True, brightness=brightness, rgb=rgb) return HaCommandEvent(
if mode == "color_temp" and color_temp is not None and "color_temp" in member.spec.mode_set: member.spec.name,
return HaCommandEvent(member.spec.name, on=True, brightness=brightness, color_temp=color_temp) on=True,
return HaCommandEvent(member.spec.name, on=True, brightness=brightness) brightness=plan.brightness,
rgb=tuple(int(round(component * 255.0)) for component in plan.target.rgb), # type: ignore[arg-type]
)
if plan.mode == "color_temp" and "color_temp" in member.spec.mode_set:
return HaCommandEvent(
member.spec.name,
on=True,
brightness=plan.brightness,
color_temp=int(round(plan.target.color_temp)),
)
return HaCommandEvent(member.spec.name, on=True, brightness=plan.brightness)
def _target_for_command(
self,
mode: str,
*,
rgb: tuple[int, int, int] | None,
color_temp: int | None,
) -> Color:
if mode == "color_temp":
return Color.from_color_temp(
color_temp or self._remembered.color_temp or self._default_color_temp() or 326,
brightness=1.0,
temp_range=self.spec.exposed_mireds or (153, 500),
)
if mode == "rgb":
return Color.from_rgb(rgb or self._remembered.rgb or (255, 255, 255), brightness=1.0)
return Color.from_rgb((255, 255, 255), brightness=1.0)
def _recompute_state( def _recompute_state(
self, self,
@@ -267,10 +304,15 @@ class VirtualGroupLight:
mode: str, mode: str,
target: Color, target: Color,
) -> float: ) -> float:
return sum( plan = plan_group_command(
self._member_max_brightness(member_spec, member, mode, target) self.group,
for member_spec, member, _state in members {name: member.spec for name, member in self.members.items()},
group_target=target,
group_mode=mode,
group_brightness=255,
model_for_light=lambda name: self.members[name].light_model,
) )
return plan.reference_brightness
def _member_max_brightness( def _member_max_brightness(
self, self,
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from ha_deconz_bridge.color import Color
from ha_deconz_bridge.groups import GroupMemberSpec, GroupSpec, group_member_mode
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
from ha_deconz_bridge.solver import CompiledLightModel, solve_light
from ha_deconz_bridge.solver_model import compile_light_model
@dataclass(slots=True, frozen=True)
class GroupMemberPlan:
active: bool
brightness: int
mode: str
target: Color
fixed_brightness: int | None = None
@dataclass(slots=True, frozen=True)
class GroupSolvePlan:
members: dict[str, GroupMemberPlan]
achieved_color: Color
reference_brightness: float
chroma_error: float
brightness_error: float
@dataclass(slots=True, frozen=True)
class _MemberContribution:
member: GroupMemberSpec
spec: VirtualLightSpec
mode: str
target: Color
color: Color
switch_like: bool
fixed_brightness: int | None
def plan_group_command(
group: GroupSpec,
lights: dict[str, VirtualLightSpec],
*,
group_target: Color,
group_mode: str,
group_brightness: int,
model_for_light: Callable[[str], CompiledLightModel],
) -> GroupSolvePlan:
if group_brightness <= 0:
return GroupSolvePlan(
members={
member.light_name: GroupMemberPlan(
active=False,
brightness=0,
mode=group_member_mode(member, lights[member.light_name], group_mode), # type: ignore[arg-type]
target=group_target,
)
for member in group.member_specs()
},
achieved_color=Color.off(),
reference_brightness=0.0,
chroma_error=0.0,
brightness_error=0.0,
)
contributions = tuple(
_member_contribution(member, lights[member.light_name], group_target, group_mode, model_for_light)
for member in group.member_specs()
)
if not contributions:
return GroupSolvePlan({}, Color.off(), 0.0, 0.0, 0.0)
plan_model = _compile_member_model(group.name, contributions)
requested_mode = group_mode if group_mode in {"rgb", "color_temp"} else "brightness"
result = solve_light(
plan_model,
group_target,
brightness=group_brightness,
requested_mode=requested_mode,
)
members: dict[str, GroupMemberPlan] = {}
for index, contribution in enumerate(contributions):
address = f"member_{index}.{'onoff' if contribution.switch_like else 'brightness'}.w"
level = result.channel_levels.get(address, 0.0)
active = level > 0.0
brightness = 255 if contribution.switch_like and active else int(round(max(0.0, min(1.0, level)) * 255.0))
members[contribution.member.light_name] = GroupMemberPlan(
active=active,
brightness=brightness,
mode=contribution.mode,
target=contribution.target,
fixed_brightness=contribution.fixed_brightness,
)
return GroupSolvePlan(
members=members,
achieved_color=result.achieved_color,
reference_brightness=result.reference_color.brightness,
chroma_error=float(result.chroma_error),
brightness_error=float(result.brightness_error),
)
def _member_contribution(
member: GroupMemberSpec,
spec: VirtualLightSpec,
group_target: Color,
group_mode: str,
model_for_light: Callable[[str], CompiledLightModel],
) -> _MemberContribution:
mode = group_member_mode(member, spec, group_mode) # type: ignore[arg-type]
target = member.fixed_color.color if member.fixed_color is not None else group_target
fixed_brightness = int(member.fixed_brightness) if member.fixed_brightness is not None else None
brightness = fixed_brightness if fixed_brightness is not None else 255
model = model_for_light(spec.name)
result = solve_light(
model,
target,
brightness=brightness,
requested_mode=mode,
)
color = result.achieved_color
if (
group_mode == "color_temp"
and mode == "color_temp"
and color
and color.uv_distance(group_target) <= model.approximate_chroma_band
):
# Group CT requests intentionally allow calibrated CT emitters to be
# approximate. Plan them on the requested CT chroma so a tiny exact
# emitter does not exclude much brighter near-CT emitters.
color = group_target.with_brightness(color.brightness)
return _MemberContribution(
member=member,
spec=spec,
mode=mode,
target=target,
color=color,
switch_like=member.fixed_brightness is not None or spec.supported_color_modes == ("onoff",),
fixed_brightness=fixed_brightness,
)
def _compile_member_model(
group_name: str,
contributions: tuple[_MemberContribution, ...],
) -> CompiledLightModel:
controllers: dict[str, ControllerSpec] = {}
for index, contribution in enumerate(contributions):
controller_id = f"member_{index}"
mode_name = "onoff" if contribution.switch_like else "brightness"
controllers[controller_id] = ControllerSpec(
zigbee_id=controller_id,
name=contribution.member.light_name,
modes={
mode_name: ControllerModeSpec(
name=mode_name, # type: ignore[arg-type]
channels={
"w": ChannelSpec(
"w",
contribution.color,
_semantic_label(contribution.mode),
)
},
)
},
)
return compile_light_model(
VirtualLightSpec(
name=f"{group_name} member plan",
supported_color_modes=("rgb",),
controllers=controllers,
)
)
def _semantic_label(mode: str) -> str | None:
if mode in {"rgb", "color_temp"}:
return mode
return None
+2 -2
View File
@@ -735,8 +735,8 @@ def test_example_group_readback_uses_aggregate_member_output(
state = app.group_lights["Living Room"].handle_member_update() state = app.group_lights["Living Room"].handle_member_update()
assert 45 <= state.brightness <= 60 assert 45 <= state.brightness <= 60
assert app.lights["Floor Lamp Plug"].state.brightness == 255 assert app.lights["Floor Lamp Plug"].state.brightness == 0
assert app.lights["Desk Lamp Plug"].state.brightness == 255 assert app.lights["Desk Lamp Plug"].state.brightness == 0
def test_example_native_ct_member_reports_requested_ct(settings) -> None: def test_example_native_ct_member_reports_requested_ct(settings) -> None:
+25 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from lights_config import GROUPS, LIGHTS from lights_config import GROUPS, LIGHTS
from ha_deconz_bridge.color import Color from ha_deconz_bridge.color import Color
from ha_deconz_bridge.debugger.web_debugger import _combine_specs, _group_debug_spec from ha_deconz_bridge.debugger.web_debugger import _combine_specs, _group_debug_spec, _solve_group_request
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
from ha_deconz_bridge.solver import solve_light from ha_deconz_bridge.solver import solve_light
from ha_deconz_bridge.solver_model import compile_light_model from ha_deconz_bridge.solver_model import compile_light_model
@@ -686,3 +686,27 @@ def test_example_all_lights_ct_sweep_does_not_use_unused_onoff_reference() -> No
previous = levels previous = levels
assert max_delta < 0.9 assert max_delta < 0.9
def test_debugger_group_low_brightness_keeps_onoff_members_off() -> None:
group = GROUPS["Living Room"]
group_spec = _group_debug_spec(group, LIGHTS)
models = {
name: compile_light_model(spec)
for name, spec in LIGHTS.items()
}
result = _solve_group_request(
group,
LIGHTS,
group_spec,
{"mode": "color_temp", "brightness": 1, "color_temp": 500},
model_for_light=models.__getitem__,
)
assert result["commands"]["plug_a"]["on"] is False
assert result["commands"]["plug_b"]["on"] is False
assert result["channel_levels"].get("plug_a.onoff.w", 0.0) == 0.0
assert result["channel_levels"].get("plug_b.onoff.w", 0.0) == 0.0
assert result["channel_levels"].get("fixed_a.color_temp.ww", 0.0) == 0.0
assert result["channel_levels"].get("fixed_b.color_temp.ww", 0.0) == 0.0