Initial public release
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
old attempt/
|
||||
configs/local/
|
||||
scripts/deploy.py
|
||||
*.env
|
||||
*.env.local
|
||||
*.local
|
||||
*.secret
|
||||
@@ -0,0 +1,67 @@
|
||||
# HA deCONZ Bridge
|
||||
|
||||
`ha-deconz-bridge` is a Python service that presents clean virtual lights to Home Assistant while translating those requests onto messy physical controllers behind deCONZ.
|
||||
|
||||
The core design goals are:
|
||||
|
||||
- deterministic forward and reverse mapping
|
||||
- typed light definitions instead of unstructured nested dictionaries
|
||||
- one shared core for runtime and debug tooling
|
||||
- exact-target solving first, with clamping only when a request is truly out of gamut
|
||||
|
||||
New code lives under [`src/ha_deconz_bridge`](./src/ha_deconz_bridge/). Local/private experiments and archived prototypes should stay ignored.
|
||||
|
||||
## Project Background
|
||||
|
||||
This started as a personal hobby project, was put on ice for a while after motivation ran out, and was later picked up again as a clean rewrite with substantial help from AI. The current public codebase is the rewritten version; old experiments are kept out of the repository history.
|
||||
|
||||
## Project Layout
|
||||
|
||||
- [`docs/semantics.md`](./docs/semantics.md): runtime behavior and mapping rules
|
||||
- [`docs/architecture.md`](./docs/architecture.md): package structure and data flow
|
||||
- [`docs/configuration.md`](./docs/configuration.md): typed light definitions
|
||||
- [`docs/deployment.md`](./docs/deployment.md): Raspberry Pi deploy and service flow
|
||||
- [`docs/debugger.md`](./docs/debugger.md): shared-core simulator
|
||||
- [`docs/plan.md`](./docs/plan.md): implementation plan that drove this reboot
|
||||
|
||||
## Quick Start
|
||||
|
||||
Install in editable mode:
|
||||
|
||||
```bash
|
||||
python3 -m pip install -e '.[dev]'
|
||||
```
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
Create a private config from the public example:
|
||||
|
||||
```bash
|
||||
mkdir -p configs/local
|
||||
cp configs/example.py configs/local/lights_config.py
|
||||
cp configs/example_*.py configs/local/
|
||||
```
|
||||
|
||||
Edit `configs/local/lights_config.py` and the copied split files for your real controller IDs, light names, groups, and calibration values. Files under `configs/local/` are ignored by Git.
|
||||
|
||||
Run the service with a Python config path that exports `SETTINGS` and `LIGHTS`:
|
||||
|
||||
```bash
|
||||
ha-deconz-bridge-service configs/local/lights_config.py
|
||||
```
|
||||
|
||||
Run the browser debugger against the same config:
|
||||
|
||||
```bash
|
||||
ha-deconz-bridge-debug-web configs/local/lights_config.py "Example Mixed North" --host 127.0.0.1 --port 8765
|
||||
```
|
||||
|
||||
## Configuration And Deployment
|
||||
|
||||
The public repository includes sanitized example configs under [`configs/example.py`](./configs/example.py). Keep real room names, controller IDs, hostnames, user names, and deployment scripts in ignored local files such as `configs/local/`.
|
||||
|
||||
See [`docs/deployment.md`](./docs/deployment.md) for the generic service layout and deployment expectations.
|
||||
@@ -0,0 +1,9 @@
|
||||
from ha_deconz_bridge.settings import Settings
|
||||
|
||||
from configs.example_actions import AUTOMATIONS
|
||||
from configs.example_groups import GROUPS
|
||||
from configs.example_lights import LIGHTS
|
||||
from configs.example_remotes import REMOTES
|
||||
|
||||
|
||||
SETTINGS = Settings.from_env()
|
||||
@@ -0,0 +1,70 @@
|
||||
from ha_deconz_bridge.automation import ActionSpec, automation_rules
|
||||
|
||||
|
||||
def kelvin_to_mireds(kelvin: int) -> int:
|
||||
return int(round(1_000_000 / kelvin))
|
||||
|
||||
|
||||
def scene(target: str, *, brightness: int, kelvin: int) -> ActionSpec:
|
||||
return ActionSpec(
|
||||
"set_scene",
|
||||
target=target,
|
||||
brightness=brightness,
|
||||
color_temp=kelvin_to_mireds(kelvin),
|
||||
)
|
||||
|
||||
|
||||
def all_lights_scene(*, brightness: int, kelvin: int) -> tuple[ActionSpec, ...]:
|
||||
return (
|
||||
scene("Example Living", brightness=brightness, kelvin=kelvin),
|
||||
scene("Example Hall", brightness=brightness, kelvin=kelvin),
|
||||
scene("Example Kitchen", brightness=brightness, kelvin=kelvin),
|
||||
scene("Example Bathroom", brightness=brightness, kelvin=kelvin),
|
||||
scene("Example Bedroom", brightness=brightness, kelvin=kelvin),
|
||||
)
|
||||
|
||||
|
||||
def all_lights_off() -> tuple[ActionSpec, ...]:
|
||||
return (
|
||||
ActionSpec("turn_off", target="Example Living"),
|
||||
ActionSpec("turn_off", target="Example Hall"),
|
||||
ActionSpec("turn_off", target="Example Kitchen"),
|
||||
ActionSpec("turn_off", target="Example Bathroom"),
|
||||
ActionSpec("turn_off", target="Example Bedroom"),
|
||||
)
|
||||
|
||||
|
||||
def room_automation(room: str) -> dict[str, dict[str, tuple[ActionSpec, ...]]]:
|
||||
return {
|
||||
"top": {
|
||||
"single": (scene(room, brightness=255, kelvin=4000),),
|
||||
"double": (scene(room, brightness=255, kelvin=5500),),
|
||||
"hold": all_lights_scene(brightness=255, kelvin=4000),
|
||||
"double_hold": all_lights_scene(brightness=255, kelvin=5500),
|
||||
},
|
||||
"up": {
|
||||
"single": (scene(room, brightness=153, kelvin=3000),),
|
||||
"double": (scene(room, brightness=204, kelvin=3500),),
|
||||
"hold": (ActionSpec("brightness_step", target=room, brightness_step=26),),
|
||||
},
|
||||
"down": {
|
||||
"single": (scene(room, brightness=51, kelvin=2200),),
|
||||
"double": (scene(room, brightness=102, kelvin=2500),),
|
||||
"hold": (ActionSpec("brightness_step", target=room, brightness_step=-26),),
|
||||
},
|
||||
"bottom": {
|
||||
"single": (ActionSpec("turn_off", target=room),),
|
||||
"hold": all_lights_off(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
AUTOMATION_CONFIG = {
|
||||
"example_living_remote": room_automation("Example Living"),
|
||||
"example_kitchen_remote": room_automation("Example Kitchen"),
|
||||
"example_hall_remote": room_automation("Example Hall"),
|
||||
"example_bathroom_remote": room_automation("Example Bathroom"),
|
||||
"example_bedroom_remote": room_automation("Example Bedroom"),
|
||||
}
|
||||
|
||||
AUTOMATIONS = automation_rules(AUTOMATION_CONFIG)
|
||||
@@ -0,0 +1,130 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
|
||||
|
||||
|
||||
RGB_RED = (0.68, 0.31)
|
||||
RGB_GREEN = (0.18, 0.70)
|
||||
RGB_BLUE = (0.14, 0.05)
|
||||
CT_RANGE = (153, 500)
|
||||
DAYLIGHT_MIREDS = 182
|
||||
|
||||
|
||||
def define_lights(raw_lights: Mapping[str, Mapping[str, Any]]) -> dict[str, VirtualLightSpec]:
|
||||
return {
|
||||
light_name: _virtual_light(light_name, raw_spec)
|
||||
for light_name, raw_spec in raw_lights.items()
|
||||
}
|
||||
|
||||
|
||||
def _virtual_light(light_name: str, raw_spec: Mapping[str, Any]) -> VirtualLightSpec:
|
||||
controllers = {
|
||||
controller_id: _controller_spec(
|
||||
controller_id,
|
||||
raw_controller,
|
||||
default_name=light_name,
|
||||
)
|
||||
for controller_id, raw_controller in raw_spec["controllers"].items()
|
||||
}
|
||||
return VirtualLightSpec(
|
||||
name=str(raw_spec.get("name", light_name)),
|
||||
supported_color_modes=tuple(raw_spec["supported_color_modes"]),
|
||||
controllers=controllers,
|
||||
)
|
||||
|
||||
|
||||
def _controller_spec(
|
||||
controller_id: str,
|
||||
raw_controller: Mapping[str, Any] | ControllerSpec,
|
||||
*,
|
||||
default_name: str,
|
||||
) -> ControllerSpec:
|
||||
if isinstance(raw_controller, ControllerSpec):
|
||||
if raw_controller.zigbee_id != controller_id:
|
||||
raise ValueError(
|
||||
f"Controller key {controller_id!r} does not match spec zigbee_id {raw_controller.zigbee_id!r}."
|
||||
)
|
||||
return raw_controller
|
||||
|
||||
mireds = raw_controller.get("mireds")
|
||||
if mireds is not None:
|
||||
mireds = tuple(sorted(int(value) for value in mireds))
|
||||
raw_modes = raw_controller.get("modes")
|
||||
if raw_modes is None:
|
||||
raw_modes = {
|
||||
key: value
|
||||
for key, value in raw_controller.items()
|
||||
if key in {"onoff", "brightness", "rgb", "color_temp"}
|
||||
}
|
||||
modes = {
|
||||
mode_name: _mode_spec(mode_name, raw_mode, mireds=mireds)
|
||||
for mode_name, raw_mode in raw_modes.items()
|
||||
}
|
||||
return ControllerSpec(
|
||||
zigbee_id=controller_id,
|
||||
name=str(raw_controller.get("name", default_name)),
|
||||
modes=modes,
|
||||
mireds=mireds,
|
||||
)
|
||||
|
||||
|
||||
def _mode_spec(
|
||||
mode_name: str,
|
||||
raw_mode: Mapping[str, Any],
|
||||
*,
|
||||
mireds: tuple[int, int] | None,
|
||||
) -> ControllerModeSpec:
|
||||
channels = raw_mode.get("channels", raw_mode)
|
||||
return ControllerModeSpec(
|
||||
name=mode_name,
|
||||
channels={
|
||||
role: _channel_spec(role, raw_channel, mode_name=mode_name, mireds=mireds)
|
||||
for role, raw_channel in channels.items()
|
||||
if role not in {"name", "channels"}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _channel_spec(
|
||||
role: str,
|
||||
raw_channel: Mapping[str, Any],
|
||||
*,
|
||||
mode_name: str,
|
||||
mireds: tuple[int, int] | None,
|
||||
) -> ChannelSpec:
|
||||
label = raw_channel.get("label", raw_channel.get("semantic_label"))
|
||||
return ChannelSpec(
|
||||
role,
|
||||
_parse_color(
|
||||
raw_channel["color"],
|
||||
brightness=raw_channel.get("brightness"),
|
||||
temp_range=mireds or CT_RANGE,
|
||||
),
|
||||
label,
|
||||
)
|
||||
|
||||
|
||||
def _parse_color(
|
||||
value: Color | int | float | tuple[float, ...] | list[float],
|
||||
*,
|
||||
brightness: float | int | None,
|
||||
temp_range: tuple[int, int],
|
||||
) -> Color:
|
||||
output = 1.0 if brightness is None else float(brightness)
|
||||
if isinstance(value, Color):
|
||||
return value.with_brightness(output) if brightness is not None else value
|
||||
if isinstance(value, int | float):
|
||||
mireds = float(value)
|
||||
if mireds >= 1000.0:
|
||||
mireds = 1_000_000.0 / mireds
|
||||
return Color.from_color_temp(mireds, brightness=output, temp_range=temp_range)
|
||||
|
||||
components = tuple(float(component) for component in value)
|
||||
if len(components) == 2:
|
||||
return Color.from_xy(components, brightness=output)
|
||||
if len(components) == 3:
|
||||
scale = 255.0 if any(component > 1.0 for component in components) else 1.0
|
||||
return Color.from_rgb(tuple(component / scale for component in components), brightness=output)
|
||||
raise ValueError(f"Unsupported color value {value!r}.")
|
||||
@@ -0,0 +1,42 @@
|
||||
from ha_deconz_bridge.groups import GroupMemberSpec, GroupSpec
|
||||
|
||||
from configs.example_controllers import DAYLIGHT_MIREDS
|
||||
|
||||
|
||||
GROUPS: dict[str, GroupSpec] = {
|
||||
"Example Hall": GroupSpec(
|
||||
name="Example Hall",
|
||||
members=("Example Hall Floor", "Example Hall Corner", "Example Hall Cabinets"),
|
||||
),
|
||||
"Example Kitchen": GroupSpec(
|
||||
name="Example Kitchen",
|
||||
members=("Example Kitchen Counter",),
|
||||
),
|
||||
"Example Bathroom": GroupSpec(
|
||||
name="Example Bathroom",
|
||||
members=("Example Bathroom Ceiling", "Example Bathroom Mirror"),
|
||||
),
|
||||
"Example Bedroom": GroupSpec(
|
||||
name="Example Bedroom",
|
||||
members=("Example Bedroom West", "Example Bedroom East"),
|
||||
),
|
||||
"Example Living": GroupSpec(
|
||||
name="Example Living",
|
||||
members=(
|
||||
"Example Fixed White A",
|
||||
"Example Fixed White B",
|
||||
"Example Mixed North",
|
||||
"Example Plug A",
|
||||
"Example Plug B",
|
||||
),
|
||||
),
|
||||
"Example Plant": GroupSpec(
|
||||
name="Example Plant",
|
||||
members=(
|
||||
"Example Fixed White A",
|
||||
"Example Fixed White B",
|
||||
GroupMemberSpec("Example Mixed North", fixed_color=DAYLIGHT_MIREDS),
|
||||
),
|
||||
balance="relative_member",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
from configs.example_controllers import (
|
||||
CT_RANGE,
|
||||
DAYLIGHT_MIREDS,
|
||||
RGB_BLUE,
|
||||
RGB_GREEN,
|
||||
RGB_RED,
|
||||
define_lights,
|
||||
)
|
||||
|
||||
|
||||
LIGHTS = define_lights(
|
||||
{
|
||||
"Example Fixed White A": {
|
||||
"supported_color_modes": ("brightness",),
|
||||
"controllers": {
|
||||
"fixed_a": {
|
||||
"name": "Example Fixed White A",
|
||||
"mireds": CT_RANGE,
|
||||
"color_temp": {
|
||||
"ww": {"color": DAYLIGHT_MIREDS, "brightness": 9000},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Fixed White B": {
|
||||
"supported_color_modes": ("brightness",),
|
||||
"controllers": {
|
||||
"fixed_b": {
|
||||
"name": "Example Fixed White B",
|
||||
"mireds": CT_RANGE,
|
||||
"color_temp": {
|
||||
"ww": {"color": DAYLIGHT_MIREDS, "brightness": 7000},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Plug A": {
|
||||
"supported_color_modes": ("onoff",),
|
||||
"controllers": {
|
||||
"plug_a": {
|
||||
"name": "Example Plug A",
|
||||
"onoff": {
|
||||
"w": {"color": DAYLIGHT_MIREDS, "brightness": 8000, "label": "color_temp"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Plug B": {
|
||||
"supported_color_modes": ("onoff",),
|
||||
"controllers": {
|
||||
"plug_b": {
|
||||
"name": "Example Plug B",
|
||||
"onoff": {
|
||||
"w": {"color": DAYLIGHT_MIREDS, "brightness": 8000, "label": "color_temp"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Mixed North": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"mixed_rgb": {
|
||||
"name": "Example Mixed North RGB",
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 3000},
|
||||
"g": {"color": RGB_GREEN, "brightness": 2480},
|
||||
"b": {"color": RGB_BLUE, "brightness": 3000},
|
||||
},
|
||||
},
|
||||
"mixed_ct": {
|
||||
"name": "Example Mixed North CCT",
|
||||
"mireds": CT_RANGE,
|
||||
"color_temp": {
|
||||
"ww": {"color": 500, "brightness": 25600},
|
||||
"cw": {"color": 153, "brightness": 56000},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Hall Floor": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"hall_floor": {
|
||||
"name": "Example Hall Floor",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 2460},
|
||||
"g": {"color": RGB_GREEN, "brightness": 2040},
|
||||
"b": {"color": RGB_BLUE, "brightness": 1260},
|
||||
},
|
||||
"color_temp": {
|
||||
"ww": {"color": 500, "brightness": 6000},
|
||||
"cw": {"color": 153, "brightness": 16800},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Hall Corner": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"hall_corner": {
|
||||
"name": "Example Hall Corner",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 820},
|
||||
"g": {"color": RGB_GREEN, "brightness": 680},
|
||||
"b": {"color": RGB_BLUE, "brightness": 420},
|
||||
},
|
||||
"color_temp": {
|
||||
"ww": {"color": 500, "brightness": 2000},
|
||||
"cw": {"color": 153, "brightness": 5600},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Hall Cabinets": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"hall_cabinets": {
|
||||
"name": "Example Hall Cabinets",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 2460},
|
||||
"g": {"color": RGB_GREEN, "brightness": 2040},
|
||||
"b": {"color": RGB_BLUE, "brightness": 1260},
|
||||
},
|
||||
"color_temp": {
|
||||
"ww": {"color": 500, "brightness": 6000},
|
||||
"cw": {"color": 153, "brightness": 16800},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Kitchen Counter": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"kitchen_counter": {
|
||||
"name": "Example Kitchen Counter",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 1800},
|
||||
"g": {"color": RGB_GREEN, "brightness": 2200},
|
||||
"b": {"color": RGB_BLUE, "brightness": 1900},
|
||||
},
|
||||
"color_temp": {
|
||||
"ww": {"color": 500, "brightness": 3800},
|
||||
"cw": {"color": 153, "brightness": 3800},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Bathroom Ceiling": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"bath_ceiling_1": {
|
||||
"name": "Example Bathroom Ceiling 1",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 900},
|
||||
"g": {"color": RGB_GREEN, "brightness": 420},
|
||||
"b": {"color": RGB_BLUE, "brightness": 180},
|
||||
},
|
||||
"color_temp": {
|
||||
"ww": {"color": 500, "brightness": 1700},
|
||||
"cw": {"color": 153, "brightness": 2700},
|
||||
},
|
||||
},
|
||||
"bath_ceiling_2": {
|
||||
"name": "Example Bathroom Ceiling 2",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 900},
|
||||
"g": {"color": RGB_GREEN, "brightness": 420},
|
||||
"b": {"color": RGB_BLUE, "brightness": 180},
|
||||
},
|
||||
"color_temp": {
|
||||
"ww": {"color": 500, "brightness": 1700},
|
||||
"cw": {"color": 153, "brightness": 2700},
|
||||
},
|
||||
},
|
||||
"bath_ceiling_3": {
|
||||
"name": "Example Bathroom Ceiling 3",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 900},
|
||||
"g": {"color": RGB_GREEN, "brightness": 420},
|
||||
"b": {"color": RGB_BLUE, "brightness": 180},
|
||||
},
|
||||
"color_temp": {
|
||||
"ww": {"color": 500, "brightness": 1700},
|
||||
"cw": {"color": 153, "brightness": 2700},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Bathroom Mirror": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"bath_mirror": {
|
||||
"name": "Example Bathroom Mirror",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 4100},
|
||||
"g": {"color": RGB_GREEN, "brightness": 3400},
|
||||
"b": {"color": RGB_BLUE, "brightness": 2100},
|
||||
},
|
||||
"color_temp": {
|
||||
"ww": {"color": 500, "brightness": 10000},
|
||||
"cw": {"color": 153, "brightness": 28000},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Bedroom West": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"bedroom_w": {
|
||||
"name": "Example Bedroom West",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 1100},
|
||||
"g": {"color": RGB_GREEN, "brightness": 810},
|
||||
"b": {"color": RGB_BLUE, "brightness": 360},
|
||||
},
|
||||
"color_temp": {
|
||||
"ww": {"color": 500, "brightness": 3200},
|
||||
"cw": {"color": 153, "brightness": 4700},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Bedroom East": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"bedroom_e": {
|
||||
"name": "Example Bedroom East",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 1100},
|
||||
"g": {"color": RGB_GREEN, "brightness": 810},
|
||||
"b": {"color": RGB_BLUE, "brightness": 360},
|
||||
},
|
||||
"color_temp": {
|
||||
"ww": {"color": 500, "brightness": 3200},
|
||||
"cw": {"color": 153, "brightness": 4700},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
from ha_deconz_bridge.automation import RemoteSpec
|
||||
|
||||
|
||||
REMOTES = {
|
||||
"example_sensor_1": RemoteSpec(
|
||||
sensor_id="example_sensor_1",
|
||||
remote_id="example_living_remote",
|
||||
name="Example living remote",
|
||||
model="RWL021",
|
||||
),
|
||||
"example_sensor_2": RemoteSpec(
|
||||
sensor_id="example_sensor_2",
|
||||
remote_id="example_kitchen_remote",
|
||||
name="Example kitchen remote",
|
||||
model="RWL021",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=HA deCONZ Bridge web debugger
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/ha-deconz-bridge/app
|
||||
EnvironmentFile=%h/ha-deconz-bridge/config/service.env
|
||||
ExecStart=%h/ha-deconz-bridge/.venv/bin/python -m ha_deconz_bridge.debugger.web_debugger %h/ha-deconz-bridge/config/lights_config.py --host 0.0.0.0 --port 8766
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=HA deCONZ Bridge service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/ha-deconz-bridge/app
|
||||
EnvironmentFile=-%h/ha-deconz-bridge/config/service.env
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=HA_DECONZ_BRIDGE_LOG_FILE=%h/ha-deconz-bridge/ha-deconz-bridge.log
|
||||
ExecStart=%h/ha-deconz-bridge/.venv/bin/ha-deconz-bridge-service %h/ha-deconz-bridge/config/lights_config.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,57 @@
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
|
||||
from ha_deconz_bridge.settings import Settings
|
||||
|
||||
|
||||
SETTINGS = Settings.from_env()
|
||||
|
||||
# Replace the controller IDs with real deCONZ light IDs.
|
||||
# The chromaticity and brightness numbers here are placeholders that are good
|
||||
# enough for an initial hardware smoke test.
|
||||
LIGHTS = {
|
||||
"test_ct": VirtualLightSpec(
|
||||
name="test_ct",
|
||||
supported_color_modes=("color_temp",),
|
||||
controllers={
|
||||
"1": ControllerSpec(
|
||||
zigbee_id="1",
|
||||
name="Replace with real CT controller",
|
||||
modes={
|
||||
"color_temp": ControllerModeSpec(
|
||||
name="color_temp",
|
||||
channels={
|
||||
"ww": ChannelSpec(
|
||||
"ww",
|
||||
Color.from_color_temp(500, brightness=300, temp_range=(153, 500)),
|
||||
),
|
||||
"cw": ChannelSpec(
|
||||
"cw",
|
||||
Color.from_color_temp(153, brightness=320, temp_range=(153, 500)),
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
"test_rgb": VirtualLightSpec(
|
||||
name="test_rgb",
|
||||
supported_color_modes=("rgb",),
|
||||
controllers={
|
||||
"2": ControllerSpec(
|
||||
zigbee_id="2",
|
||||
name="Replace with real RGB controller",
|
||||
modes={
|
||||
"rgb": ControllerModeSpec(
|
||||
name="rgb",
|
||||
channels={
|
||||
"r": ChannelSpec("r", Color.from_xy((0.68, 0.31), brightness=180)),
|
||||
"g": ChannelSpec("g", Color.from_xy((0.18, 0.70), brightness=420)),
|
||||
"b": ChannelSpec("b", Color.from_xy((0.14, 0.05), brightness=110)),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Copy or edit this file on the target host at:
|
||||
# <install-dir>/config/service.env
|
||||
|
||||
HA_DECONZ_BRIDGE_MQTT_HOST=homeassistant
|
||||
HA_DECONZ_BRIDGE_MQTT_PORT=1883
|
||||
HA_DECONZ_BRIDGE_MQTT_USER=
|
||||
HA_DECONZ_BRIDGE_MQTT_PASSWORD=
|
||||
|
||||
HA_DECONZ_BRIDGE_DECONZ_HOST=localhost
|
||||
HA_DECONZ_BRIDGE_DECONZ_HTTP_PORT=80
|
||||
HA_DECONZ_BRIDGE_DECONZ_WS_PORT=443
|
||||
HA_DECONZ_BRIDGE_DECONZ_API_KEY=
|
||||
|
||||
# Lower values make nearby color/CT requests share solver cache entries.
|
||||
# This improves repeated nearby solves, but can create visible color dead zones.
|
||||
HA_DECONZ_BRIDGE_TARGET_CACHE_SCALE=10000
|
||||
|
||||
# CT-only uv slack for preferring CT-labeled emitters over exact RGB emulation.
|
||||
HA_DECONZ_BRIDGE_APPROXIMATE_CHROMA_BAND=0.025
|
||||
|
||||
# Run one representative solver request per HA mode in the background so
|
||||
# Numba-backed paths are compiled without blocking MQTT or debugger startup.
|
||||
HA_DECONZ_BRIDGE_WARMUP_ON_STARTUP=true
|
||||
|
||||
# Numeric backend for color solves. Keep "scipy" unless optional speed
|
||||
# dependencies were installed, for example with pip install -e '.[speed]'.
|
||||
# Allowed values: scipy, numba, auto.
|
||||
HA_DECONZ_BRIDGE_NUMERIC_BACKEND=scipy
|
||||
@@ -0,0 +1,63 @@
|
||||
# Architecture
|
||||
|
||||
## Core Layers
|
||||
|
||||
- `ha_deconz_bridge.color`: shared color model and conversions
|
||||
- `ha_deconz_bridge.lights`: typed light definitions and validation
|
||||
- `ha_deconz_bridge.solver_model`: compiled light-model and exclusivity combinations
|
||||
- `ha_deconz_bridge.solver_numeric`: shared numeric color-solve helpers
|
||||
- `ha_deconz_bridge.solver`: solve orchestration and deterministic elimination policy
|
||||
- `ha_deconz_bridge.translate`: controller and virtual-light translation helpers
|
||||
|
||||
## Runtime Layers
|
||||
|
||||
- `ha_deconz_bridge.backends.base`: backend protocol
|
||||
- `ha_deconz_bridge.backends.deconz`: deCONZ HTTP and websocket normalization
|
||||
- `ha_deconz_bridge.automation`: remote specs, gesture detection, and automation rule matching
|
||||
- `ha_deconz_bridge.controller`: one physical controller runtime
|
||||
- `ha_deconz_bridge.group_light`: exposed groups that translate commands into member light commands
|
||||
- `ha_deconz_bridge.groups`: group specs, member restrictions, and capability derivation
|
||||
- `ha_deconz_bridge.light`: one virtual Home Assistant light
|
||||
- `ha_deconz_bridge.ha`: MQTT discovery, commands, and state publishing
|
||||
- `ha_deconz_bridge.queueing`: HA command coalescing and event prioritization
|
||||
- `ha_deconz_bridge.app`: service wiring
|
||||
|
||||
## Debug Layer
|
||||
|
||||
- `ha_deconz_bridge.debugger.mpl_debugger` is an offline simulator.
|
||||
- It uses the same `Color`, solver, and translation code as runtime.
|
||||
- It does not have a separate solver branch.
|
||||
|
||||
## Solver Shape
|
||||
|
||||
- Each exposed virtual light or debugger selection is compiled once into a light model.
|
||||
- Only exclusivity groups create solver branches; independent control paths may be active together.
|
||||
- `solve_light()` treats all emitters uniformly in one shared color space.
|
||||
- Semantic labels like `rgb` and `color_temp` are used only during policy stages, not in the mixing math itself.
|
||||
|
||||
## Event Flow
|
||||
|
||||
1. Home Assistant command arrives over MQTT.
|
||||
2. The command is normalized into a typed HA event.
|
||||
3. The event queue coalesces pending commands for the same virtual light. Sensor events run first, HA commands run before pending light readback events, and deCONZ light readbacks are deduplicated by controller.
|
||||
4. Individual `VirtualLight` entities merge the command with their last stable state and ask the solver for controller targets.
|
||||
5. Group entities translate the group command into member `VirtualLight` commands. A group member may have a fixed RGB or CT restriction that applies only when that group is controlled.
|
||||
6. Each `PhysicalController` writes the solver's physical command to deCONZ. HA-driven writes trust a successful HTTP response and keep the accepted logical report state while near-term stale readbacks arrive.
|
||||
7. `VirtualLight` and group entities aggregate accepted-or-physical output state and publish it back to Home Assistant.
|
||||
8. Unsolicited deCONZ events update hardware truth directly when they are not near-term stale readbacks from a just-written command.
|
||||
|
||||
## Remote Event Flow
|
||||
|
||||
1. deCONZ sends a websocket sensor event for a configured remote.
|
||||
2. The raw `buttonevent` is normalized into a button and gesture.
|
||||
3. Double-press detection is tracked per physical sensor ID.
|
||||
4. Automation rules match the remote's internal `remote_id`, button, gesture, and optional time condition.
|
||||
5. Actions execute through the same virtual-light command path as Home Assistant.
|
||||
|
||||
## Shared Controllers
|
||||
|
||||
- Runtime owns exactly one `PhysicalController` object per physical deCONZ controller ID.
|
||||
- Individual virtual lights reference those shared controller objects. Configured groups reference individual virtual lights, not controllers directly.
|
||||
- `ha_deconz_bridge.app` maintains a controller-to-virtual-lights index.
|
||||
- A deCONZ event updates the shared controller once, then every virtual light that includes that controller recomputes and republishes state. Groups containing any changed member recompute after those member updates.
|
||||
- This allows overlapping exposed entities, such as one individual light and one room/group light, to stay consistent after external physical changes.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Configuration
|
||||
|
||||
Configuration is Python, but typed and validated.
|
||||
|
||||
## Expected Module Exports
|
||||
|
||||
A runtime config module should export:
|
||||
|
||||
- `SETTINGS`: `ha_deconz_bridge.settings.Settings`
|
||||
- `LIGHTS`: `dict[str, ha_deconz_bridge.lights.VirtualLightSpec]`
|
||||
- `REMOTES`: optional `dict[str, ha_deconz_bridge.automation.RemoteSpec]`
|
||||
- `AUTOMATIONS`: optional `tuple[ha_deconz_bridge.automation.AutomationRule, ...]`
|
||||
|
||||
## Example
|
||||
|
||||
```python
|
||||
from configs.example_controllers import define_lights
|
||||
|
||||
LIGHTS = define_lights({
|
||||
"living_room": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"7": {
|
||||
"name": "North RGB+CCT",
|
||||
"mireds": (153, 500),
|
||||
"rgb": {
|
||||
"r": {"color": (0.6949, 0.3051), "brightness": 202},
|
||||
"g": {"color": (0.1414, 0.7181), "brightness": 479},
|
||||
"b": {"color": (0.1443, 0.0427), "brightness": 124},
|
||||
},
|
||||
"color_temp": {
|
||||
"ww": {"color": 417, "brightness": 520},
|
||||
"cw": {"color": 154, "brightness": 576},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The controller ID (`"7"`), physical mode names (`"rgb"` / `"color_temp"`), and channel roles (`"r"`, `"g"`, `"b"`, `"ww"`, `"cw"`) are taken from dictionary keys. `color` accepts:
|
||||
|
||||
- a single number as CT, using mireds below `1000` and Kelvin at or above `1000`
|
||||
- an `(x, y)` tuple
|
||||
- an RGB tuple, either `0..1` floats or `0..255` integers
|
||||
|
||||
`label` is optional. If omitted, channels inside physical `rgb` mode default to label `rgb`, and channels inside physical `color_temp` mode default to label `color_temp`. Use `label` only for unusual wiring, such as an RGB controller output connected to white LEDs.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- Supported virtual modes must be one of:
|
||||
- `("onoff",)`
|
||||
- `("brightness",)`
|
||||
- any non-empty subset of `("rgb", "color_temp")`
|
||||
- Controller mode roles must match the controller mode:
|
||||
- `rgb`: `r`, `g`, `b`
|
||||
- `color_temp`: `ww`, `cw`
|
||||
- `brightness`: `w`
|
||||
- `onoff`: `w`
|
||||
- Every channel defines the emitted physical color and max output. The role only describes how the backend controller address is written.
|
||||
- `ChannelSpec.semantic_label` is optional and may be:
|
||||
- `rgb`
|
||||
- `color_temp`
|
||||
- `boost`
|
||||
- If omitted, `rgb` and `color_temp` channels default to their mode name; `brightness` and `onoff` channels default to no label.
|
||||
- Solver priority is staged:
|
||||
- dimmable same-label channels first
|
||||
- then other dimmable channels
|
||||
- then `boost`/unlabeled dimmable channels
|
||||
- then same-label on/off channels
|
||||
- then other on/off channels
|
||||
- then `boost`/unlabeled on/off channels
|
||||
- Controllers may define multiple mutually exclusive modes, and the solver is free to choose one mode or turn that controller fully off.
|
||||
- A virtual light that exposes `color_temp` must have native CT-capable emitters.
|
||||
- A controller that claims a mode unsupported by the real device fails startup validation.
|
||||
|
||||
## Solver Responsiveness
|
||||
|
||||
`Settings.target_cache_scale` controls how precisely requested chromaticity is keyed in the solver cache. The default is `10000`, which keeps adjacent RGB/CT slider positions distinct in normal use. Lower values make nearby requests reuse the same reference solve, but can create visible color dead zones where a small slider movement does not change the achieved color. Treat lower values as an explicit responsiveness experiment, not the default behavior.
|
||||
|
||||
`Settings.approximate_chroma_band` controls how far a CT-line solution may sit from the ideal blackbody point while still being accepted. The default is `0.025` in uv distance. Lower values make CT requests more color-accurate but may choose RGB compensation more often; higher values prefer broad-spectrum CT output more aggressively.
|
||||
|
||||
`HA_DECONZ_BRIDGE_NUMERIC_BACKEND` controls the numeric backend used by the solver:
|
||||
|
||||
- `scipy`: default, current stable backend.
|
||||
- `numba`: try the Numba active-set backend for supported LP solves, with SciPy fallback when needed.
|
||||
- `auto`: same as `numba` when Numba is importable, otherwise SciPy.
|
||||
|
||||
Numba is an optional dependency. Install it with the `speed` extra before selecting `numba` or `auto`.
|
||||
|
||||
`HA_DECONZ_BRIDGE_WARMUP_ON_STARTUP` defaults to `true`. When enabled, the service and debugger start a background warmup that runs one representative solve per supported HA mode. This compiles Numba-backed paths without blocking MQTT or the debugger HTTP port.
|
||||
|
||||
## Config File Layout
|
||||
|
||||
The public example entry point is `configs/example.py`. It imports focused files:
|
||||
|
||||
- `configs/example_controllers.py`: physical controller/channel helper functions and constants.
|
||||
- `configs/example_lights.py`: individual virtual lights and which controllers they abstract.
|
||||
- `configs/example_groups.py`: room/group membership, optional member restrictions, and group balancing policy.
|
||||
- `configs/example_remotes.py`: physical remote sensor IDs mapped to internal `remote_id` values.
|
||||
- `configs/example_actions.py`: scenes, action helpers, and remote automation mappings.
|
||||
|
||||
Real home configs should stay in ignored local files such as `configs/local/`. The entry point should stay small and only assemble `SETTINGS`, `LIGHTS`, `GROUPS`, `REMOTES`, and `AUTOMATIONS`.
|
||||
|
||||
For a private local setup, copy the public example into the ignored local directory:
|
||||
|
||||
```bash
|
||||
mkdir -p configs/local
|
||||
cp configs/example.py configs/local/lights_config.py
|
||||
cp configs/example_*.py configs/local/
|
||||
```
|
||||
|
||||
Then edit the copied files. If you keep the split-file imports, update them from `configs.example_*` to `configs.local.example_*`, or rename the copied split files and import those names instead. Run with:
|
||||
|
||||
```bash
|
||||
ha-deconz-bridge-service configs/local/lights_config.py
|
||||
ha-deconz-bridge-debug-web configs/local/lights_config.py "Example Mixed North"
|
||||
```
|
||||
|
||||
The runtime accepts either a Python module name or a `.py` file path.
|
||||
|
||||
Groups are defined with `GroupSpec`. A member can be listed by name, or wrapped in `GroupMemberSpec` to restrict how that member behaves only inside that group:
|
||||
|
||||
```python
|
||||
from ha_deconz_bridge.groups import GroupMemberSpec, GroupSpec
|
||||
|
||||
GROUPS = {
|
||||
"Example Plant": GroupSpec(
|
||||
name="Example Plant",
|
||||
members=(
|
||||
"Example Fixed White A",
|
||||
"Example Fixed White B",
|
||||
GroupMemberSpec("Example Mixed North", fixed_color=182),
|
||||
),
|
||||
balance="relative_member",
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
By default, a group derives its exposed modes from the union of its members. If any member supports `rgb` or `color_temp`, the group exposes those color modes. If no member supports color but at least one member supports dimming, the group exposes `brightness`. If no member is dimmable, the group is `onoff`. `supported_color_modes` is still available as an explicit override.
|
||||
|
||||
`fixed_color` makes that member act like a dimmable fixed-color contributor only inside that group. It accepts a single number as color temperature (`<1000` is mireds, `>=1000` is kelvin), an RGB triple, or an xy pair. RGB input is normalized, so `(1.0, 1.0, 1.0)` and `(255, 255, 255)` describe the same color.
|
||||
|
||||
`fixed_brightness` requires `fixed_color` and makes that member act like an on/off fixed-output contributor inside the group. Values up to `1.0` are treated as relative brightness; larger values are treated as HA `0..255` brightness. On readback, the member counts as on when it reports at least 90% of that fixed output. Direct control of the member light is unchanged.
|
||||
|
||||
`balance="relative_member"` sends the same relative brightness to each member. The default `passthrough` sends the group command to every compatible member without trying to equalize member brightness.
|
||||
|
||||
## Remote Automation
|
||||
|
||||
Physical remotes are configured by deCONZ sensor ID, but automation rules bind to an internal `remote_id`.
|
||||
|
||||
```python
|
||||
from ha_deconz_bridge.automation import ActionSpec, RemoteSpec, automation_rules
|
||||
|
||||
REMOTES = {
|
||||
"example_sensor_1": RemoteSpec(
|
||||
sensor_id="example_sensor_1",
|
||||
remote_id="example_living",
|
||||
name="Example living remote",
|
||||
model="RWL021",
|
||||
double_press_window=0.45, # optional override
|
||||
),
|
||||
}
|
||||
|
||||
AUTOMATION_CONFIG = {
|
||||
"example_living": {
|
||||
"top": {
|
||||
"single": (ActionSpec("set_scene", target="Example Living", brightness=180, color_temp=330),),
|
||||
},
|
||||
"bottom": {
|
||||
"single": (ActionSpec("turn_off", target="Example Living"),),
|
||||
},
|
||||
"up": {
|
||||
"hold": (ActionSpec("brightness_step", target="Example Living", brightness_step=16),),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
AUTOMATIONS = automation_rules(AUTOMATION_CONFIG)
|
||||
```
|
||||
|
||||
`RemoteSpec.model` is informational in the current implementation. It documents the remote type and gives us a place to add model-specific validation or decoding later. The current Hue dimmer mapping is for `RWL021`.
|
||||
|
||||
Supported base gestures are `press`, `single`, `double`, `long`, `double_long`, `hold`, and `double_hold`.
|
||||
|
||||
Hold-count gesture patterns are also supported:
|
||||
|
||||
- `hold_x`: trigger only on the x'th hold event.
|
||||
- `double_hold_x`: trigger only on the x'th hold event of a double-hold.
|
||||
- `hold_x_y`: trigger from x through y, inclusive.
|
||||
- `double_hold_x_y`: same for double-hold.
|
||||
- If `y < x`, the trigger starts at x and continues until release. The recommended spelling for an unbounded hold is `hold_x_0` or `double_hold_x_0`.
|
||||
- If `y == x`, the trigger fires only on the x'th hold event.
|
||||
|
||||
Supported action kinds are:
|
||||
|
||||
- `turn_on`
|
||||
- `turn_off`
|
||||
- `set_scene`
|
||||
- `brightness_step`
|
||||
- `color_temp_step`
|
||||
|
||||
Action targets may be exact virtual light names, group names, or group expressions:
|
||||
|
||||
- `A+B+C`: union of groups/lights A, B, and C.
|
||||
- `A-B`: all members of A except members of B.
|
||||
- `A+B-C+D`: parsed left-to-right.
|
||||
- `All`: all individual virtual lights.
|
||||
- Empty string: same as `All`.
|
||||
- `-A` or `All-A`: all individual virtual lights except A.
|
||||
|
||||
If the target exactly matches an exposed virtual light or group and contains no `+` or `-`, the action is sent to that virtual entity directly. Compound expressions expand to individual virtual lights.
|
||||
|
||||
## Example Groups
|
||||
|
||||
`configs/example.py` exposes sanitized example groups in addition to individual lights:
|
||||
|
||||
- `Example Hall`: three RGB+CCT virtual lights.
|
||||
- `Example Kitchen`: one RGB+CCT virtual light.
|
||||
- `Example Bathroom`: one multi-controller ceiling light and one mirror light.
|
||||
- `Example Bedroom`: two RGB+CCT virtual lights.
|
||||
- `Example Living`: fixed-white, mixed RGB/CCT, and on/off examples.
|
||||
- `Example Plant`: fixed-white examples plus one constrained mixed light, using relative member brightness balancing.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Debugger
|
||||
|
||||
The debugger is a browser UI around the real core library. It can run in two modes:
|
||||
|
||||
- `virtual`: offline solver only
|
||||
- `live`: solver plus direct deCONZ reads/writes for the selected lights
|
||||
|
||||
## Goals
|
||||
|
||||
- use the exact same solver and translation code as runtime
|
||||
- visualize requested versus achieved color
|
||||
- show per-controller and per-channel activation
|
||||
- inspect timing and chosen controller modes
|
||||
|
||||
## Current Shape
|
||||
|
||||
- `ha-deconz-bridge-debug`: the original local Matplotlib tool
|
||||
- `ha-deconz-bridge-debug-web`: a browser-accessible debugger for remote development on the VM
|
||||
|
||||
Both variants:
|
||||
|
||||
- load a config module and one or more initial virtual lights
|
||||
- use the shared solver and color/translation code
|
||||
- let the user drive RGB or color temperature plus brightness
|
||||
- show achieved color, per-channel activation, controller commands, and solver metadata
|
||||
|
||||
## Web Debugger
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m ha_deconz_bridge.debugger.web_debugger ./configs/example.py "Example Mixed North" --host 127.0.0.1 --port 8765
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:8765` in a browser. If the debugger runs on the VM, tunnel the port over SSH if needed.
|
||||
|
||||
For real hardware, pass your ignored local config instead:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m ha_deconz_bridge.debugger.web_debugger ./configs/local/lights_config.py "Your Light Name" --host 127.0.0.1 --port 8765
|
||||
```
|
||||
|
||||
Inside the UI:
|
||||
|
||||
- `virtual` mode only runs the solver against the selected light set
|
||||
- `live` mode polls deCONZ for the selected lights and only writes to the backend when you move the sliders
|
||||
- changing the selected lights or switching between `rgb` and `color_temp` does not write anything to the backend
|
||||
- in `live` mode, external changes made through Phoscon or elsewhere are pulled back into the debugger on the next poll
|
||||
|
||||
- all configured lights are listed with checkboxes
|
||||
- the selected lights are merged into one temporary solver target
|
||||
- changing either the sliders or the light selection re-runs the solver
|
||||
|
||||
In `live` mode, the current color is reinterpreted for both RGB and CT controls. That means an arbitrary RGB state can still be shown on the CT slider, and switching UI modes does not itself send any backend change.
|
||||
|
||||
This is useful both for inspecting a single virtual light and for experimenting with possible grouped-light behavior before adding it to the runtime service.
|
||||
|
||||
## Running On The Pi
|
||||
|
||||
Once the app has been deployed, you can run the same debugger directly on the Pi:
|
||||
|
||||
```bash
|
||||
ssh user@host.example \
|
||||
'cd /path/to/ha-deconz-bridge/app && set -a && . ../config/service.env && set +a && ../.venv/bin/python -m ha_deconz_bridge.debugger.web_debugger ../config/lights_config.py --host 0.0.0.0 --port 8765'
|
||||
```
|
||||
|
||||
Then open `http://host.example:8765` from your browser, or tunnel the port over SSH if you prefer.
|
||||
|
||||
There is also a dedicated user service unit for the Pi-hosted debugger:
|
||||
|
||||
```bash
|
||||
ssh user@host.example 'systemctl --user start ha-deconz-bridge-debug.service'
|
||||
```
|
||||
|
||||
That service listens on:
|
||||
|
||||
```text
|
||||
http://host.example:8766
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# Deployment
|
||||
|
||||
This project is designed to run as a user-owned Python app on a small Linux host such as a Raspberry Pi.
|
||||
|
||||
## Public Repo Policy
|
||||
|
||||
The public repository intentionally does not track a real deployment script or real home config.
|
||||
|
||||
Keep these in ignored local files:
|
||||
|
||||
- real SSH hostnames, domains, and usernames
|
||||
- real remote install paths
|
||||
- real room, light, group, and remote names
|
||||
- real deCONZ IDs and API keys
|
||||
- local deployment scripts
|
||||
|
||||
The sanitized example config is [`configs/example.py`](../configs/example.py). A real deployment can use an ignored config such as `configs/local/lights_config.py`.
|
||||
|
||||
## Target Layout
|
||||
|
||||
A typical target machine can use this layout:
|
||||
|
||||
- `<install-dir>/app`: synced application code
|
||||
- `<install-dir>/.venv`: virtualenv used by the service
|
||||
- `<install-dir>/config/lights_config.py`: runtime light definitions
|
||||
- `<install-dir>/config/service.env`: runtime environment variables
|
||||
- `~/.config/systemd/user/ha-deconz-bridge.service`: user service unit
|
||||
- `~/.config/systemd/user/ha-deconz-bridge-debug.service`: optional web debugger service
|
||||
|
||||
## Service Units
|
||||
|
||||
The generic user service templates are tracked in [`deploy/`](../deploy/). They expect:
|
||||
|
||||
- `HA_DECONZ_BRIDGE_CONFIG` to point at the runtime config file
|
||||
- `HA_DECONZ_BRIDGE_SERVICE_ENV` or an equivalent environment file to provide secrets and host-specific settings
|
||||
- a virtualenv with the package installed
|
||||
|
||||
The environment file should not be committed. Use [`deploy/service.env.example`](../deploy/service.env.example) as a template.
|
||||
|
||||
## Manual Deployment Outline
|
||||
|
||||
1. Copy the repository to the target machine, excluding `.git`, virtualenvs, caches, and local/private files.
|
||||
2. Create a virtualenv on the target.
|
||||
3. Install the package, optionally with the speed extra:
|
||||
|
||||
```bash
|
||||
python3 -m venv <install-dir>/.venv
|
||||
<install-dir>/.venv/bin/pip install --upgrade pip
|
||||
<install-dir>/.venv/bin/pip install -e '<install-dir>/app[speed]'
|
||||
```
|
||||
|
||||
4. Copy a real ignored config to `<install-dir>/config/lights_config.py`.
|
||||
5. Copy and edit `deploy/service.env.example` to `<install-dir>/config/service.env`.
|
||||
6. Install the systemd user units from `deploy/`.
|
||||
7. Reload and start the service:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now ha-deconz-bridge.service
|
||||
```
|
||||
|
||||
## Creating A Private Config
|
||||
|
||||
Start from the public example:
|
||||
|
||||
```bash
|
||||
mkdir -p configs/local
|
||||
cp configs/example.py configs/local/lights_config.py
|
||||
cp configs/example_*.py configs/local/
|
||||
```
|
||||
|
||||
Edit the copied files for your real devices. If the entry point imports copied split files, make sure the imports point at `configs.local...` modules. For example:
|
||||
|
||||
```python
|
||||
from configs.local.example_lights import LIGHTS
|
||||
```
|
||||
|
||||
Then deploy or run with `configs/local/lights_config.py`. Keep that path private and uncommitted.
|
||||
|
||||
## Required Runtime Values
|
||||
|
||||
These values must be real for hardware use:
|
||||
|
||||
- deCONZ API key
|
||||
- deCONZ host/port
|
||||
- MQTT host/port and credentials if needed
|
||||
- physical controller IDs in `lights_config.py`
|
||||
|
||||
These can be approximate at first:
|
||||
|
||||
- channel chromaticities
|
||||
- per-channel brightness/lumen values
|
||||
- exact CT calibration
|
||||
|
||||
Approximate calibration is enough for integration testing. Exact calibration can come later.
|
||||
|
||||
## Debugger
|
||||
|
||||
The web debugger can run on the target machine against the same runtime config:
|
||||
|
||||
```bash
|
||||
cd <install-dir>/app
|
||||
set -a
|
||||
. ../config/service.env
|
||||
set +a
|
||||
../.venv/bin/python -m ha_deconz_bridge.debugger.web_debugger ../config/lights_config.py --host 0.0.0.0 --port 8765
|
||||
```
|
||||
|
||||
Open `http://<target-host>:8765` from a browser that can reach the target.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Implementation Plan
|
||||
|
||||
This repo reboot implements a deterministic virtual light abstraction with:
|
||||
|
||||
- typed specs and validation
|
||||
- one shared runtime/debug core
|
||||
- a compiled light model plus a shared solve function
|
||||
- exact-target solving with deterministic combination elimination
|
||||
- deCONZ backend support
|
||||
- Home Assistant MQTT bridge
|
||||
- coalesced HA command queueing
|
||||
- test coverage for color, solver, reverse mapping, queueing, and integration flows
|
||||
|
||||
The archived prototype remains available for reference but is not the implementation base.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Semantics
|
||||
|
||||
## Virtual Color Temperature Range
|
||||
|
||||
- A virtual light exposes the broadest native CT range available from its configured CT-capable emitters.
|
||||
- RGB emulation is not used to expand the published Home Assistant CT range.
|
||||
- Bogus deCONZ CT limits are ignored when config or calibration provides a better range.
|
||||
|
||||
## Brightness
|
||||
|
||||
- Home Assistant brightness `255` means the brightest achievable output at the requested color or color temperature.
|
||||
- Lower brightness values are derived after chroma selection. The solver first chooses the best reachable chroma set, then defines `255` from the brightest surviving combination at that chroma, and then scales down to the requested brightness.
|
||||
- Reverse mapping reports brightness relative to the brightest achievable output at the achieved reported color.
|
||||
|
||||
## Forward And Reverse Mapping
|
||||
|
||||
- The solver treats requested RGB or CT as exact whenever possible.
|
||||
- If the request is out of gamut, the solver keeps the combinations within a small chroma-error band of the best result and then continues the same elimination pipeline.
|
||||
- For CT requests, the primary white reference is not the ideal blackbody point. The solver uses the same reverse-CT model as readback and treats a requested CT as a constant-CT line in xy space.
|
||||
- CT-labeled emitters first maximize brightness along that constant-CT line. This defines the broad-spectrum white contribution and keeps forward/reverse CT mapping stable even when calibrated emitters do not lie on the blackbody locus.
|
||||
- RGB correction may be used at lower CT brightness if it can hit or approach the blackbody point while preserving most of the available CT-labeled contribution.
|
||||
- When exact blackbody correction is no longer feasible at the requested brightness, correction degrades by minimizing chroma error while staying on the requested constant-CT line instead of dropping correction abruptly.
|
||||
- At high brightness, CT-labeled output can remain approximate instead of sacrificing large amounts of white output for a small chroma correction.
|
||||
- `Settings.approximate_chroma_band` bounds how far a CT-line result may be from the blackbody target before it is rejected as too inaccurate.
|
||||
- RGB requests do not use this slack path. If any RGB-labeled emitter exists in the virtual light or group, RGB requests are solved using RGB-labeled emitters only. This prevents very bright white emitters from causing brightness cliffs as an RGB color sweep approaches the blackbody locus.
|
||||
- Home Assistant normally sees the achieved physical output, not a remembered request history.
|
||||
- Immediately after our own command, the accepted command state is treated as truth while deCONZ readback remains close enough to the command. This absorbs controller rounding, stale websocket events, and small drift without making HA sliders jump.
|
||||
- Physical controller commands use the solver's computed channel mix. They are not overwritten just to make a controller CT value equal the HA-requested CT.
|
||||
- Accepted CT command state preserves the requested CT chroma for reporting, but uses calibrated solver/channel output for brightness. This keeps HA -> physical -> HA stable even when the calibrated CCT emitters do not lie exactly on the blackbody locus.
|
||||
- Once readback no longer matches the accepted command within tolerance, accepted state is dropped and reverse mapping recomputes from calibrated physical channel output.
|
||||
- Repeated HA -> physical -> HA translation should converge to a stable reported state rather than drift.
|
||||
- When a physical controller changes externally, every exposed virtual light or group containing that controller should recompute from the shared controller state and publish an updated HA state.
|
||||
|
||||
## Groups
|
||||
|
||||
- Groups are exposed as Home Assistant lights, but at runtime they control member virtual lights instead of solving directly against physical controllers.
|
||||
- A group member may be constrained to a fixed RGB color or fixed color temperature. The restriction applies only through that group; direct control of the member light remains unchanged.
|
||||
- A constrained member is effectively a dimmable fixed-color contributor from the group point of view. The member's own solver still decides how to produce that fixed color physically.
|
||||
- Group capabilities are derived from effective member capabilities unless explicitly configured. A group whose members are all fixed-color or brightness-only contributors exposes `brightness`.
|
||||
- Group reverse mapping aggregates each member's accepted-or-physical output color, not the member's already-idealized HA-facing report color.
|
||||
- `relative_member` balancing sends the same relative brightness to every member, which is useful when a group represents physically separated areas that should brighten together.
|
||||
|
||||
## Mode Selection
|
||||
|
||||
- Native mode is not a separate solve path. Labels like `rgb` and `color_temp` are only prioritization hints after chroma and brightness feasibility are established.
|
||||
- Cross-family boosting is allowed when it increases reachable brightness while staying within the chosen chroma target or chroma fallback band.
|
||||
- On/off channels are last-resort brightness boosters and are minimized before final combination selection.
|
||||
- Reverse mapping chooses the dominant physical family for external states. Accepted command state reports the command's requested HA mode while it remains valid.
|
||||
- Ties fall back deterministically: `color_temp`, then `rgb`, then `brightness`, then `onoff`.
|
||||
|
||||
## Combination Elimination
|
||||
|
||||
- Only exclusivity groups create branches. Independent control paths may be active together.
|
||||
- If several combinations survive chroma and brightness filtering, the solver:
|
||||
1. minimizes on/off lumens
|
||||
2. maximizes same-label lumens
|
||||
3. prefers more output channels
|
||||
4. prefers higher total available lumens
|
||||
5. falls back to stable config order
|
||||
- After one combination is chosen, load balancing happens inside that combination rather than across several combinations.
|
||||
|
||||
## Remotes And Automations
|
||||
|
||||
- A physical remote is identified by its deCONZ sensor ID.
|
||||
- Automation rules do not bind directly to the physical sensor ID. They bind to an internal `remote_id`.
|
||||
- Replacing a remote should only require moving an existing `remote_id` to a different physical sensor ID.
|
||||
- Multiple physical remotes may share one `remote_id`, which makes them trigger the same automation rules.
|
||||
- Double-press state is tracked per physical sensor ID, not per `remote_id`, so two different remotes sharing one `remote_id` cannot accidentally combine into one double press.
|
||||
- `Settings.default_double_press_window` defines the global double-press window. A `RemoteSpec.double_press_window` override may replace it for one physical remote.
|
||||
- `RemoteSpec.model` documents the physical remote type. The current runtime does not branch on it yet.
|
||||
- For Philips Hue dimmer `RWL021`, deCONZ `buttonevent` values are interpreted as:
|
||||
- `1000`, `2000`, `3000`, `4000`: press for top/up/down/bottom
|
||||
- `1001`, `2001`, `3001`, `4001`: hold-active, exposed as `hold` or `double_hold`
|
||||
- `1002`, `2002`, `3002`, `4002`: short release, exposed as `single`
|
||||
- `1003`, `2003`, `3003`, `4003`: long release, exposed as `long` or `double_long`
|
||||
- A `single` action fires immediately on short release. If another short release for the same physical remote and button arrives inside the double-press window, an additional `double` action fires.
|
||||
- If a short release is followed by a second press inside the double-press window and that second press becomes a hold, hold events are exposed as `double_hold`; release is exposed as `double_long`.
|
||||
- Hold-count patterns are supported: `hold_x`, `double_hold_x`, `hold_x_y`, and `double_hold_x_y`. `x` must be positive. If `y < x`, the trigger starts at `x` and continues until release. If `y == x`, it fires only on the x'th hold event.
|
||||
- Automation target expressions support `+` and `-` over configured groups/lights. `All` and the empty string mean all individual virtual lights. Expressions are evaluated left-to-right.
|
||||
- Automation actions execute through the same virtual-light command path as Home Assistant commands.
|
||||
@@ -0,0 +1,40 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ha-deconz-bridge"
|
||||
version = "0.1.0"
|
||||
description = "Deterministic virtual light abstraction for Home Assistant and deCONZ"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"matplotlib>=3.8",
|
||||
"numpy>=1.26",
|
||||
"paho-mqtt>=2.1",
|
||||
"requests>=2.32",
|
||||
"scipy>=1.13",
|
||||
"websocket-client>=1.8",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
]
|
||||
speed = [
|
||||
"numba>=0.65",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
ha-deconz-bridge-service = "ha_deconz_bridge.app:main"
|
||||
ha-deconz-bridge-debug = "ha_deconz_bridge.debugger.mpl_debugger:main"
|
||||
ha-deconz-bridge-debug-web = "ha_deconz_bridge.debugger.web_debugger:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Deterministic virtual light abstraction for Home Assistant and deCONZ."""
|
||||
|
||||
from ha_deconz_bridge.automation import ActionSpec, AutomationRule, RemoteSpec, TimeCondition, automation_rules
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
|
||||
from ha_deconz_bridge.settings import Settings
|
||||
|
||||
__all__ = [
|
||||
"ActionSpec",
|
||||
"AutomationRule",
|
||||
"ChannelSpec",
|
||||
"Color",
|
||||
"ControllerModeSpec",
|
||||
"ControllerSpec",
|
||||
"RemoteSpec",
|
||||
"Settings",
|
||||
"TimeCondition",
|
||||
"VirtualLightSpec",
|
||||
"automation_rules",
|
||||
]
|
||||
@@ -0,0 +1,419 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import websocket
|
||||
|
||||
from ha_deconz_bridge.automation import ActionSpec, AutomationEngine, AutomationRule, RemoteSpec
|
||||
from ha_deconz_bridge.backends.deconz import DeconzBackend
|
||||
from ha_deconz_bridge.controller import PhysicalController
|
||||
from ha_deconz_bridge.events import DeconzEvent, DeconzSensorEvent, HaCommandEvent
|
||||
from ha_deconz_bridge.group_light import VirtualGroupLight
|
||||
from ha_deconz_bridge.groups import GroupSpec, normalize_group_specs
|
||||
from ha_deconz_bridge.ha import HomeAssistantBridge
|
||||
from ha_deconz_bridge.light import VirtualLight
|
||||
from ha_deconz_bridge.lights import VirtualLightSpec
|
||||
from ha_deconz_bridge.queueing import EventBroker
|
||||
from ha_deconz_bridge.settings import Settings
|
||||
from ha_deconz_bridge.solver import compile_light_model, solve_light
|
||||
from ha_deconz_bridge.warmup import WarmupRequest, run_warmup
|
||||
|
||||
|
||||
class HaDeconzBridgeApp:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
lights: dict[str, VirtualLightSpec],
|
||||
*,
|
||||
remotes: dict[str, RemoteSpec] | None = None,
|
||||
automations: tuple[AutomationRule, ...] = (),
|
||||
groups: dict[str, GroupSpec | tuple[str, ...]] | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.light_specs = lights
|
||||
self.group_specs = normalize_group_specs(groups or {})
|
||||
self.groups = {name: group.member_names() for name, group in self.group_specs.items()}
|
||||
self.base_light_names = set(self.light_specs)
|
||||
self.backend = DeconzBackend(settings)
|
||||
self.event_broker = EventBroker()
|
||||
self.ha_bridge = HomeAssistantBridge(settings)
|
||||
self.virtual_lights: dict[str, VirtualLight] = {}
|
||||
self.group_lights: dict[str, VirtualGroupLight] = {}
|
||||
self.lights: dict[str, VirtualLight | VirtualGroupLight] = {}
|
||||
self.controllers: dict[str, PhysicalController] = {}
|
||||
self.controller_lights: dict[str, list[VirtualLight]] = {}
|
||||
self.member_groups: dict[str, list[VirtualGroupLight]] = {}
|
||||
self.automation = AutomationEngine(
|
||||
remotes=remotes or {},
|
||||
rules=automations,
|
||||
default_double_press_window=settings.default_double_press_window,
|
||||
)
|
||||
|
||||
for light_name, spec in self.light_specs.items():
|
||||
controllers: dict[str, PhysicalController] = {}
|
||||
for controller_id, controller_spec in spec.controllers.items():
|
||||
controller = self.controllers.get(controller_id)
|
||||
if controller is None:
|
||||
controller = PhysicalController(
|
||||
controller_spec,
|
||||
backend=self.backend,
|
||||
settings=settings,
|
||||
fallback_mireds=spec.exposed_mireds,
|
||||
)
|
||||
self.controllers[controller_id] = controller
|
||||
elif controller.spec != controller_spec:
|
||||
raise ValueError(f"Conflicting controller definition for {controller_id!r}.")
|
||||
controllers[controller_id] = controller
|
||||
light = VirtualLight(
|
||||
spec,
|
||||
controllers=controllers,
|
||||
light_model=compile_light_model(
|
||||
spec,
|
||||
chroma_tolerance=settings.chroma_tolerance,
|
||||
target_cache_scale=settings.target_cache_scale,
|
||||
approximate_chroma_band=settings.approximate_chroma_band,
|
||||
),
|
||||
ha_bridge=self.ha_bridge,
|
||||
)
|
||||
self.virtual_lights[light_name] = light
|
||||
self.lights[light_name] = light
|
||||
for controller_id in controllers:
|
||||
self.controller_lights.setdefault(controller_id, []).append(light)
|
||||
for group_name, group_spec in self.group_specs.items():
|
||||
members = {
|
||||
member_name: self.virtual_lights[member_name]
|
||||
for member_name in group_spec.member_names()
|
||||
}
|
||||
group_light = VirtualGroupLight(
|
||||
group_spec,
|
||||
members=members,
|
||||
ha_bridge=self.ha_bridge,
|
||||
brightness_tolerance=settings.brightness_tolerance,
|
||||
)
|
||||
self.group_lights[group_name] = group_light
|
||||
self.lights[group_name] = group_light
|
||||
for member_name in group_spec.member_names():
|
||||
self.member_groups.setdefault(member_name, []).append(group_light)
|
||||
|
||||
def run(self) -> None:
|
||||
logging.info("Starting HA deCONZ Bridge app with %d virtual lights.", len(self.lights))
|
||||
self.ha_bridge.start(on_command=self.event_broker.put_ha)
|
||||
for light in self.lights.values():
|
||||
light.publish_discovery()
|
||||
light.publish_state()
|
||||
if self.settings.warmup_on_startup:
|
||||
threading.Thread(
|
||||
target=self.warmup_solvers,
|
||||
name="solver-warmup",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
ws_thread = threading.Thread(target=self._run_ws, daemon=True)
|
||||
ws_thread.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
event = self.event_broker.get()
|
||||
if event is None:
|
||||
return
|
||||
if isinstance(event, HaCommandEvent):
|
||||
logging.info(
|
||||
"Handling HA command for %s: on=%s brightness=%s rgb=%s color_temp=%s",
|
||||
event.light_name,
|
||||
event.on,
|
||||
event.brightness,
|
||||
event.rgb,
|
||||
event.color_temp,
|
||||
)
|
||||
self.handle_ha_command(event)
|
||||
elif isinstance(event, DeconzEvent):
|
||||
logging.info(
|
||||
"Handling deCONZ event for controller %s: %s",
|
||||
event.controller_id,
|
||||
event.raw_state,
|
||||
)
|
||||
self.handle_deconz_event(event)
|
||||
else:
|
||||
logging.info(
|
||||
"Handling deCONZ sensor event for sensor %s: %s",
|
||||
event.sensor_id,
|
||||
event.raw_state,
|
||||
)
|
||||
self.handle_sensor_event(event)
|
||||
finally:
|
||||
self.event_broker.close()
|
||||
self.ha_bridge.stop()
|
||||
|
||||
def handle_deconz_event(self, event: DeconzEvent) -> None:
|
||||
controller = self.controllers[event.controller_id]
|
||||
state = self.backend.normalize_event(event.controller_id, event.raw_state)
|
||||
controller.process_external_state(state)
|
||||
updated_names = []
|
||||
for light in self.controller_lights[event.controller_id]:
|
||||
light.handle_controller_update()
|
||||
updated_names.append(light.spec.name)
|
||||
self._publish_groups_for_members(set(updated_names))
|
||||
|
||||
def handle_ha_command(self, event: HaCommandEvent) -> None:
|
||||
light = self.lights[event.light_name]
|
||||
if isinstance(light, VirtualGroupLight):
|
||||
updated_names = set(light.handle_ha_command(event))
|
||||
self._publish_groups_for_members(updated_names, exclude={event.light_name})
|
||||
return
|
||||
for group in self.member_groups.get(event.light_name, ()):
|
||||
group.clear_accepted()
|
||||
light.handle_ha_command(event)
|
||||
updated_names = self._publish_overlapping_updates(
|
||||
light.spec.controllers,
|
||||
exclude={event.light_name},
|
||||
)
|
||||
updated_names.add(event.light_name)
|
||||
self._publish_groups_for_members(updated_names)
|
||||
|
||||
def handle_sensor_event(self, event: DeconzSensorEvent) -> None:
|
||||
result = self.automation.handle_sensor_event(
|
||||
event.sensor_id,
|
||||
event.raw_state,
|
||||
received_at=event.received_at,
|
||||
)
|
||||
if result is None:
|
||||
return
|
||||
gesture, actions = result
|
||||
logging.info(
|
||||
"Remote %s sensor %s button=%s gesture=%s actions=%d",
|
||||
gesture.remote_id,
|
||||
gesture.sensor_id,
|
||||
gesture.button,
|
||||
gesture.gesture,
|
||||
len(actions),
|
||||
)
|
||||
for action in actions:
|
||||
self._run_action(action)
|
||||
|
||||
def _run_action(self, action: ActionSpec) -> None:
|
||||
target_names = self._action_target_names(action.target)
|
||||
if action.kind == "turn_off":
|
||||
for target in target_names:
|
||||
self.handle_ha_command(HaCommandEvent(target, on=False))
|
||||
return
|
||||
if action.kind in {"turn_on", "set_scene"}:
|
||||
for target in target_names:
|
||||
self.handle_ha_command(
|
||||
HaCommandEvent(
|
||||
target,
|
||||
on=True,
|
||||
brightness=action.brightness,
|
||||
rgb=action.rgb,
|
||||
color_temp=action.color_temp,
|
||||
)
|
||||
)
|
||||
return
|
||||
if action.kind == "brightness_step":
|
||||
step = action.brightness_step or 0
|
||||
for target in target_names:
|
||||
light = self.lights[target]
|
||||
brightness = max(0, min(255, light.state.brightness + step))
|
||||
self.handle_ha_command(HaCommandEvent(target, on=brightness > 0, brightness=brightness))
|
||||
return
|
||||
if action.kind == "color_temp_step":
|
||||
step = action.color_temp_step or 0
|
||||
for target in target_names:
|
||||
light = self.lights[target]
|
||||
if "color_temp" not in light.spec.mode_set:
|
||||
continue
|
||||
low, high = light.spec.exposed_mireds or (153, 500)
|
||||
color_temp = max(low, min(high, int(light.state.color.color_temp) + step))
|
||||
self.handle_ha_command(HaCommandEvent(target, on=True, color_temp=color_temp))
|
||||
return
|
||||
raise ValueError(f"Unsupported action kind {action.kind!r}.")
|
||||
|
||||
def _publish_overlapping_updates(
|
||||
self,
|
||||
controller_ids: dict[str, object],
|
||||
*,
|
||||
exclude: set[str],
|
||||
) -> set[str]:
|
||||
affected = set(controller_ids)
|
||||
updated_names: set[str] = set()
|
||||
for light_name, light in self.lights.items():
|
||||
if not isinstance(light, VirtualLight):
|
||||
continue
|
||||
if light_name in exclude:
|
||||
continue
|
||||
if affected.isdisjoint(light.spec.controllers):
|
||||
continue
|
||||
light.handle_controller_update()
|
||||
updated_names.add(light_name)
|
||||
return updated_names
|
||||
|
||||
def _publish_groups_for_members(
|
||||
self,
|
||||
member_names: set[str],
|
||||
*,
|
||||
exclude: set[str] | None = None,
|
||||
) -> None:
|
||||
exclude = exclude or set()
|
||||
seen: set[str] = set()
|
||||
for member_name in member_names:
|
||||
for group in self.member_groups.get(member_name, ()):
|
||||
if group.spec.name in exclude or group.spec.name in seen:
|
||||
continue
|
||||
group.handle_member_update()
|
||||
seen.add(group.spec.name)
|
||||
|
||||
def _action_target_names(self, target: str) -> tuple[str, ...]:
|
||||
if target in self.lights and target not in {"", "All"} and not any(operator in target for operator in "+-"):
|
||||
return (target,)
|
||||
|
||||
selected: set[str]
|
||||
if target in {"", "All"} or target.startswith("-"):
|
||||
selected = set(self.base_light_names)
|
||||
else:
|
||||
selected = set()
|
||||
|
||||
for operator, name in self._target_terms(target):
|
||||
members = self._target_members(name)
|
||||
if operator == "+":
|
||||
selected.update(members)
|
||||
else:
|
||||
selected.difference_update(members)
|
||||
return tuple(name for name in self.light_specs if name in selected)
|
||||
|
||||
def _target_terms(self, expression: str) -> tuple[tuple[str, str], ...]:
|
||||
terms: list[tuple[str, str]] = []
|
||||
operator = "+"
|
||||
start = 0
|
||||
if expression.startswith(("+", "-")):
|
||||
operator = expression[0]
|
||||
start = 1
|
||||
for index in range(start, len(expression) + 1):
|
||||
if index < len(expression) and expression[index] not in "+-":
|
||||
continue
|
||||
name = expression[start:index].strip()
|
||||
if name:
|
||||
terms.append((operator, name))
|
||||
if index < len(expression):
|
||||
operator = expression[index]
|
||||
start = index + 1
|
||||
return tuple(terms)
|
||||
|
||||
def _target_members(self, name: str) -> set[str]:
|
||||
if name in {"", "All"}:
|
||||
return set(self.base_light_names)
|
||||
if name in self.groups:
|
||||
return set(self.groups[name])
|
||||
if name in self.lights:
|
||||
return {name}
|
||||
raise ValueError(f"Unknown action target {name!r}.")
|
||||
|
||||
def _run_ws(self) -> None:
|
||||
def on_open(_ws) -> None:
|
||||
logging.info("Connected to deCONZ websocket at %s", self.backend.websocket_url)
|
||||
|
||||
def on_message(_ws, message: str) -> None:
|
||||
parsed = self.backend.parse_ws_event(message)
|
||||
if parsed is None:
|
||||
return
|
||||
if isinstance(parsed, DeconzEvent):
|
||||
if parsed.controller_id not in self.controllers:
|
||||
return
|
||||
self.event_broker.put_deconz(parsed)
|
||||
else:
|
||||
if parsed.sensor_id not in self.automation.remotes:
|
||||
return
|
||||
self.event_broker.put_sensor(parsed)
|
||||
|
||||
def on_error(_ws, error) -> None:
|
||||
logging.error("deCONZ websocket error: %s", error)
|
||||
|
||||
def on_close(_ws, status_code, message) -> None:
|
||||
logging.warning("deCONZ websocket closed: code=%s message=%s", status_code, message)
|
||||
|
||||
ws_app = websocket.WebSocketApp(
|
||||
self.backend.websocket_url,
|
||||
on_open=on_open,
|
||||
on_message=on_message,
|
||||
on_error=on_error,
|
||||
on_close=on_close,
|
||||
)
|
||||
ws_app.run_forever()
|
||||
|
||||
def warmup_solvers(self) -> None:
|
||||
import time
|
||||
|
||||
started = time.perf_counter()
|
||||
light_by_spec_name = {light.spec.name: light for light in self.virtual_lights.values()}
|
||||
|
||||
def solve(spec: VirtualLightSpec, request: WarmupRequest) -> None:
|
||||
target, mode, brightness = request
|
||||
light = light_by_spec_name[spec.name]
|
||||
solve_light(
|
||||
light.light_model,
|
||||
target,
|
||||
brightness=brightness,
|
||||
requested_mode=mode,
|
||||
)
|
||||
|
||||
count = run_warmup((light.spec for light in self.virtual_lights.values()), solve)
|
||||
duration_ms = (time.perf_counter() - started) * 1000.0
|
||||
logging.info(
|
||||
"Completed solver warmup: lights=%d solves=%d duration_ms=%.2f",
|
||||
len(self.virtual_lights),
|
||||
count,
|
||||
duration_ms,
|
||||
)
|
||||
|
||||
|
||||
def load_config_module(module_or_path: str):
|
||||
_ensure_import_path(os.getcwd())
|
||||
if module_or_path.endswith(".py") and os.path.exists(module_or_path):
|
||||
_ensure_import_path(os.path.dirname(os.path.abspath(module_or_path)))
|
||||
spec = importlib.util.spec_from_file_location("ha_deconz_bridge_user_config", module_or_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load config from {module_or_path!r}.")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
return importlib.import_module(module_or_path)
|
||||
|
||||
|
||||
def _ensure_import_path(path: str) -> None:
|
||||
normalized = os.path.abspath(path or ".")
|
||||
if normalized not in sys.path:
|
||||
sys.path.insert(0, normalized)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
handlers: list[logging.Handler] = [logging.StreamHandler()]
|
||||
log_file = os.getenv("HA_DECONZ_BRIDGE_LOG_FILE") or os.getenv("HOME_LIGHTS_LOG_FILE")
|
||||
if log_file:
|
||||
handlers.append(logging.FileHandler(log_file))
|
||||
log_level = os.getenv("HA_DECONZ_BRIDGE_LOG_LEVEL") or os.getenv("HOME_LIGHTS_LOG_LEVEL") or "INFO"
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=handlers,
|
||||
)
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("config", help="Python module or .py path exporting SETTINGS and LIGHTS")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
module = load_config_module(args.config)
|
||||
settings = getattr(module, "SETTINGS", Settings.from_env())
|
||||
lights = getattr(module, "LIGHTS")
|
||||
remotes = getattr(module, "REMOTES", {})
|
||||
automations = tuple(getattr(module, "AUTOMATIONS", ()))
|
||||
groups = getattr(module, "GROUPS", {})
|
||||
app = HaDeconzBridgeApp(settings, lights, remotes=remotes, automations=automations, groups=groups)
|
||||
app.run()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Literal
|
||||
|
||||
|
||||
ButtonName = Literal["top", "up", "down", "bottom"]
|
||||
ActionKind = Literal["turn_on", "turn_off", "set_scene", "brightness_step", "color_temp_step"]
|
||||
|
||||
|
||||
BUTTONS_BY_PREFIX: dict[int, ButtonName] = {
|
||||
1: "top",
|
||||
2: "up",
|
||||
3: "down",
|
||||
4: "bottom",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteSpec:
|
||||
sensor_id: str
|
||||
remote_id: str
|
||||
name: str
|
||||
model: str = "RWL021"
|
||||
double_press_window: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TimeCondition:
|
||||
start: str
|
||||
end: str
|
||||
|
||||
def matches(self, now: datetime | None = None) -> bool:
|
||||
current = (now or datetime.now()).time()
|
||||
start = _parse_time(self.start)
|
||||
end = _parse_time(self.end)
|
||||
if start <= end:
|
||||
return start <= current < end
|
||||
return current >= start or current < end
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActionSpec:
|
||||
kind: ActionKind
|
||||
target: str
|
||||
brightness: int | None = None
|
||||
rgb: tuple[int, int, int] | None = None
|
||||
color_temp: int | None = None
|
||||
brightness_step: int | None = None
|
||||
color_temp_step: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AutomationRule:
|
||||
remote_id: str
|
||||
button: ButtonName
|
||||
gesture: str
|
||||
actions: tuple[ActionSpec, ...]
|
||||
time_condition: TimeCondition | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteGestureEvent:
|
||||
sensor_id: str
|
||||
remote_id: str
|
||||
button: ButtonName
|
||||
gesture: str
|
||||
raw_buttonevent: int
|
||||
eventduration: int | None = None
|
||||
hold_count: int | None = None
|
||||
received_at: float = 0.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RemoteGestureDetector:
|
||||
default_double_press_window: float
|
||||
_last_single_at: dict[tuple[str, ButtonName], float] = field(default_factory=dict)
|
||||
_double_press_active: set[tuple[str, ButtonName]] = field(default_factory=set)
|
||||
_hold_counts: dict[tuple[str, ButtonName], int] = field(default_factory=dict)
|
||||
|
||||
def handle(
|
||||
self,
|
||||
remote: RemoteSpec,
|
||||
raw_state: dict[str, object],
|
||||
*,
|
||||
received_at: float,
|
||||
) -> tuple[RemoteGestureEvent, ...]:
|
||||
raw_event = raw_state.get("buttonevent")
|
||||
if not isinstance(raw_event, int):
|
||||
return ()
|
||||
button_prefix = raw_event // 1000
|
||||
event_code = raw_event % 1000
|
||||
button = BUTTONS_BY_PREFIX.get(button_prefix)
|
||||
if button is None:
|
||||
return ()
|
||||
|
||||
eventduration = raw_state.get("eventduration")
|
||||
duration = eventduration if isinstance(eventduration, int) else None
|
||||
base = {
|
||||
"sensor_id": remote.sensor_id,
|
||||
"remote_id": remote.remote_id,
|
||||
"button": button,
|
||||
"raw_buttonevent": raw_event,
|
||||
"eventduration": duration,
|
||||
"received_at": received_at,
|
||||
}
|
||||
|
||||
key = (remote.sensor_id, button)
|
||||
window = remote.double_press_window or self.default_double_press_window
|
||||
|
||||
if event_code == 0:
|
||||
self._hold_counts[key] = 0
|
||||
previous_at = self._last_single_at.get(key)
|
||||
if previous_at is not None and received_at - previous_at <= window:
|
||||
self._double_press_active.add(key)
|
||||
return (RemoteGestureEvent(gesture="press", **base),)
|
||||
if event_code == 1:
|
||||
hold_count = self._hold_counts.get(key, 0) + 1
|
||||
self._hold_counts[key] = hold_count
|
||||
if key in self._double_press_active:
|
||||
self._last_single_at.pop(key, None)
|
||||
return (RemoteGestureEvent(gesture="double_hold", hold_count=hold_count, **base),)
|
||||
return (RemoteGestureEvent(gesture="hold", hold_count=hold_count, **base),)
|
||||
if event_code == 3:
|
||||
self._hold_counts.pop(key, None)
|
||||
if key in self._double_press_active:
|
||||
self._double_press_active.remove(key)
|
||||
self._last_single_at.pop(key, None)
|
||||
return (RemoteGestureEvent(gesture="double_long", **base),)
|
||||
return (RemoteGestureEvent(gesture="long", **base),)
|
||||
if event_code != 2:
|
||||
return ()
|
||||
|
||||
self._hold_counts.pop(key, None)
|
||||
if key in self._double_press_active:
|
||||
self._double_press_active.remove(key)
|
||||
self._last_single_at.pop(key, None)
|
||||
return (RemoteGestureEvent(gesture="double", **base),)
|
||||
self._last_single_at[key] = received_at
|
||||
return (RemoteGestureEvent(gesture="single", **base),)
|
||||
|
||||
|
||||
class AutomationEngine:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
remotes: dict[str, RemoteSpec],
|
||||
rules: tuple[AutomationRule, ...],
|
||||
default_double_press_window: float,
|
||||
) -> None:
|
||||
self.remotes = remotes
|
||||
self.rules = rules
|
||||
self.gestures = RemoteGestureDetector(default_double_press_window)
|
||||
|
||||
def handle_sensor_event(
|
||||
self,
|
||||
sensor_id: str,
|
||||
raw_state: dict[str, object],
|
||||
*,
|
||||
received_at: float,
|
||||
) -> tuple[RemoteGestureEvent, tuple[ActionSpec, ...]] | None:
|
||||
remote = self.remotes.get(sensor_id)
|
||||
if remote is None:
|
||||
return None
|
||||
gestures = self.gestures.handle(remote, raw_state, received_at=received_at)
|
||||
if not gestures:
|
||||
return None
|
||||
|
||||
actions: list[ActionSpec] = []
|
||||
for gesture in gestures:
|
||||
actions.extend(self.actions_for_gesture(gesture))
|
||||
return (gestures[-1], tuple(actions))
|
||||
|
||||
def actions_for_gesture(self, event: RemoteGestureEvent) -> tuple[ActionSpec, ...]:
|
||||
actions: list[ActionSpec] = []
|
||||
now = datetime.fromtimestamp(event.received_at)
|
||||
for rule in self.rules:
|
||||
if rule.remote_id != event.remote_id:
|
||||
continue
|
||||
if rule.button != event.button or not gesture_matches(rule.gesture, event):
|
||||
continue
|
||||
if rule.time_condition is not None and not rule.time_condition.matches(now):
|
||||
continue
|
||||
actions.extend(rule.actions)
|
||||
return tuple(actions)
|
||||
|
||||
|
||||
def _parse_time(value: str) -> time:
|
||||
hour, minute = value.split(":", 1)
|
||||
return time(hour=int(hour), minute=int(minute))
|
||||
|
||||
|
||||
def automation_rules(
|
||||
config: Mapping[str, Mapping[str, Mapping[str, Sequence[ActionSpec]]]],
|
||||
) -> tuple[AutomationRule, ...]:
|
||||
rules: list[AutomationRule] = []
|
||||
for remote_id, buttons in config.items():
|
||||
for button, gestures in buttons.items():
|
||||
if button not in BUTTONS_BY_PREFIX.values():
|
||||
raise ValueError(f"Unsupported remote button {button!r}.")
|
||||
for gesture, actions in gestures.items():
|
||||
rules.append(
|
||||
AutomationRule(
|
||||
remote_id=remote_id,
|
||||
button=button,
|
||||
gesture=gesture,
|
||||
actions=tuple(actions),
|
||||
)
|
||||
)
|
||||
return tuple(rules)
|
||||
|
||||
|
||||
def gesture_matches(rule_gesture: str, event: RemoteGestureEvent) -> bool:
|
||||
if rule_gesture == event.gesture:
|
||||
return True
|
||||
if event.gesture not in {"hold", "double_hold"}:
|
||||
return False
|
||||
if event.hold_count is None:
|
||||
return False
|
||||
|
||||
prefix = f"{event.gesture}_"
|
||||
if not rule_gesture.startswith(prefix):
|
||||
return False
|
||||
parts = rule_gesture[len(prefix) :].split("_")
|
||||
if len(parts) not in {1, 2}:
|
||||
return False
|
||||
try:
|
||||
start = int(parts[0])
|
||||
end = int(parts[1]) if len(parts) == 2 else start
|
||||
except ValueError:
|
||||
return False
|
||||
if start <= 0:
|
||||
return False
|
||||
if len(parts) == 1:
|
||||
return event.hold_count == start
|
||||
if end < start:
|
||||
return event.hold_count >= start
|
||||
return start <= event.hold_count <= end
|
||||
@@ -0,0 +1,6 @@
|
||||
from ha_deconz_bridge.backends.base import ControllerBackend
|
||||
from ha_deconz_bridge.backends.deconz import DeconzBackend
|
||||
from ha_deconz_bridge.backends.fake import FakeControllerBackend
|
||||
|
||||
__all__ = ["ControllerBackend", "DeconzBackend", "FakeControllerBackend"]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from ha_deconz_bridge.state import ControllerCommand, ControllerState
|
||||
|
||||
|
||||
class ControllerBackend(Protocol):
|
||||
def fetch_controller_resource(self, controller_id: str) -> dict[str, object]:
|
||||
...
|
||||
|
||||
def read_controller_state(self, controller_id: str) -> ControllerState:
|
||||
...
|
||||
|
||||
def write_controller_command(self, controller_id: str, command: ControllerCommand) -> None:
|
||||
...
|
||||
|
||||
def normalize_event(
|
||||
self,
|
||||
controller_id: str,
|
||||
raw_state: dict[str, object],
|
||||
) -> ControllerState:
|
||||
...
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from ha_deconz_bridge.settings import Settings
|
||||
from ha_deconz_bridge.events import DeconzEvent, DeconzSensorEvent
|
||||
from ha_deconz_bridge.state import ControllerCommand, ControllerState
|
||||
|
||||
|
||||
def _reasonable_mireds(low: int | None, high: int | None) -> bool:
|
||||
if low is None or high is None:
|
||||
return False
|
||||
return 100 <= low < high <= 1000
|
||||
|
||||
|
||||
class DeconzBackend:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
self._session = requests.Session()
|
||||
|
||||
@property
|
||||
def websocket_url(self) -> str:
|
||||
return f"ws://{self.settings.deconz_host}:{self.settings.deconz_ws_port}"
|
||||
|
||||
def fetch_controller_resource(self, controller_id: str) -> dict[str, object]:
|
||||
response = self._session.get(
|
||||
self._controller_url(controller_id),
|
||||
timeout=self.settings.http_timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def read_controller_state(self, controller_id: str) -> ControllerState:
|
||||
resource = self.fetch_controller_resource(controller_id)
|
||||
return self._normalize_state(resource.get("state", {}))
|
||||
|
||||
def write_controller_command(self, controller_id: str, command: ControllerCommand) -> None:
|
||||
payload = self._command_payload(command)
|
||||
logging.info("Writing deCONZ command for controller %s: %s", controller_id, payload)
|
||||
response = self._session.put(
|
||||
f"{self._controller_url(controller_id)}/state",
|
||||
json=payload,
|
||||
timeout=self.settings.http_timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
def normalize_event(
|
||||
self,
|
||||
controller_id: str,
|
||||
raw_state: dict[str, object],
|
||||
) -> ControllerState:
|
||||
return self._normalize_state(raw_state)
|
||||
|
||||
def parse_ws_message(self, raw_message: str) -> tuple[str, dict[str, object]] | None:
|
||||
event = self.parse_ws_event(raw_message)
|
||||
if isinstance(event, DeconzEvent):
|
||||
return (event.controller_id, event.raw_state)
|
||||
return None
|
||||
|
||||
def parse_ws_event(self, raw_message: str) -> DeconzEvent | DeconzSensorEvent | None:
|
||||
payload = json.loads(raw_message)
|
||||
if payload.get("t") != "event":
|
||||
return None
|
||||
resource = payload.get("r")
|
||||
resource_id = str(payload.get("id"))
|
||||
state = payload.get("state")
|
||||
if not isinstance(state, dict):
|
||||
return None
|
||||
if resource == "lights":
|
||||
return DeconzEvent(resource_id, state)
|
||||
if resource == "sensors":
|
||||
return DeconzSensorEvent(resource_id, state)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_mireds(resource: dict[str, Any]) -> tuple[int, int] | None:
|
||||
low = resource.get("ctmin")
|
||||
high = resource.get("ctmax")
|
||||
if not _reasonable_mireds(low if isinstance(low, int) else None, high if isinstance(high, int) else None):
|
||||
capabilities = resource.get("capabilities", {}) or resource.get("capatilities", {})
|
||||
color = capabilities.get("color", {}) if isinstance(capabilities, dict) else {}
|
||||
ct = color.get("ct", {}) if isinstance(color, dict) else {}
|
||||
low = ct.get("min")
|
||||
high = ct.get("max")
|
||||
if _reasonable_mireds(low if isinstance(low, int) else None, high if isinstance(high, int) else None):
|
||||
return (int(low), int(high))
|
||||
return None
|
||||
|
||||
def _controller_url(self, controller_id: str) -> str:
|
||||
return (
|
||||
f"http://{self.settings.deconz_host}:{self.settings.deconz_http_port}"
|
||||
f"/api/{self.settings.deconz_api_key}/lights/{controller_id}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _command_payload(command: ControllerCommand) -> dict[str, object]:
|
||||
payload: dict[str, object] = {"on": command.on, "transitiontime": 0}
|
||||
if not command.on:
|
||||
return payload
|
||||
if command.brightness is not None:
|
||||
payload["bri"] = max(0, min(254, int(command.brightness)))
|
||||
if command.mode == "rgb" and command.xy is not None:
|
||||
payload["xy"] = [float(command.xy[0]), float(command.xy[1])]
|
||||
elif command.mode == "color_temp" and command.color_temp is not None:
|
||||
payload["ct"] = int(command.color_temp)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _normalize_state(raw_state: dict[str, object]) -> ControllerState:
|
||||
reachable = bool(raw_state.get("reachable", True))
|
||||
on = bool(raw_state.get("on", False))
|
||||
brightness = raw_state.get("bri")
|
||||
if isinstance(brightness, bool):
|
||||
brightness = None
|
||||
brightness_int = int(brightness) if brightness is not None else None
|
||||
|
||||
colormode = raw_state.get("colormode")
|
||||
if colormode == "xy":
|
||||
xy_value = raw_state.get("xy")
|
||||
xy = None
|
||||
if isinstance(xy_value, (list, tuple)) and len(xy_value) == 2:
|
||||
xy = (float(xy_value[0]), float(xy_value[1]))
|
||||
return ControllerState(
|
||||
on=on,
|
||||
reachable=reachable,
|
||||
brightness=brightness_int,
|
||||
mode="rgb",
|
||||
xy=xy,
|
||||
)
|
||||
if colormode == "ct":
|
||||
ct = raw_state.get("ct")
|
||||
return ControllerState(
|
||||
on=on,
|
||||
reachable=reachable,
|
||||
brightness=brightness_int,
|
||||
mode="color_temp",
|
||||
color_temp=(None if ct is None else int(ct)),
|
||||
)
|
||||
if brightness_int is not None:
|
||||
return ControllerState(
|
||||
on=on,
|
||||
reachable=reachable,
|
||||
brightness=brightness_int,
|
||||
mode="brightness",
|
||||
)
|
||||
return ControllerState(on=on, reachable=reachable, mode="onoff")
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from ha_deconz_bridge.state import ControllerCommand, ControllerState
|
||||
from ha_deconz_bridge.translate import controller_command_to_state
|
||||
|
||||
|
||||
class FakeControllerBackend:
|
||||
def __init__(self, resources: dict[str, dict[str, object]]) -> None:
|
||||
self._resources = deepcopy(resources)
|
||||
|
||||
def fetch_controller_resource(self, controller_id: str) -> dict[str, object]:
|
||||
return deepcopy(self._resources[controller_id])
|
||||
|
||||
def read_controller_state(self, controller_id: str) -> ControllerState:
|
||||
resource = self._resources[controller_id]
|
||||
state = resource.get("state", {})
|
||||
return self.normalize_event(controller_id, state)
|
||||
|
||||
def write_controller_command(self, controller_id: str, command: ControllerCommand) -> None:
|
||||
state = controller_command_to_state(command)
|
||||
resource = self._resources[controller_id]
|
||||
raw_state: dict[str, object] = {
|
||||
"reachable": True,
|
||||
"on": state.on,
|
||||
}
|
||||
if state.brightness is not None:
|
||||
raw_state["bri"] = state.brightness
|
||||
if state.mode == "rgb" and state.xy is not None:
|
||||
raw_state["colormode"] = "xy"
|
||||
raw_state["xy"] = list(state.xy)
|
||||
elif state.mode == "color_temp" and state.color_temp is not None:
|
||||
raw_state["colormode"] = "ct"
|
||||
raw_state["ct"] = state.color_temp
|
||||
resource["state"] = raw_state
|
||||
|
||||
def normalize_event(
|
||||
self,
|
||||
controller_id: str,
|
||||
raw_state: dict[str, object],
|
||||
) -> ControllerState:
|
||||
from ha_deconz_bridge.backends.deconz import DeconzBackend
|
||||
|
||||
return DeconzBackend._normalize_state(raw_state)
|
||||
|
||||
def push_state(self, controller_id: str, raw_state: dict[str, object]) -> None:
|
||||
self._resources[controller_id]["state"] = deepcopy(raw_state)
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _clamp(value: float, low: float, high: float) -> float:
|
||||
return max(low, min(high, value))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Color:
|
||||
"""Color stored internally in XYZ with brightness carried in Y."""
|
||||
|
||||
_xyz: np.ndarray
|
||||
_mode: str = ""
|
||||
_temp_range: tuple[int, int] = (153, 500)
|
||||
_source_color_temp: int | None = None
|
||||
|
||||
M_RGB_TO_XYZ = np.array(
|
||||
[
|
||||
[0.4124564, 0.3575761, 0.1804375],
|
||||
[0.2126729, 0.7151522, 0.0721750],
|
||||
[0.0193339, 0.1191920, 0.9503041],
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
M_XYZ_TO_RGB = np.linalg.inv(M_RGB_TO_XYZ)
|
||||
D65_X = 0.31272
|
||||
D65_Y = 0.32903
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
rgb: Iterable[float] | None = None,
|
||||
xy: tuple[float, float] | None = None,
|
||||
xyz: Iterable[float] | None = None,
|
||||
uv: tuple[float, float] | None = None,
|
||||
color_temp: float | None = None,
|
||||
brightness: float | None = None,
|
||||
temp_range: tuple[int, int] = (153, 500),
|
||||
mode: str | None = None,
|
||||
) -> None:
|
||||
self._temp_range = tuple(sorted(tuple(int(v) for v in temp_range)))
|
||||
self._source_color_temp = None
|
||||
kinds = [rgb is not None, xy is not None, xyz is not None, uv is not None, color_temp is not None]
|
||||
if sum(kinds) > 1:
|
||||
raise ValueError("Only one color representation can be provided.")
|
||||
|
||||
if xyz is not None:
|
||||
xyz_array = np.asarray(tuple(float(v) for v in xyz), dtype=float)
|
||||
if xyz_array.shape != (3,):
|
||||
raise ValueError("XYZ must contain exactly three values.")
|
||||
self._xyz = np.clip(xyz_array, 0.0, None)
|
||||
self._mode = "rgb" if mode is None else mode
|
||||
return
|
||||
|
||||
if rgb is not None:
|
||||
rgb_array = np.asarray(tuple(float(v) for v in rgb), dtype=float)
|
||||
if rgb_array.shape != (3,):
|
||||
raise ValueError("RGB must contain exactly three values.")
|
||||
if np.max(rgb_array) > 1.0:
|
||||
rgb_array = rgb_array / 255.0
|
||||
brightness_value = float(1.0 if brightness is None else brightness)
|
||||
self._xyz = self.srgb_to_xyz(rgb_array, brightness_value)
|
||||
self._mode = mode or "rgb"
|
||||
return
|
||||
|
||||
if xy is not None:
|
||||
brightness_value = float(1.0 if brightness is None else brightness)
|
||||
self._xyz = self.xy_to_xyz(xy[0], xy[1], brightness_value)
|
||||
self._mode = mode or "rgb"
|
||||
return
|
||||
|
||||
if uv is not None:
|
||||
brightness_value = float(1.0 if brightness is None else brightness)
|
||||
xy_value = self.uv_to_xy(uv[0], uv[1])
|
||||
self._xyz = self.xy_to_xyz(xy_value[0], xy_value[1], brightness_value)
|
||||
self._mode = mode or "rgb"
|
||||
return
|
||||
|
||||
if color_temp is not None:
|
||||
brightness_value = float(1.0 if brightness is None else brightness)
|
||||
x, y = self.k_to_xy(self.mired_to_kelvin(float(color_temp)))
|
||||
self._xyz = self.xy_to_xyz(x, y, brightness_value)
|
||||
self._mode = mode or "color_temp"
|
||||
self._source_color_temp = int(round(color_temp))
|
||||
return
|
||||
|
||||
self._xyz = np.zeros(3, dtype=float)
|
||||
self._mode = mode or ""
|
||||
|
||||
@classmethod
|
||||
def off(cls) -> "Color":
|
||||
return cls(xyz=(0.0, 0.0, 0.0), mode="")
|
||||
|
||||
@classmethod
|
||||
def from_rgb(
|
||||
cls,
|
||||
rgb: Iterable[float],
|
||||
*,
|
||||
brightness: float = 1.0,
|
||||
) -> "Color":
|
||||
return cls(rgb=rgb, brightness=brightness)
|
||||
|
||||
@classmethod
|
||||
def from_xy(
|
||||
cls,
|
||||
xy: tuple[float, float],
|
||||
*,
|
||||
brightness: float = 1.0,
|
||||
) -> "Color":
|
||||
return cls(xy=xy, brightness=brightness)
|
||||
|
||||
@classmethod
|
||||
def from_xyz(cls, xyz: Iterable[float], *, mode: str = "rgb") -> "Color":
|
||||
return cls(xyz=xyz, mode=mode)
|
||||
|
||||
@classmethod
|
||||
def from_color_temp(
|
||||
cls,
|
||||
mireds: float,
|
||||
*,
|
||||
brightness: float = 1.0,
|
||||
temp_range: tuple[int, int] = (153, 500),
|
||||
) -> "Color":
|
||||
return cls(
|
||||
color_temp=mireds,
|
||||
brightness=brightness,
|
||||
temp_range=temp_range,
|
||||
mode="color_temp",
|
||||
)
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
return self._mode
|
||||
|
||||
@property
|
||||
def xyz(self) -> np.ndarray:
|
||||
return self._xyz.copy()
|
||||
|
||||
@property
|
||||
def XYZ(self) -> np.ndarray:
|
||||
return self.xyz
|
||||
|
||||
@property
|
||||
def brightness(self) -> float:
|
||||
return float(self._xyz[1])
|
||||
|
||||
@property
|
||||
def is_off(self) -> bool:
|
||||
return self.brightness <= 0.0
|
||||
|
||||
@property
|
||||
def xy(self) -> tuple[float, float]:
|
||||
denom = float(np.sum(self._xyz))
|
||||
if denom <= 0.0:
|
||||
return (self.D65_X, self.D65_Y)
|
||||
return (float(self._xyz[0] / denom), float(self._xyz[1] / denom))
|
||||
|
||||
@property
|
||||
def uv(self) -> np.ndarray:
|
||||
return np.asarray(self.xy_to_uv(*self.xy), dtype=float)
|
||||
|
||||
@property
|
||||
def rgb(self) -> np.ndarray:
|
||||
linear = self.M_XYZ_TO_RGB @ self._xyz
|
||||
linear = np.clip(linear, 0.0, None)
|
||||
max_linear = float(np.max(linear))
|
||||
if max_linear <= 0.0:
|
||||
return np.zeros(3, dtype=float)
|
||||
linear = linear / max_linear
|
||||
return self.linear_to_srgb(linear)
|
||||
|
||||
@property
|
||||
def RGB(self) -> np.ndarray:
|
||||
return self.rgb
|
||||
|
||||
@property
|
||||
def color_temp(self) -> int:
|
||||
if self._source_color_temp is not None:
|
||||
return self._source_color_temp
|
||||
kelvin = self.xy_to_k(*self.xy)
|
||||
return int(round(self.kelvin_to_mired(kelvin)))
|
||||
|
||||
def copy(self) -> "Color":
|
||||
color = Color(xyz=self._xyz.copy(), mode=self._mode, temp_range=self._temp_range)
|
||||
color._source_color_temp = self._source_color_temp
|
||||
return color
|
||||
|
||||
def with_brightness(self, brightness: float) -> "Color":
|
||||
brightness = max(0.0, float(brightness))
|
||||
current = self.brightness
|
||||
if current <= 0.0:
|
||||
color = Color.from_xy(self.xy, brightness=brightness).as_mode(self.mode)
|
||||
color._source_color_temp = self._source_color_temp
|
||||
return color
|
||||
color = Color.from_xyz(self._xyz * (brightness / current), mode=self.mode)
|
||||
color._source_color_temp = self._source_color_temp
|
||||
return color
|
||||
|
||||
def as_mode(self, mode: str) -> "Color":
|
||||
color = Color.from_xyz(self._xyz, mode=mode or self.mode)
|
||||
color._source_color_temp = self._source_color_temp
|
||||
return color
|
||||
|
||||
def ct_mix(self, mireds_range: tuple[int, int] | None = None) -> tuple[float, float]:
|
||||
low, high = tuple(sorted(mireds_range or self._temp_range))
|
||||
if high <= low:
|
||||
return (1.0, 0.0)
|
||||
temp = float(_clamp(self.color_temp, low, high))
|
||||
warm = (temp - low) / (high - low)
|
||||
cool = 1.0 - warm
|
||||
peak = max(cool, warm)
|
||||
if peak <= 0.0:
|
||||
return (0.0, 0.0)
|
||||
return (cool / peak, warm / peak)
|
||||
|
||||
def uv_distance(self, other: "Color") -> float:
|
||||
return float(np.linalg.norm(self.uv - other.uv))
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return not self.is_off
|
||||
|
||||
def __add__(self, other: "Color") -> "Color":
|
||||
if self.mode == "" and other.mode == "":
|
||||
return Color.off()
|
||||
if self.mode == "":
|
||||
return other.copy()
|
||||
if other.mode == "":
|
||||
return self.copy()
|
||||
return Color.from_xyz(self._xyz + other._xyz, mode="rgb")
|
||||
|
||||
def __iadd__(self, other: "Color") -> "Color":
|
||||
combined = self + other
|
||||
self._xyz = combined._xyz
|
||||
self._mode = combined._mode
|
||||
self._source_color_temp = combined._source_color_temp
|
||||
return self
|
||||
|
||||
def __mul__(self, factor: float) -> "Color":
|
||||
return self.with_brightness(self.brightness * float(factor))
|
||||
|
||||
def __rmul__(self, factor: float) -> "Color":
|
||||
return self.__mul__(factor)
|
||||
|
||||
def __imul__(self, factor: float) -> "Color":
|
||||
scaled = self * factor
|
||||
self._xyz = scaled._xyz
|
||||
self._mode = scaled._mode
|
||||
self._source_color_temp = scaled._source_color_temp
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def weighted_sum(
|
||||
cls,
|
||||
colors: Iterable["Color"],
|
||||
weights: Iterable[float] | None = None,
|
||||
) -> "Color":
|
||||
colors_tuple = tuple(colors)
|
||||
if not colors_tuple:
|
||||
return cls.off()
|
||||
if weights is None:
|
||||
weights_array = np.ones(len(colors_tuple), dtype=float)
|
||||
else:
|
||||
weights_array = np.asarray(tuple(float(v) for v in weights), dtype=float)
|
||||
if len(colors_tuple) != len(weights_array):
|
||||
raise ValueError("Color and weight counts must match.")
|
||||
|
||||
xyz_stack = np.asarray([color.xyz for color in colors_tuple], dtype=float)
|
||||
return cls.from_xyz(weights_array @ xyz_stack, mode="rgb")
|
||||
|
||||
@staticmethod
|
||||
def srgb_to_linear(srgb: np.ndarray) -> np.ndarray:
|
||||
srgb = np.asarray(srgb, dtype=float)
|
||||
return np.where(
|
||||
srgb <= 0.04045,
|
||||
srgb / 12.92,
|
||||
((srgb + 0.055) / 1.055) ** 2.4,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def linear_to_srgb(linear: np.ndarray) -> np.ndarray:
|
||||
linear = np.asarray(linear, dtype=float)
|
||||
return np.where(
|
||||
linear <= 0.0031308,
|
||||
12.92 * linear,
|
||||
1.055 * (linear ** (1.0 / 2.4)) - 0.055,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def srgb_to_xyz(cls, rgb: np.ndarray, brightness: float = 1.0) -> np.ndarray:
|
||||
xyz = cls.M_RGB_TO_XYZ @ cls.srgb_to_linear(np.clip(rgb, 0.0, 1.0))
|
||||
if xyz[1] <= 0.0:
|
||||
return np.zeros(3, dtype=float)
|
||||
return xyz * (float(brightness) / float(xyz[1]))
|
||||
|
||||
@staticmethod
|
||||
def xy_to_xyz(x: float, y: float, brightness: float = 1.0) -> np.ndarray:
|
||||
if y <= 0.0:
|
||||
return np.zeros(3, dtype=float)
|
||||
X = brightness * x / y
|
||||
Z = brightness * (1.0 - x - y) / y
|
||||
return np.asarray((X, brightness, Z), dtype=float)
|
||||
|
||||
@staticmethod
|
||||
def xy_to_uv(x: float, y: float) -> tuple[float, float]:
|
||||
denom = (-2.0 * x) + (12.0 * y) + 3.0
|
||||
if abs(denom) < 1e-12:
|
||||
return (0.0, 0.0)
|
||||
return ((4.0 * x) / denom, (9.0 * y) / denom)
|
||||
|
||||
@classmethod
|
||||
def uv_to_xy(cls, u: float, v: float) -> tuple[float, float]:
|
||||
denom = (6.0 * u) - (16.0 * v) + 12.0
|
||||
if abs(denom) < 1e-12:
|
||||
return (cls.D65_X, cls.D65_Y)
|
||||
return ((9.0 * u) / denom, (4.0 * v) / denom)
|
||||
|
||||
@staticmethod
|
||||
def xy_to_k(x: float, y: float) -> float:
|
||||
n = (x - 0.3320) / (0.1858 - y)
|
||||
return 449.0 * (n**3) + 3525.0 * (n**2) + 6823.3 * n + 5520.33
|
||||
|
||||
@staticmethod
|
||||
def k_to_xy(kelvin: float) -> tuple[float, float]:
|
||||
kelvin = _clamp(kelvin, 1667.0, 25000.0)
|
||||
if kelvin <= 4000.0:
|
||||
x = (
|
||||
-0.2661239 * (10**9) / (kelvin**3)
|
||||
- 0.2343580 * (10**6) / (kelvin**2)
|
||||
+ 0.8776956 * (10**3) / kelvin
|
||||
+ 0.179910
|
||||
)
|
||||
else:
|
||||
x = (
|
||||
-3.0258469 * (10**9) / (kelvin**3)
|
||||
+ 2.1070379 * (10**6) / (kelvin**2)
|
||||
+ 0.2226347 * (10**3) / kelvin
|
||||
+ 0.240390
|
||||
)
|
||||
if kelvin <= 2222.0:
|
||||
y = -1.1063814 * (x**3) - 1.34811020 * (x**2) + 2.18555832 * x - 0.20219683
|
||||
elif kelvin <= 4000.0:
|
||||
y = -0.9549476 * (x**3) - 1.37418593 * (x**2) + 2.09137015 * x - 0.16748867
|
||||
else:
|
||||
y = 3.0817580 * (x**3) - 5.87338670 * (x**2) + 3.75112997 * x - 0.37001483
|
||||
return (float(x), float(y))
|
||||
|
||||
@staticmethod
|
||||
def mired_to_kelvin(mireds: float) -> float:
|
||||
if mireds <= 0:
|
||||
return 6500.0
|
||||
return 1_000_000.0 / mireds
|
||||
|
||||
@staticmethod
|
||||
def kelvin_to_mired(kelvin: float) -> float:
|
||||
if kelvin <= 0:
|
||||
return 153.0
|
||||
return 1_000_000.0 / kelvin
|
||||
|
||||
def __repr__(self) -> str:
|
||||
rgb = tuple(round(v, 4) for v in self.rgb)
|
||||
return f"Color(mode={self.mode!r}, brightness={self.brightness:.4f}, rgb={rgb})"
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from ha_deconz_bridge.backends.deconz import DeconzBackend
|
||||
from ha_deconz_bridge.lights import ControllerSpec
|
||||
from ha_deconz_bridge.settings import Settings
|
||||
from ha_deconz_bridge.state import AcceptedControllerState, ControllerCommand, ControllerState
|
||||
from ha_deconz_bridge.translate import controller_command_to_state
|
||||
|
||||
|
||||
class PhysicalController:
|
||||
def __init__(
|
||||
self,
|
||||
spec: ControllerSpec,
|
||||
*,
|
||||
backend,
|
||||
settings: Settings,
|
||||
fallback_mireds: tuple[int, int] | None = None,
|
||||
) -> None:
|
||||
self.spec = spec
|
||||
self.backend = backend
|
||||
self.settings = settings
|
||||
|
||||
resource = backend.fetch_controller_resource(spec.zigbee_id)
|
||||
self._validate_capabilities(resource)
|
||||
self.mireds = self._resolve_mireds(resource, fallback_mireds)
|
||||
self._state = backend.read_controller_state(spec.zigbee_id)
|
||||
self._last_command: ControllerCommand | None = None
|
||||
self._accepted_state: AcceptedControllerState | None = None
|
||||
self._last_sent_at = 0.0
|
||||
|
||||
@property
|
||||
def state(self) -> ControllerState:
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def accepted_state(self) -> AcceptedControllerState | None:
|
||||
return self._accepted_state
|
||||
|
||||
def refresh(self) -> ControllerState:
|
||||
self._state = self.backend.read_controller_state(self.spec.zigbee_id)
|
||||
if self._accepted_state is not None and not self._state_matches_command(
|
||||
self._state,
|
||||
self._accepted_state.command,
|
||||
):
|
||||
self._accepted_state = None
|
||||
return self._state
|
||||
|
||||
def apply(
|
||||
self,
|
||||
command: ControllerCommand,
|
||||
accepted_state: AcceptedControllerState | None = None,
|
||||
*,
|
||||
verify: bool = True,
|
||||
) -> ControllerState:
|
||||
self._last_command = command
|
||||
self._last_sent_at = time.monotonic()
|
||||
self.backend.write_controller_command(self.spec.zigbee_id, command)
|
||||
if not verify:
|
||||
latest_state = (
|
||||
accepted_state.state
|
||||
if accepted_state is not None
|
||||
else controller_command_to_state(command)
|
||||
)
|
||||
self._state = latest_state
|
||||
self._accepted_state = accepted_state
|
||||
return latest_state
|
||||
|
||||
latest_state = self._state
|
||||
for attempt in range(self.settings.deconz_max_retries + 1):
|
||||
latest_state = self.backend.read_controller_state(self.spec.zigbee_id)
|
||||
if self._state_matches_command(
|
||||
latest_state,
|
||||
command,
|
||||
):
|
||||
self._state = latest_state
|
||||
self._accepted_state = accepted_state
|
||||
return latest_state
|
||||
if attempt >= self.settings.deconz_max_retries:
|
||||
break
|
||||
self._last_sent_at = time.monotonic()
|
||||
self.backend.write_controller_command(self.spec.zigbee_id, command)
|
||||
self._state = latest_state
|
||||
self._accepted_state = None
|
||||
return latest_state
|
||||
|
||||
def process_external_state(self, state: ControllerState) -> ControllerState:
|
||||
elapsed = time.monotonic() - self._last_sent_at
|
||||
if (
|
||||
self._last_command is not None
|
||||
and elapsed <= self.settings.deconz_retry_timeout
|
||||
and not self._state_matches_command(
|
||||
state,
|
||||
self._last_command,
|
||||
)
|
||||
):
|
||||
if self._state_matches_command(self._state, self._last_command):
|
||||
return self._state
|
||||
return self.apply(self._last_command, self._accepted_state)
|
||||
self._state = state
|
||||
if self._accepted_state is not None and not self._state_matches_command(
|
||||
state,
|
||||
self._accepted_state.command,
|
||||
):
|
||||
self._accepted_state = None
|
||||
return state
|
||||
|
||||
def _state_matches_command(
|
||||
self,
|
||||
state: ControllerState,
|
||||
command: ControllerCommand,
|
||||
) -> bool:
|
||||
if state.similar_to(
|
||||
command,
|
||||
brightness_tolerance=self.settings.brightness_tolerance,
|
||||
ct_tolerance=self.settings.ct_tolerance,
|
||||
xy_distance_tolerance=self.settings.xy_distance_tolerance,
|
||||
):
|
||||
return True
|
||||
return self._state_matches_endpoint_clamped_ct(state, command)
|
||||
|
||||
def _state_matches_endpoint_clamped_ct(
|
||||
self,
|
||||
state: ControllerState,
|
||||
command: ControllerCommand,
|
||||
) -> bool:
|
||||
if self.mireds is None:
|
||||
return False
|
||||
if command.mode != "color_temp" or state.mode != "color_temp":
|
||||
return False
|
||||
if not state.reachable or state.on != command.on or not command.on:
|
||||
return False
|
||||
if command.color_temp is None or state.color_temp is None:
|
||||
return False
|
||||
if command.brightness is not None:
|
||||
if state.brightness is None:
|
||||
return False
|
||||
if abs(state.brightness - command.brightness) > self.settings.brightness_tolerance:
|
||||
return False
|
||||
|
||||
low, high = tuple(sorted(self.mireds))
|
||||
endpoint_tolerance = self.settings.ct_endpoint_tolerance
|
||||
if command.color_temp <= low + self.settings.ct_tolerance:
|
||||
return low <= state.color_temp <= low + endpoint_tolerance
|
||||
if command.color_temp >= high - self.settings.ct_tolerance:
|
||||
return high - endpoint_tolerance <= state.color_temp <= high
|
||||
return False
|
||||
|
||||
def _validate_capabilities(self, resource: dict[str, object]) -> None:
|
||||
state = resource.get("state", {})
|
||||
if not isinstance(state, dict):
|
||||
raise ValueError(f"Controller {self.spec.zigbee_id!r} returned no valid state.")
|
||||
|
||||
if self.spec.supported_modes != {"onoff"} and "bri" not in state:
|
||||
raise ValueError(
|
||||
f"Controller {self.spec.zigbee_id!r} is missing brightness support in its reported state."
|
||||
)
|
||||
|
||||
def _resolve_mireds(
|
||||
self,
|
||||
resource: dict[str, object],
|
||||
fallback_mireds: tuple[int, int] | None,
|
||||
) -> tuple[int, int] | None:
|
||||
if "color_temp" not in self.spec.supported_modes:
|
||||
return None
|
||||
if self.spec.mireds is not None:
|
||||
return tuple(sorted(self.spec.mireds))
|
||||
if isinstance(self.backend, DeconzBackend):
|
||||
deconz_mireds = self.backend.extract_mireds(resource)
|
||||
if deconz_mireds is not None:
|
||||
return deconz_mireds
|
||||
if self.spec.native_ct_mireds is not None:
|
||||
return self.spec.native_ct_mireds
|
||||
if fallback_mireds is not None:
|
||||
return fallback_mireds
|
||||
raise ValueError(
|
||||
f"Controller {self.spec.zigbee_id!r} requires a valid color temperature range."
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Offline debugger and simulator tools."""
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.patches import Rectangle
|
||||
from matplotlib.widgets import RadioButtons, Slider
|
||||
|
||||
from ha_deconz_bridge.app import load_config_module
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.lights import VirtualLightSpec
|
||||
from ha_deconz_bridge.solver import compile_light_model, solve_light
|
||||
|
||||
|
||||
def _xy_bounds(spec: VirtualLightSpec) -> tuple[float, float, float, float]:
|
||||
xs: list[float] = []
|
||||
ys: list[float] = []
|
||||
for controller in spec.controllers.values():
|
||||
for mode in controller.modes.values():
|
||||
for channel in mode.channels.values():
|
||||
x, y = channel.color.xy
|
||||
xs.append(x)
|
||||
ys.append(y)
|
||||
if not xs:
|
||||
return (0.0, 1.0, 0.0, 1.0)
|
||||
pad = 0.03
|
||||
return (min(xs) - pad, max(xs) + pad, min(ys) - pad, max(ys) + pad)
|
||||
|
||||
|
||||
def run_debugger(spec: VirtualLightSpec) -> None:
|
||||
light_model = compile_light_model(spec)
|
||||
mode_options = [mode for mode in ("rgb", "color_temp") if mode in spec.mode_set]
|
||||
if not mode_options:
|
||||
mode_options = [spec.supported_color_modes[0]]
|
||||
|
||||
state = {
|
||||
"mode": mode_options[0],
|
||||
"brightness": 255,
|
||||
"xy": Color.from_rgb((1.0, 1.0, 1.0)).xy,
|
||||
"color_temp": int(round(sum(spec.exposed_mireds or (153, 500)) / 2)),
|
||||
}
|
||||
|
||||
fig = plt.figure(figsize=(14, 8))
|
||||
ax_xy = fig.add_axes([0.06, 0.22, 0.42, 0.70])
|
||||
ax_channels = fig.add_axes([0.54, 0.58, 0.40, 0.34])
|
||||
ax_text = fig.add_axes([0.54, 0.22, 0.40, 0.30])
|
||||
ax_brightness = fig.add_axes([0.08, 0.08, 0.30, 0.04])
|
||||
ax_ct = fig.add_axes([0.54, 0.08, 0.24, 0.04])
|
||||
ax_mode = fig.add_axes([0.82, 0.04, 0.12, 0.12])
|
||||
|
||||
ax_text.axis("off")
|
||||
mode_selector = RadioButtons(ax_mode, mode_options, active=0)
|
||||
brightness_slider = Slider(ax_brightness, "Brightness", 0, 255, valinit=255, valstep=1)
|
||||
ct_min, ct_max = spec.exposed_mireds or (153, 500)
|
||||
ct_slider = Slider(ax_ct, "Color Temp", ct_min, ct_max, valinit=state["color_temp"], valstep=1)
|
||||
|
||||
bounds = _xy_bounds(spec)
|
||||
dragging = {"active": False}
|
||||
|
||||
def target_color() -> Color:
|
||||
if state["mode"] == "color_temp":
|
||||
return Color.from_color_temp(
|
||||
state["color_temp"],
|
||||
brightness=1.0,
|
||||
temp_range=spec.exposed_mireds or (153, 500),
|
||||
)
|
||||
return Color.from_xy(state["xy"], brightness=1.0)
|
||||
|
||||
def update(_event=None) -> None:
|
||||
start = time.perf_counter()
|
||||
target = target_color()
|
||||
solve = solve_light(light_model, target, brightness=int(state["brightness"]), requested_mode=state["mode"])
|
||||
duration_ms = (time.perf_counter() - start) * 1000.0
|
||||
|
||||
ax_xy.clear()
|
||||
ax_xy.set_xlim(bounds[0], bounds[1])
|
||||
ax_xy.set_ylim(bounds[2], bounds[3])
|
||||
ax_xy.set_title("Target vs achieved xy")
|
||||
ax_xy.set_xlabel("x")
|
||||
ax_xy.set_ylabel("y")
|
||||
ax_xy.set_aspect("equal")
|
||||
for controller in spec.controllers.values():
|
||||
for mode in controller.modes.values():
|
||||
for channel in mode.channels.values():
|
||||
x, y = channel.color.xy
|
||||
ax_xy.scatter([x], [y], s=50, c=[channel.color.rgb], edgecolors="k", linewidths=0.6)
|
||||
tx, ty = target.xy
|
||||
ax_xy.scatter([tx], [ty], marker="*", s=300, c=[target.rgb], edgecolors="k", linewidths=1.0, label="Target")
|
||||
ax, ay = solve.achieved_color.xy
|
||||
ax_xy.scatter([ax], [ay], marker="o", s=180, c=[solve.achieved_color.rgb], edgecolors="k", linewidths=1.0, label="Achieved")
|
||||
ax_xy.legend(loc="lower right")
|
||||
|
||||
ax_channels.clear()
|
||||
keys = list(solve.channel_levels.keys())
|
||||
values = [solve.channel_levels[key] for key in keys]
|
||||
colors = []
|
||||
for key in keys:
|
||||
controller_id, mode_name, role = key.split(".")
|
||||
channel = spec.controllers[controller_id].modes[mode_name].channels[role]
|
||||
colors.append(channel.color.rgb)
|
||||
ax_channels.bar(range(len(keys)), values, color=colors, edgecolor="k", linewidth=0.5)
|
||||
ax_channels.set_title("Per-channel activation")
|
||||
ax_channels.set_xticks(range(len(keys)))
|
||||
ax_channels.set_xticklabels(keys, rotation=45, ha="right", fontsize=8)
|
||||
ax_channels.set_ylim(0.0, 1.05)
|
||||
|
||||
ax_text.clear()
|
||||
ax_text.axis("off")
|
||||
ax_text.add_patch(Rectangle((0.02, 0.58), 0.20, 0.30, facecolor=target.rgb, edgecolor="k"))
|
||||
ax_text.add_patch(Rectangle((0.26, 0.58), 0.20, 0.30, facecolor=solve.achieved_color.rgb, edgecolor="k"))
|
||||
lines = [
|
||||
f"mode: {state['mode']}",
|
||||
f"brightness: {int(state['brightness'])}",
|
||||
f"exact chroma: {solve.exact_chroma_match}",
|
||||
f"clamped: {solve.clamped}",
|
||||
f"chroma error: {solve.chroma_error:.6f}",
|
||||
f"brightness error: {solve.brightness_error:.6f}",
|
||||
f"solver time: {duration_ms:.2f} ms",
|
||||
"",
|
||||
"controller commands:",
|
||||
]
|
||||
for controller_id, command in solve.controller_commands.items():
|
||||
lines.append(
|
||||
f"{controller_id}: on={command.on} mode={command.mode} "
|
||||
f"bri={command.brightness} xy={command.xy} ct={command.color_temp}"
|
||||
)
|
||||
ax_text.text(0.02, 0.50, "\n".join(lines), va="top", family="monospace")
|
||||
|
||||
fig.canvas.draw_idle()
|
||||
|
||||
def on_mode(label: str) -> None:
|
||||
state["mode"] = label
|
||||
update()
|
||||
|
||||
def on_brightness(value: float) -> None:
|
||||
state["brightness"] = int(value)
|
||||
update()
|
||||
|
||||
def on_ct(value: float) -> None:
|
||||
state["color_temp"] = int(value)
|
||||
if state["mode"] == "color_temp":
|
||||
update()
|
||||
|
||||
def on_press(event) -> None:
|
||||
if state["mode"] != "rgb":
|
||||
return
|
||||
if event.inaxes != ax_xy or event.xdata is None or event.ydata is None:
|
||||
return
|
||||
dragging["active"] = True
|
||||
state["xy"] = (float(event.xdata), float(event.ydata))
|
||||
update()
|
||||
|
||||
def on_motion(event) -> None:
|
||||
if not dragging["active"] or state["mode"] != "rgb":
|
||||
return
|
||||
if event.inaxes != ax_xy or event.xdata is None or event.ydata is None:
|
||||
return
|
||||
state["xy"] = (float(event.xdata), float(event.ydata))
|
||||
update()
|
||||
|
||||
def on_release(_event) -> None:
|
||||
dragging["active"] = False
|
||||
|
||||
mode_selector.on_clicked(on_mode)
|
||||
brightness_slider.on_changed(on_brightness)
|
||||
ct_slider.on_changed(on_ct)
|
||||
fig.canvas.mpl_connect("button_press_event", on_press)
|
||||
fig.canvas.mpl_connect("motion_notify_event", on_motion)
|
||||
fig.canvas.mpl_connect("button_release_event", on_release)
|
||||
|
||||
update()
|
||||
plt.show()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("config", help="Python module or .py path exporting LIGHTS")
|
||||
parser.add_argument("light_name", help="Virtual light name to debug")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
module = load_config_module(args.config)
|
||||
lights = getattr(module, "LIGHTS")
|
||||
run_debugger(lights[args.light_name])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import time
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class HaCommandEvent:
|
||||
light_name: str
|
||||
on: bool | None = None
|
||||
brightness: int | None = None
|
||||
rgb: tuple[int, int, int] | None = None
|
||||
color_temp: int | None = None
|
||||
received_at: float = field(default_factory=time.time)
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, light_name: str, payload: dict[str, object]) -> "HaCommandEvent":
|
||||
rgb_payload = payload.get("color")
|
||||
rgb = None
|
||||
if isinstance(rgb_payload, dict):
|
||||
rgb = tuple(int(rgb_payload[channel]) for channel in ("r", "g", "b"))
|
||||
state = payload.get("state")
|
||||
return cls(
|
||||
light_name=light_name,
|
||||
on=(None if state is None else str(state).upper() == "ON"),
|
||||
brightness=(None if payload.get("brightness") is None else int(payload["brightness"])),
|
||||
rgb=rgb,
|
||||
color_temp=(
|
||||
None
|
||||
if payload.get("color_temp") is None
|
||||
else int(payload["color_temp"])
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class DeconzEvent:
|
||||
controller_id: str
|
||||
raw_state: dict[str, object]
|
||||
received_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class DeconzSensorEvent:
|
||||
sensor_id: str
|
||||
raw_state: dict[str, object]
|
||||
received_at: float = field(default_factory=time.time)
|
||||
@@ -0,0 +1,403 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.events import HaCommandEvent
|
||||
from ha_deconz_bridge.groups import (
|
||||
GroupMemberSpec,
|
||||
GroupSpec,
|
||||
derive_group_mireds,
|
||||
derive_group_modes,
|
||||
fixed_brightness_scale,
|
||||
fixed_brightness_threshold,
|
||||
fixed_color_payload,
|
||||
group_member_mode,
|
||||
)
|
||||
from ha_deconz_bridge.light import VirtualLight
|
||||
from ha_deconz_bridge.lights import LightEntitySpec
|
||||
from ha_deconz_bridge.solver import max_brightness
|
||||
from ha_deconz_bridge.state import VirtualLightState
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RememberedGroupState:
|
||||
brightness: int
|
||||
mode: str
|
||||
rgb: tuple[int, int, int] | None = None
|
||||
color_temp: int | None = None
|
||||
|
||||
|
||||
class VirtualGroupLight:
|
||||
def __init__(
|
||||
self,
|
||||
group: GroupSpec,
|
||||
*,
|
||||
members: dict[str, VirtualLight],
|
||||
ha_bridge,
|
||||
brightness_tolerance: int = 2,
|
||||
) -> None:
|
||||
self.group = group
|
||||
self.member_specs = group.member_specs()
|
||||
self.members = members
|
||||
self.brightness_tolerance = brightness_tolerance
|
||||
self.spec = LightEntitySpec(
|
||||
name=group.name,
|
||||
supported_color_modes=derive_group_modes(
|
||||
group,
|
||||
{name: member.spec for name, member in members.items()},
|
||||
),
|
||||
exposed_mireds=derive_group_mireds(
|
||||
group,
|
||||
{name: member.spec for name, member in members.items()},
|
||||
),
|
||||
)
|
||||
self.ha_bridge = ha_bridge
|
||||
self._remembered = RememberedGroupState(
|
||||
brightness=255,
|
||||
mode=self._default_mode(),
|
||||
color_temp=self._default_color_temp(),
|
||||
)
|
||||
self._accepted: RememberedGroupState | None = None
|
||||
self.state = self._recompute_state()
|
||||
|
||||
def publish_discovery(self) -> None:
|
||||
self.ha_bridge.publish_discovery(self.spec)
|
||||
|
||||
def publish_state(self) -> None:
|
||||
self.ha_bridge.publish_state(self.spec, self.state)
|
||||
|
||||
def handle_ha_command(self, event: HaCommandEvent) -> tuple[str, ...]:
|
||||
on, brightness, mode, rgb, color_temp = self._merge_command(event)
|
||||
for member_spec in self.member_specs:
|
||||
member = self.members[member_spec.light_name]
|
||||
member.handle_ha_command(
|
||||
self._member_command(
|
||||
member_spec,
|
||||
member,
|
||||
on=on,
|
||||
brightness=brightness,
|
||||
mode=mode,
|
||||
rgb=rgb,
|
||||
color_temp=color_temp,
|
||||
)
|
||||
)
|
||||
self._accepted = RememberedGroupState(
|
||||
brightness=brightness,
|
||||
mode=mode,
|
||||
rgb=rgb,
|
||||
color_temp=color_temp,
|
||||
)
|
||||
self.state = self._recompute_state(
|
||||
preferred_mode=mode,
|
||||
preferred_brightness=brightness,
|
||||
preferred_rgb=rgb,
|
||||
preferred_color_temp=color_temp,
|
||||
)
|
||||
self.publish_state()
|
||||
return tuple(member.light_name for member in self.member_specs)
|
||||
|
||||
def handle_member_update(self) -> VirtualLightState:
|
||||
self.state = self._recompute_state()
|
||||
self.publish_state()
|
||||
return self.state
|
||||
|
||||
def clear_accepted(self) -> None:
|
||||
self._accepted = None
|
||||
|
||||
def _merge_command(
|
||||
self,
|
||||
event: HaCommandEvent,
|
||||
) -> tuple[bool, int, str, tuple[int, int, int] | None, int | None]:
|
||||
mode = self._remembered.mode
|
||||
rgb = self._remembered.rgb
|
||||
color_temp = self._remembered.color_temp
|
||||
brightness = self._remembered.brightness
|
||||
|
||||
if event.rgb is not None and "rgb" in self.spec.mode_set:
|
||||
mode = "rgb"
|
||||
rgb = event.rgb
|
||||
color_temp = None
|
||||
elif event.color_temp is not None and "color_temp" in self.spec.mode_set:
|
||||
mode = "color_temp"
|
||||
color_temp = event.color_temp
|
||||
rgb = None
|
||||
elif self.spec.supported_color_modes == ("brightness",):
|
||||
mode = "brightness"
|
||||
elif self.spec.supported_color_modes == ("onoff",):
|
||||
mode = "onoff"
|
||||
|
||||
if event.on is False:
|
||||
brightness = 0
|
||||
elif event.brightness is not None and mode != "onoff":
|
||||
brightness = max(0, min(255, int(event.brightness)))
|
||||
elif event.on is True and not self.state.on:
|
||||
brightness = self._remembered.brightness
|
||||
elif mode == "onoff":
|
||||
brightness = 255 if event.on is not False else 0
|
||||
|
||||
on = brightness > 0
|
||||
self._remembered = RememberedGroupState(
|
||||
brightness=brightness if brightness > 0 else self._remembered.brightness,
|
||||
mode=mode,
|
||||
rgb=rgb,
|
||||
color_temp=color_temp,
|
||||
)
|
||||
return (on, brightness, mode, rgb, color_temp)
|
||||
|
||||
def _member_command(
|
||||
self,
|
||||
member_spec: GroupMemberSpec,
|
||||
member: VirtualLight,
|
||||
*,
|
||||
on: bool,
|
||||
brightness: int,
|
||||
mode: str,
|
||||
rgb: tuple[int, int, int] | None,
|
||||
color_temp: int | None,
|
||||
) -> HaCommandEvent:
|
||||
if not on or brightness <= 0:
|
||||
return HaCommandEvent(member.spec.name, on=False)
|
||||
|
||||
if member.spec.supported_color_modes == ("onoff",):
|
||||
return HaCommandEvent(member.spec.name, on=True)
|
||||
|
||||
fixed_brightness = member_spec.fixed_brightness
|
||||
member_brightness = int(fixed_brightness) if fixed_brightness is not None else brightness
|
||||
payload = fixed_color_payload(member_spec, member.spec)
|
||||
if payload is not None:
|
||||
return HaCommandEvent(
|
||||
member.spec.name,
|
||||
on=True,
|
||||
brightness=member_brightness,
|
||||
**payload,
|
||||
)
|
||||
if mode == "rgb" and rgb is not None and "rgb" in member.spec.mode_set:
|
||||
return HaCommandEvent(member.spec.name, on=True, brightness=brightness, rgb=rgb)
|
||||
if mode == "color_temp" and color_temp is not None and "color_temp" in member.spec.mode_set:
|
||||
return HaCommandEvent(member.spec.name, on=True, brightness=brightness, color_temp=color_temp)
|
||||
return HaCommandEvent(member.spec.name, on=True, brightness=brightness)
|
||||
|
||||
def _recompute_state(
|
||||
self,
|
||||
*,
|
||||
preferred_mode: str | None = None,
|
||||
preferred_brightness: int | None = None,
|
||||
preferred_rgb: tuple[int, int, int] | None = None,
|
||||
preferred_color_temp: int | None = None,
|
||||
) -> VirtualLightState:
|
||||
member_pairs = [
|
||||
(member, self.members[member.light_name], self.members[member.light_name].state)
|
||||
for member in self.member_specs
|
||||
]
|
||||
mode = self._choose_mode(preferred_mode)
|
||||
actual_color = self._aggregate_member_color(member_pairs)
|
||||
report_color = self._report_color(
|
||||
mode,
|
||||
source=actual_color,
|
||||
brightness=None,
|
||||
rgb=None,
|
||||
color_temp=None,
|
||||
)
|
||||
derived_brightness = self._derive_brightness(member_pairs, mode, report_color, actual_color)
|
||||
on = derived_brightness > 0
|
||||
accepted = self._accepted
|
||||
if preferred_brightness is not None:
|
||||
brightness = preferred_brightness
|
||||
elif accepted is not None and (
|
||||
self._members_still_accept_group_command()
|
||||
or self._accepted_state_is_close(accepted, derived_brightness, on)
|
||||
):
|
||||
brightness = accepted.brightness
|
||||
mode = self._choose_mode(accepted.mode)
|
||||
preferred_rgb = accepted.rgb
|
||||
preferred_color_temp = accepted.color_temp
|
||||
else:
|
||||
if accepted is not None:
|
||||
self._accepted = None
|
||||
brightness = derived_brightness
|
||||
if not on:
|
||||
brightness = 0
|
||||
|
||||
color = self._report_color(
|
||||
mode,
|
||||
source=actual_color,
|
||||
brightness=brightness,
|
||||
rgb=preferred_rgb,
|
||||
color_temp=preferred_color_temp,
|
||||
)
|
||||
return VirtualLightState(
|
||||
on=on,
|
||||
color=color,
|
||||
ha_mode=mode, # type: ignore[arg-type]
|
||||
brightness=brightness,
|
||||
exact_chroma_match=True,
|
||||
dominance={},
|
||||
)
|
||||
|
||||
def _derive_brightness(
|
||||
self,
|
||||
members: list[tuple[GroupMemberSpec, VirtualLight, VirtualLightState]],
|
||||
mode: str,
|
||||
report_color: Color,
|
||||
actual_color: Color,
|
||||
) -> int:
|
||||
if not members or not actual_color:
|
||||
return 0
|
||||
if mode == "onoff":
|
||||
return 255 if self._onoff_group_is_on(members) else 0
|
||||
reference = self._max_group_brightness(members, mode, report_color)
|
||||
if reference <= 0.0:
|
||||
return 0
|
||||
return int(round(max(0.0, min(1.0, actual_color.brightness / reference)) * 255.0))
|
||||
|
||||
def _aggregate_member_color(
|
||||
self,
|
||||
members: list[tuple[GroupMemberSpec, VirtualLight, VirtualLightState]],
|
||||
) -> Color:
|
||||
total = Color.off()
|
||||
for _member_spec, member, state in members:
|
||||
if state.on:
|
||||
total += member.current_output_color()
|
||||
return total
|
||||
|
||||
def _max_group_brightness(
|
||||
self,
|
||||
members: list[tuple[GroupMemberSpec, VirtualLight, VirtualLightState]],
|
||||
mode: str,
|
||||
target: Color,
|
||||
) -> float:
|
||||
return sum(
|
||||
self._member_max_brightness(member_spec, member, mode, target)
|
||||
for member_spec, member, _state in members
|
||||
)
|
||||
|
||||
def _member_max_brightness(
|
||||
self,
|
||||
member_spec: GroupMemberSpec,
|
||||
member: VirtualLight,
|
||||
group_mode: str,
|
||||
target: Color,
|
||||
) -> float:
|
||||
if member_spec.fixed_brightness is not None:
|
||||
if member_spec.fixed_color is None:
|
||||
return 0.0
|
||||
requested_mode = group_member_mode(member_spec, member.spec, group_mode)
|
||||
reference = max_brightness(
|
||||
member.light_model,
|
||||
member_spec.fixed_color.color,
|
||||
requested_mode=requested_mode,
|
||||
)
|
||||
return reference * fixed_brightness_scale(member_spec)
|
||||
if member_spec.fixed_color is not None:
|
||||
requested_mode = group_member_mode(member_spec, member.spec, group_mode)
|
||||
return max_brightness(
|
||||
member.light_model,
|
||||
member_spec.fixed_color.color,
|
||||
requested_mode=requested_mode,
|
||||
)
|
||||
requested_mode = group_member_mode(member_spec, member.spec, group_mode)
|
||||
return max_brightness(
|
||||
member.light_model,
|
||||
target,
|
||||
requested_mode=requested_mode,
|
||||
)
|
||||
|
||||
def _onoff_group_is_on(
|
||||
self,
|
||||
members: list[tuple[GroupMemberSpec, VirtualLight, VirtualLightState]],
|
||||
) -> bool:
|
||||
for member_spec, member, state in members:
|
||||
if not state.on:
|
||||
continue
|
||||
if member_spec.fixed_brightness is None:
|
||||
return True
|
||||
if member_spec.fixed_color is None:
|
||||
continue
|
||||
requested_mode = group_member_mode(member_spec, member.spec, "onoff")
|
||||
reference = max_brightness(
|
||||
member.light_model,
|
||||
member_spec.fixed_color.color,
|
||||
requested_mode=requested_mode,
|
||||
)
|
||||
threshold = fixed_brightness_threshold(reference, member_spec)
|
||||
if state.color.brightness >= threshold:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _accepted_state_is_close(
|
||||
self,
|
||||
accepted: RememberedGroupState,
|
||||
derived_brightness: int,
|
||||
on: bool,
|
||||
) -> bool:
|
||||
if accepted.brightness <= 0:
|
||||
return not on or derived_brightness <= self.brightness_tolerance
|
||||
if not on:
|
||||
return False
|
||||
return abs(derived_brightness - accepted.brightness) <= self.brightness_tolerance
|
||||
|
||||
def _members_still_accept_group_command(self) -> bool:
|
||||
for member_spec in self.member_specs:
|
||||
member = self.members[member_spec.light_name]
|
||||
if not all(
|
||||
controller.accepted_state is not None
|
||||
for controller in member.controllers.values()
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _choose_mode(self, preferred_mode: str | None) -> str:
|
||||
if preferred_mode in self.spec.mode_set:
|
||||
return preferred_mode
|
||||
if self._remembered.mode in self.spec.mode_set:
|
||||
return self._remembered.mode
|
||||
return self._default_mode()
|
||||
|
||||
def _report_color(
|
||||
self,
|
||||
mode: str,
|
||||
*,
|
||||
source: Color,
|
||||
brightness: int | None,
|
||||
rgb: tuple[int, int, int] | None,
|
||||
color_temp: int | None,
|
||||
) -> Color:
|
||||
output_brightness = source.brightness if brightness is None else max(0.0, min(1.0, brightness / 255.0))
|
||||
if mode == "color_temp":
|
||||
mireds = (
|
||||
color_temp
|
||||
or (source.color_temp if source else None)
|
||||
or self._remembered.color_temp
|
||||
or self._default_color_temp()
|
||||
or 326
|
||||
)
|
||||
return Color.from_color_temp(
|
||||
mireds,
|
||||
brightness=output_brightness,
|
||||
temp_range=self.spec.exposed_mireds or (153, 500),
|
||||
)
|
||||
if mode == "rgb":
|
||||
source_rgb = (
|
||||
tuple(int(round(component * 255.0)) for component in source.rgb)
|
||||
if source
|
||||
else None
|
||||
)
|
||||
return Color.from_rgb(
|
||||
rgb or source_rgb or self._remembered.rgb or (255, 255, 255),
|
||||
brightness=output_brightness,
|
||||
)
|
||||
return Color.from_rgb((255, 255, 255), brightness=output_brightness)
|
||||
|
||||
def _default_mode(self) -> str:
|
||||
if "color_temp" in self.spec.mode_set:
|
||||
return "color_temp"
|
||||
if "rgb" in self.spec.mode_set:
|
||||
return "rgb"
|
||||
return self.spec.supported_color_modes[0]
|
||||
|
||||
def _default_color_temp(self) -> int | None:
|
||||
if self.spec.exposed_mireds is None:
|
||||
return None
|
||||
low, high = self.spec.exposed_mireds
|
||||
return int(round((low + high) / 2))
|
||||
@@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from numbers import Real
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.lights import SupportedColorMode, VirtualLightSpec
|
||||
|
||||
GroupBalanceMode = Literal["passthrough", "relative_member"]
|
||||
FixedColorKind = Literal["color_temp", "rgb", "xy"]
|
||||
FixedColorInput = Real | Sequence[Real]
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class FixedColorSpec:
|
||||
kind: FixedColorKind
|
||||
color: Color
|
||||
color_temp: int | None = None
|
||||
|
||||
@property
|
||||
def rgb255(self) -> tuple[int, int, int]:
|
||||
return tuple(int(round(component * 255.0)) for component in self.color.rgb) # type: ignore[return-value]
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class GroupMemberSpec:
|
||||
light_name: str
|
||||
fixed_color: FixedColorInput | FixedColorSpec | None = None
|
||||
fixed_brightness: float | int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.fixed_color is not None and not isinstance(self.fixed_color, FixedColorSpec):
|
||||
object.__setattr__(self, "fixed_color", _normalize_fixed_color(self.fixed_color))
|
||||
if self.fixed_brightness is not None:
|
||||
if self.fixed_color is None:
|
||||
raise ValueError("fixed_brightness requires fixed_color.")
|
||||
object.__setattr__(
|
||||
self,
|
||||
"fixed_brightness",
|
||||
_normalize_fixed_brightness(self.fixed_brightness),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_constrained(self) -> bool:
|
||||
return self.fixed_color is not None
|
||||
|
||||
@property
|
||||
def is_fixed_brightness(self) -> bool:
|
||||
return self.fixed_brightness is not None
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class GroupSpec:
|
||||
name: str
|
||||
members: tuple[GroupMemberSpec | str, ...]
|
||||
supported_color_modes: tuple[SupportedColorMode, ...] | None = None
|
||||
balance: GroupBalanceMode = "passthrough"
|
||||
|
||||
def member_specs(self) -> tuple[GroupMemberSpec, ...]:
|
||||
return tuple(
|
||||
member if isinstance(member, GroupMemberSpec) else GroupMemberSpec(member)
|
||||
for member in self.members
|
||||
)
|
||||
|
||||
def member_names(self) -> tuple[str, ...]:
|
||||
return tuple(member.light_name for member in self.member_specs())
|
||||
|
||||
|
||||
def normalize_group_specs(
|
||||
groups: dict[str, GroupSpec | tuple[str, ...]],
|
||||
) -> dict[str, GroupSpec]:
|
||||
normalized: dict[str, GroupSpec] = {}
|
||||
for name, group in groups.items():
|
||||
if isinstance(group, GroupSpec):
|
||||
if group.name != name:
|
||||
raise ValueError(f"Group dictionary key {name!r} does not match GroupSpec name {group.name!r}.")
|
||||
normalized[name] = group
|
||||
else:
|
||||
normalized[name] = GroupSpec(name=name, members=group)
|
||||
return normalized
|
||||
|
||||
|
||||
def derive_group_modes(
|
||||
group: GroupSpec,
|
||||
lights: dict[str, VirtualLightSpec],
|
||||
) -> tuple[SupportedColorMode, ...]:
|
||||
if group.supported_color_modes is not None:
|
||||
return group.supported_color_modes
|
||||
|
||||
modes: set[SupportedColorMode] = set()
|
||||
for member in group.member_specs():
|
||||
modes.update(_effective_member_modes(member, lights[member.light_name]))
|
||||
if not modes:
|
||||
return ("onoff",)
|
||||
|
||||
color_modes = tuple(mode for mode in ("rgb", "color_temp") if mode in modes)
|
||||
if color_modes:
|
||||
return color_modes # type: ignore[return-value]
|
||||
if "brightness" in modes:
|
||||
return ("brightness",)
|
||||
return ("onoff",)
|
||||
|
||||
|
||||
def derive_group_mireds(
|
||||
group: GroupSpec,
|
||||
lights: dict[str, VirtualLightSpec],
|
||||
) -> tuple[int, int] | None:
|
||||
if "color_temp" not in derive_group_modes(group, lights):
|
||||
return None
|
||||
|
||||
ranges = [
|
||||
lights[member.light_name].exposed_mireds
|
||||
for member in group.member_specs()
|
||||
if not member.is_constrained and lights[member.light_name].exposed_mireds is not None
|
||||
]
|
||||
fixed_values = [
|
||||
member.fixed_color.color_temp
|
||||
for member in group.member_specs()
|
||||
if member.fixed_color is not None and member.fixed_color.color_temp is not None
|
||||
]
|
||||
values = [value for value in fixed_values if value is not None]
|
||||
for mireds in ranges:
|
||||
if mireds is None:
|
||||
continue
|
||||
values.extend(mireds)
|
||||
if not values:
|
||||
return None
|
||||
return (min(values), max(values))
|
||||
|
||||
|
||||
def _effective_member_modes(
|
||||
member: GroupMemberSpec,
|
||||
spec: VirtualLightSpec,
|
||||
) -> set[SupportedColorMode]:
|
||||
if member.is_fixed_brightness:
|
||||
return {"onoff"}
|
||||
if member.is_constrained:
|
||||
return {"brightness"}
|
||||
return spec.mode_set
|
||||
|
||||
|
||||
def fixed_color_mode(member: GroupMemberSpec, spec: VirtualLightSpec) -> SupportedColorMode:
|
||||
if member.fixed_color is None:
|
||||
return spec.supported_color_modes[0]
|
||||
if member.fixed_color.kind == "color_temp" and "color_temp" in spec.mode_set:
|
||||
return "color_temp"
|
||||
if member.fixed_color.kind in {"rgb", "xy"} and "rgb" in spec.mode_set:
|
||||
return "rgb"
|
||||
if "color_temp" in spec.mode_set:
|
||||
return "color_temp"
|
||||
if "rgb" in spec.mode_set:
|
||||
return "rgb"
|
||||
if "brightness" in spec.mode_set:
|
||||
return "brightness"
|
||||
return "onoff"
|
||||
|
||||
|
||||
def group_member_mode(
|
||||
member: GroupMemberSpec,
|
||||
spec: VirtualLightSpec,
|
||||
group_mode: SupportedColorMode,
|
||||
) -> SupportedColorMode:
|
||||
if member.fixed_color is not None:
|
||||
return fixed_color_mode(member, spec)
|
||||
if group_mode in spec.mode_set:
|
||||
return group_mode
|
||||
if spec.supported_color_modes == ("onoff",):
|
||||
return "onoff"
|
||||
return spec.supported_color_modes[0]
|
||||
|
||||
|
||||
def fixed_brightness_scale(member: GroupMemberSpec) -> float:
|
||||
if member.fixed_brightness is None:
|
||||
return 1.0
|
||||
return int(member.fixed_brightness) / 255.0
|
||||
|
||||
|
||||
def fixed_brightness_threshold(
|
||||
reference_brightness: float,
|
||||
member: GroupMemberSpec,
|
||||
*,
|
||||
ratio: float = 0.9,
|
||||
) -> float:
|
||||
return reference_brightness * fixed_brightness_scale(member) * ratio
|
||||
|
||||
|
||||
def fixed_color_payload(
|
||||
member: GroupMemberSpec,
|
||||
spec: VirtualLightSpec,
|
||||
) -> dict[str, object] | None:
|
||||
fixed_color = member.fixed_color
|
||||
if fixed_color is None:
|
||||
return None
|
||||
mode = fixed_color_mode(member, spec)
|
||||
if mode == "color_temp":
|
||||
return {"color_temp": fixed_color.color_temp or fixed_color.color.color_temp}
|
||||
if mode == "rgb":
|
||||
return {"rgb": fixed_color.rgb255}
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_fixed_color(value: FixedColorInput) -> FixedColorSpec:
|
||||
if isinstance(value, Real):
|
||||
color_temp = _color_temp_input_to_mireds(float(value))
|
||||
return FixedColorSpec(
|
||||
kind="color_temp",
|
||||
color=Color.from_color_temp(color_temp, brightness=1.0),
|
||||
color_temp=color_temp,
|
||||
)
|
||||
if not isinstance(value, Sequence):
|
||||
raise ValueError("fixed_color must be a number, RGB triple, or xy pair.")
|
||||
values = tuple(float(component) for component in value)
|
||||
if len(values) == 2:
|
||||
return FixedColorSpec(
|
||||
kind="xy",
|
||||
color=Color.from_xy((values[0], values[1]), brightness=1.0),
|
||||
)
|
||||
if len(values) == 3:
|
||||
return FixedColorSpec(
|
||||
kind="rgb",
|
||||
color=Color.from_rgb(values, brightness=1.0),
|
||||
)
|
||||
raise ValueError("fixed_color sequence must contain two xy values or three RGB values.")
|
||||
|
||||
|
||||
def _normalize_fixed_brightness(value: float | int) -> int:
|
||||
brightness = float(value)
|
||||
if 0.0 <= brightness <= 1.0:
|
||||
brightness *= 255.0
|
||||
return max(0, min(255, int(round(brightness))))
|
||||
|
||||
|
||||
def _color_temp_input_to_mireds(value: float) -> int:
|
||||
if value <= 0.0:
|
||||
raise ValueError("fixed_color color temperature must be positive.")
|
||||
if value >= 1000.0:
|
||||
return int(round(1_000_000.0 / value))
|
||||
return int(round(value))
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
|
||||
from ha_deconz_bridge.events import HaCommandEvent
|
||||
from ha_deconz_bridge.lights import LightEntitySpec, VirtualLightSpec
|
||||
from ha_deconz_bridge.settings import Settings
|
||||
from ha_deconz_bridge.state import VirtualLightState
|
||||
from ha_deconz_bridge.translate import ha_payload_from_state
|
||||
|
||||
|
||||
class HomeAssistantBridge:
|
||||
def __init__(self, settings: Settings, *, mqtt_client=None) -> None:
|
||||
if mqtt_client is None:
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
mqtt_client = mqtt.Client()
|
||||
self.settings = settings
|
||||
self._mqtt = mqtt_client
|
||||
if settings.mqtt_user is not None:
|
||||
self._mqtt.username_pw_set(settings.mqtt_user, settings.mqtt_password)
|
||||
self._on_command = None
|
||||
self._connected = threading.Event()
|
||||
self._connect_result: int | None = None
|
||||
self._light_name_by_object_id: dict[str, str] = {}
|
||||
self._command_topics: set[str] = set()
|
||||
|
||||
def start(self, *, on_command) -> None:
|
||||
self._on_command = on_command
|
||||
self._connected.clear()
|
||||
self._connect_result = None
|
||||
self._mqtt.on_connect = self._handle_connect
|
||||
self._mqtt.on_message = self._handle_message
|
||||
self._mqtt.connect(self.settings.mqtt_host, self.settings.mqtt_port, 60)
|
||||
self._mqtt.loop_start()
|
||||
if not self._connected.wait(timeout=5.0):
|
||||
raise RuntimeError("MQTT connection did not become ready.")
|
||||
if self._connect_result not in {0, None}:
|
||||
raise RuntimeError(f"MQTT connection failed with result {self._connect_result}.")
|
||||
|
||||
def stop(self) -> None:
|
||||
try:
|
||||
self._mqtt.loop_stop()
|
||||
finally:
|
||||
self._mqtt.disconnect()
|
||||
|
||||
def publish_discovery(self, spec: VirtualLightSpec | LightEntitySpec) -> None:
|
||||
object_id = self.object_id(spec.name)
|
||||
self._light_name_by_object_id[object_id] = spec.name
|
||||
command_topic = self.command_topic(spec.name)
|
||||
self._command_topics.add(command_topic)
|
||||
payload: dict[str, object] = {
|
||||
"name": spec.name,
|
||||
"unique_id": object_id,
|
||||
"device": {
|
||||
"identifiers": [f"ha-deconz-bridge:{object_id}"],
|
||||
"name": f"HA deCONZ Bridge: {spec.name}",
|
||||
"manufacturer": "ha-deconz-bridge",
|
||||
"model": "Virtual Light",
|
||||
},
|
||||
"schema": "json",
|
||||
"command_topic": command_topic,
|
||||
"state_topic": self.state_topic(spec.name),
|
||||
"supported_color_modes": list(spec.supported_color_modes),
|
||||
}
|
||||
if spec.exposed_mireds is not None:
|
||||
payload["min_mireds"] = spec.exposed_mireds[0]
|
||||
payload["max_mireds"] = spec.exposed_mireds[1]
|
||||
topic = f"{self.settings.mqtt_discovery_prefix}/light/{object_id}/config"
|
||||
self._mqtt.publish(topic, json.dumps(payload), retain=True)
|
||||
self._mqtt.subscribe(command_topic)
|
||||
logging.info("Published discovery for %s on %s", spec.name, topic)
|
||||
|
||||
def publish_state(self, spec: VirtualLightSpec | LightEntitySpec, state: VirtualLightState) -> None:
|
||||
payload = ha_payload_from_state(state)
|
||||
self._mqtt.publish(self.state_topic(spec.name), json.dumps(payload), retain=True)
|
||||
logging.info("Published state for %s: %s", spec.name, payload)
|
||||
|
||||
def command_topic(self, light_name: str) -> str:
|
||||
return f"{self.settings.mqtt_topic_prefix}/light/{self.object_id(light_name)}/set"
|
||||
|
||||
def state_topic(self, light_name: str) -> str:
|
||||
return f"{self.settings.mqtt_topic_prefix}/light/{self.object_id(light_name)}/state"
|
||||
|
||||
@staticmethod
|
||||
def object_id(light_name: str) -> str:
|
||||
sanitized = re.sub(r"[^a-zA-Z0-9_-]+", "_", light_name.strip())
|
||||
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
|
||||
if not sanitized:
|
||||
raise ValueError("Light name produces an empty MQTT object_id.")
|
||||
return sanitized
|
||||
|
||||
def _handle_connect(self, client, userdata, flags, reason_code, properties=None) -> None:
|
||||
self._connect_result = int(reason_code)
|
||||
if self._connect_result == 0:
|
||||
logging.info("Connected to MQTT broker at %s:%s", self.settings.mqtt_host, self.settings.mqtt_port)
|
||||
for topic in sorted(self._command_topics):
|
||||
self._mqtt.subscribe(topic)
|
||||
logging.info("Subscribed to %s", topic)
|
||||
else:
|
||||
logging.error("MQTT connect failed with result %s", self._connect_result)
|
||||
self._connected.set()
|
||||
|
||||
def _handle_message(self, client, userdata, message) -> None:
|
||||
if self._on_command is None:
|
||||
return
|
||||
object_id = message.topic.split("/")[-2]
|
||||
light_name = self._light_name_by_object_id.get(object_id, object_id)
|
||||
payload = json.loads(message.payload.decode())
|
||||
logging.info("Received MQTT command for %s on %s: %s", light_name, message.topic, payload)
|
||||
self._on_command(HaCommandEvent.from_payload(light_name, payload))
|
||||
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.events import HaCommandEvent
|
||||
from ha_deconz_bridge.lights import VirtualLightSpec
|
||||
from ha_deconz_bridge.solver import CompiledLightModel, max_brightness, solve_light
|
||||
from ha_deconz_bridge.state import ControllerState, VirtualLightState
|
||||
from ha_deconz_bridge.translate import (
|
||||
accepted_controller_state_from_command,
|
||||
choose_ha_mode,
|
||||
controller_state_to_actual_color,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RememberedState:
|
||||
color: Color
|
||||
brightness: int
|
||||
mode: str
|
||||
|
||||
|
||||
class VirtualLight:
|
||||
def __init__(
|
||||
self,
|
||||
spec: VirtualLightSpec,
|
||||
*,
|
||||
controllers: dict[str, object],
|
||||
light_model: CompiledLightModel,
|
||||
ha_bridge,
|
||||
) -> None:
|
||||
self.spec = spec
|
||||
self.controllers = controllers
|
||||
self.light_model = light_model
|
||||
self.ha_bridge = ha_bridge
|
||||
self._remembered = RememberedState(
|
||||
color=self._default_color(),
|
||||
brightness=255,
|
||||
mode=self._default_mode(),
|
||||
)
|
||||
self.state = self._recompute_state()
|
||||
|
||||
def publish_discovery(self) -> None:
|
||||
self.ha_bridge.publish_discovery(self.spec)
|
||||
|
||||
def publish_state(self) -> None:
|
||||
self.ha_bridge.publish_state(self.spec, self.state)
|
||||
|
||||
def handle_ha_command(self, event: HaCommandEvent) -> VirtualLightState:
|
||||
target_color, brightness, requested_mode = self._merge_command(event)
|
||||
solve_result = solve_light(
|
||||
self.light_model,
|
||||
target_color,
|
||||
brightness=brightness,
|
||||
requested_mode=requested_mode,
|
||||
)
|
||||
final_states: dict[str, ControllerState] = {}
|
||||
for controller_id, command in solve_result.controller_commands.items():
|
||||
controller = self.controllers[controller_id]
|
||||
accepted_state = accepted_controller_state_from_command(
|
||||
controller.spec,
|
||||
command,
|
||||
solve_result.channel_levels,
|
||||
controller_id=controller_id,
|
||||
requested_mode=requested_mode,
|
||||
requested_brightness=brightness,
|
||||
requested_color=target_color,
|
||||
mireds_range=controller.mireds,
|
||||
)
|
||||
final_states[controller_id] = controller.apply(
|
||||
command,
|
||||
accepted_state=accepted_state,
|
||||
verify=False,
|
||||
)
|
||||
accepted_color = solve_result.achieved_color
|
||||
if requested_mode == "color_temp":
|
||||
accepted_color = target_color.with_brightness(solve_result.achieved_color.brightness)
|
||||
self.state = self._recompute_state(
|
||||
final_states,
|
||||
preferred_mode=requested_mode,
|
||||
preferred_brightness=brightness,
|
||||
accepted_color=accepted_color,
|
||||
)
|
||||
self.publish_state()
|
||||
return self.state
|
||||
|
||||
def handle_controller_event(
|
||||
self,
|
||||
controller_id: str,
|
||||
state: ControllerState,
|
||||
) -> VirtualLightState:
|
||||
self.controllers[controller_id].process_external_state(state)
|
||||
return self.handle_controller_update()
|
||||
|
||||
def handle_controller_update(self) -> VirtualLightState:
|
||||
self.state = self._recompute_state()
|
||||
self.publish_state()
|
||||
return self.state
|
||||
|
||||
def current_output_color(self) -> Color:
|
||||
total = Color.off()
|
||||
for controller_id, controller in self.controllers.items():
|
||||
total += self._controller_output_color(controller_id, controller)
|
||||
return total
|
||||
|
||||
def _merge_command(self, event: HaCommandEvent) -> tuple[Color, int, str]:
|
||||
base_color = self._remembered.color
|
||||
base_mode = self._remembered.mode
|
||||
brightness = self._remembered.brightness
|
||||
|
||||
if event.rgb is not None:
|
||||
target = Color.from_rgb(event.rgb, brightness=1.0)
|
||||
requested_mode = "rgb"
|
||||
elif event.color_temp is not None:
|
||||
temp_range = self.spec.exposed_mireds or (153, 500)
|
||||
target = Color.from_color_temp(
|
||||
event.color_temp,
|
||||
brightness=1.0,
|
||||
temp_range=temp_range,
|
||||
)
|
||||
requested_mode = "color_temp"
|
||||
elif self.spec.supported_color_modes == ("brightness",):
|
||||
target = base_color
|
||||
requested_mode = "brightness"
|
||||
elif self.spec.supported_color_modes == ("onoff",):
|
||||
target = base_color
|
||||
requested_mode = "onoff"
|
||||
else:
|
||||
requested_mode = base_mode
|
||||
if requested_mode == "color_temp":
|
||||
temp_range = self.spec.exposed_mireds or (153, 500)
|
||||
target = Color.from_color_temp(
|
||||
base_color.color_temp,
|
||||
brightness=1.0,
|
||||
temp_range=temp_range,
|
||||
)
|
||||
else:
|
||||
target = Color.from_rgb(base_color.rgb, brightness=1.0)
|
||||
|
||||
if event.on is False:
|
||||
brightness = 0
|
||||
elif requested_mode == "onoff":
|
||||
brightness = 255 if event.on is not False else 0
|
||||
elif event.brightness is not None:
|
||||
brightness = max(0, min(255, int(event.brightness)))
|
||||
elif event.on is True and not self.state.on:
|
||||
brightness = self._remembered.brightness
|
||||
|
||||
return (target, brightness, requested_mode)
|
||||
|
||||
def _recompute_state(
|
||||
self,
|
||||
override_states: dict[str, ControllerState] | None = None,
|
||||
*,
|
||||
preferred_mode: str | None = None,
|
||||
preferred_brightness: int | None = None,
|
||||
accepted_color: Color | None = None,
|
||||
) -> VirtualLightState:
|
||||
total = Color.off()
|
||||
dominance: dict[str, float] = {
|
||||
"onoff": 0.0,
|
||||
"brightness": 0.0,
|
||||
"rgb": 0.0,
|
||||
"color_temp": 0.0,
|
||||
}
|
||||
for controller_id, controller in self.controllers.items():
|
||||
color, family = self._controller_output_color_and_family(
|
||||
controller_id,
|
||||
controller,
|
||||
override_states=override_states,
|
||||
)
|
||||
total += color
|
||||
dominance[family] += color.brightness
|
||||
|
||||
on = total.brightness > 0.0
|
||||
ha_mode = choose_ha_mode(self.spec, dominance, preferred_mode=preferred_mode)
|
||||
if on:
|
||||
report_source = accepted_color if accepted_color is not None and preferred_mode == ha_mode else total
|
||||
report_color = self._report_color(report_source, ha_mode)
|
||||
if preferred_brightness is not None and preferred_mode == ha_mode:
|
||||
brightness = preferred_brightness
|
||||
else:
|
||||
brightness = self._derive_brightness(ha_mode, total, report_color)
|
||||
self._remembered = RememberedState(
|
||||
color=report_color,
|
||||
brightness=brightness or 255,
|
||||
mode=ha_mode,
|
||||
)
|
||||
else:
|
||||
report_color = self._remembered.color
|
||||
brightness = 0
|
||||
ha_mode = self._remembered.mode
|
||||
|
||||
return VirtualLightState(
|
||||
on=on,
|
||||
color=report_color,
|
||||
ha_mode=ha_mode,
|
||||
brightness=brightness,
|
||||
exact_chroma_match=True,
|
||||
dominance={mode: value for mode, value in dominance.items() if value > 0.0},
|
||||
)
|
||||
|
||||
def _report_color(self, total: Color, ha_mode: str) -> Color:
|
||||
if ha_mode == "color_temp":
|
||||
return Color.from_color_temp(
|
||||
total.color_temp,
|
||||
brightness=total.brightness,
|
||||
temp_range=self.spec.exposed_mireds or (153, 500),
|
||||
)
|
||||
if ha_mode == "rgb":
|
||||
return Color.from_rgb(total.rgb, brightness=total.brightness)
|
||||
if ha_mode == "brightness":
|
||||
return Color.from_rgb(total.rgb if total else (1.0, 1.0, 1.0), brightness=total.brightness)
|
||||
return total
|
||||
|
||||
def _derive_brightness(
|
||||
self,
|
||||
ha_mode: str,
|
||||
total: Color,
|
||||
report_color: Color,
|
||||
) -> int:
|
||||
accepted_brightness = [
|
||||
controller.accepted_state.requested_brightness
|
||||
for controller in self.controllers.values()
|
||||
if controller.accepted_state is not None
|
||||
and controller.accepted_state.requested_mode == ha_mode
|
||||
]
|
||||
if accepted_brightness:
|
||||
return max(accepted_brightness)
|
||||
|
||||
reference = max_brightness(self.light_model, report_color, requested_mode=ha_mode)
|
||||
if reference <= 0.0:
|
||||
return 0
|
||||
return int(round(min(1.0, total.brightness / reference) * 255.0))
|
||||
|
||||
def _controller_output_color(self, controller_id: str, controller) -> Color:
|
||||
color, _family = self._controller_output_color_and_family(controller_id, controller)
|
||||
return color
|
||||
|
||||
def _controller_output_color_and_family(
|
||||
self,
|
||||
controller_id: str,
|
||||
controller,
|
||||
*,
|
||||
override_states: dict[str, ControllerState] | None = None,
|
||||
) -> tuple[Color, str]:
|
||||
state = override_states.get(controller_id) if override_states else controller.state
|
||||
if state is None:
|
||||
state = controller.state
|
||||
if controller.accepted_state is not None:
|
||||
return (controller.accepted_state.accepted_color, controller.accepted_state.requested_mode)
|
||||
return controller_state_to_actual_color(
|
||||
controller.spec,
|
||||
state,
|
||||
mireds_range=controller.mireds,
|
||||
)
|
||||
|
||||
def _default_color(self) -> Color:
|
||||
if "color_temp" in self.spec.mode_set and self.spec.exposed_mireds is not None:
|
||||
low, high = self.spec.exposed_mireds
|
||||
return Color.from_color_temp((low + high) / 2.0, brightness=1.0, temp_range=self.spec.exposed_mireds)
|
||||
return Color.from_rgb((1.0, 1.0, 1.0), brightness=1.0)
|
||||
|
||||
def _default_mode(self) -> str:
|
||||
if "color_temp" in self.spec.mode_set:
|
||||
return "color_temp"
|
||||
if "rgb" in self.spec.mode_set:
|
||||
return "rgb"
|
||||
return self.spec.supported_color_modes[0]
|
||||
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from ha_deconz_bridge.color import Color
|
||||
|
||||
SupportedColorMode = Literal["onoff", "brightness", "rgb", "color_temp"]
|
||||
ControllerModeName = Literal["onoff", "brightness", "rgb", "color_temp"]
|
||||
ChannelSemanticLabel = Literal["rgb", "color_temp", "boost"]
|
||||
|
||||
_MODE_ROLES: dict[ControllerModeName, set[str]] = {
|
||||
"onoff": {"w"},
|
||||
"brightness": {"w"},
|
||||
"rgb": {"r", "g", "b"},
|
||||
"color_temp": {"ww", "cw"},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ChannelSpec:
|
||||
role: str
|
||||
color: Color
|
||||
semantic_label: ChannelSemanticLabel | None = None
|
||||
|
||||
def validate(self, mode_name: ControllerModeName) -> None:
|
||||
if self.role not in _MODE_ROLES[mode_name]:
|
||||
raise ValueError(f"Invalid channel role {self.role!r} for mode {mode_name!r}.")
|
||||
if self.semantic_label not in {None, "rgb", "color_temp", "boost"}:
|
||||
raise ValueError(f"Invalid semantic label {self.semantic_label!r}.")
|
||||
|
||||
def effective_label(self, mode_name: ControllerModeName) -> ChannelSemanticLabel | None:
|
||||
if self.semantic_label is not None:
|
||||
return self.semantic_label
|
||||
if mode_name in {"rgb", "color_temp"}:
|
||||
return mode_name
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ControllerModeSpec:
|
||||
name: ControllerModeName
|
||||
channels: dict[str, ChannelSpec]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.channels:
|
||||
raise ValueError(f"Controller mode {self.name!r} must define at least one channel.")
|
||||
seen_roles: set[str] = set()
|
||||
for key, channel in self.channels.items():
|
||||
if key != channel.role:
|
||||
raise ValueError("Channel dictionary keys must match ChannelSpec.role.")
|
||||
channel.validate(self.name)
|
||||
if channel.role in seen_roles:
|
||||
raise ValueError(f"Duplicate channel role {channel.role!r} in mode {self.name!r}.")
|
||||
seen_roles.add(channel.role)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ControllerSpec:
|
||||
zigbee_id: str
|
||||
name: str
|
||||
modes: dict[str, ControllerModeSpec]
|
||||
mireds: tuple[int, int] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.zigbee_id:
|
||||
raise ValueError("Controller zigbee_id must not be empty.")
|
||||
if not self.modes:
|
||||
raise ValueError(f"Controller {self.zigbee_id!r} must define at least one mode.")
|
||||
if "onoff" in self.modes and len(self.modes) > 1:
|
||||
raise ValueError(f"Controller {self.zigbee_id!r} cannot mix onoff with other modes.")
|
||||
for key, mode in self.modes.items():
|
||||
if key != mode.name:
|
||||
raise ValueError("Controller mode keys must match ControllerModeSpec.name.")
|
||||
|
||||
@property
|
||||
def supported_modes(self) -> set[ControllerModeName]:
|
||||
return {mode.name for mode in self.modes.values()}
|
||||
|
||||
@property
|
||||
def native_ct_mireds(self) -> tuple[int, int] | None:
|
||||
if self.mireds is not None:
|
||||
return tuple(sorted(self.mireds))
|
||||
mode = self.modes.get("color_temp")
|
||||
if mode is None:
|
||||
return None
|
||||
values = [channel.color.color_temp for channel in mode.channels.values()]
|
||||
if not values:
|
||||
return None
|
||||
return (min(values), max(values))
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class VirtualLightSpec:
|
||||
name: str
|
||||
supported_color_modes: tuple[SupportedColorMode, ...]
|
||||
controllers: dict[str, ControllerSpec]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
allowed = {
|
||||
("onoff",),
|
||||
("brightness",),
|
||||
("rgb",),
|
||||
("color_temp",),
|
||||
("rgb", "color_temp"),
|
||||
("color_temp", "rgb"),
|
||||
}
|
||||
if self.supported_color_modes not in allowed:
|
||||
raise ValueError(
|
||||
f"Unsupported virtual supported_color_modes {self.supported_color_modes!r}."
|
||||
)
|
||||
if not self.controllers:
|
||||
raise ValueError(f"Virtual light {self.name!r} must define at least one controller.")
|
||||
if "color_temp" in self.supported_color_modes and self.exposed_mireds is None:
|
||||
raise ValueError(
|
||||
f"Virtual light {self.name!r} exposes color_temp but has no native CT emitters."
|
||||
)
|
||||
for key, controller in self.controllers.items():
|
||||
if key != controller.zigbee_id:
|
||||
raise ValueError("Controller dictionary keys must match ControllerSpec.zigbee_id.")
|
||||
|
||||
@property
|
||||
def exposed_mireds(self) -> tuple[int, int] | None:
|
||||
ranges = [
|
||||
controller.native_ct_mireds
|
||||
for controller in self.controllers.values()
|
||||
if controller.native_ct_mireds is not None
|
||||
]
|
||||
if not ranges:
|
||||
return None
|
||||
lows = [value[0] for value in ranges]
|
||||
highs = [value[1] for value in ranges]
|
||||
return (min(lows), max(highs))
|
||||
|
||||
@property
|
||||
def mode_set(self) -> set[SupportedColorMode]:
|
||||
return set(self.supported_color_modes)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class LightEntitySpec:
|
||||
name: str
|
||||
supported_color_modes: tuple[SupportedColorMode, ...]
|
||||
exposed_mireds: tuple[int, int] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
allowed = {
|
||||
("onoff",),
|
||||
("brightness",),
|
||||
("rgb",),
|
||||
("color_temp",),
|
||||
("rgb", "color_temp"),
|
||||
("color_temp", "rgb"),
|
||||
}
|
||||
if self.supported_color_modes not in allowed:
|
||||
raise ValueError(
|
||||
f"Unsupported entity supported_color_modes {self.supported_color_modes!r}."
|
||||
)
|
||||
if "color_temp" in self.supported_color_modes and self.exposed_mireds is None:
|
||||
raise ValueError(
|
||||
f"Entity {self.name!r} exposes color_temp but has no color temperature range."
|
||||
)
|
||||
|
||||
@property
|
||||
def mode_set(self) -> set[SupportedColorMode]:
|
||||
return set(self.supported_color_modes)
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from threading import Condition
|
||||
|
||||
from ha_deconz_bridge.events import DeconzEvent, DeconzSensorEvent, HaCommandEvent
|
||||
|
||||
|
||||
class EventBroker:
|
||||
def __init__(self) -> None:
|
||||
self._condition = Condition()
|
||||
self._ha_pending: dict[str, HaCommandEvent] = {}
|
||||
self._ha_order: deque[str] = deque()
|
||||
self._deconz_pending: dict[str, DeconzEvent] = {}
|
||||
self._deconz_order: deque[str] = deque()
|
||||
self._sensor_queue: deque[DeconzSensorEvent] = deque()
|
||||
self._closed = False
|
||||
|
||||
def put_ha(self, event: HaCommandEvent) -> None:
|
||||
with self._condition:
|
||||
if event.light_name in self._ha_pending:
|
||||
event = _merge_ha_command(self._ha_pending[event.light_name], event)
|
||||
self._ha_order.remove(event.light_name)
|
||||
self._ha_order.append(event.light_name)
|
||||
self._ha_pending[event.light_name] = event
|
||||
self._condition.notify()
|
||||
|
||||
def put_deconz(self, event: DeconzEvent) -> None:
|
||||
with self._condition:
|
||||
if event.controller_id in self._deconz_pending:
|
||||
self._deconz_order.remove(event.controller_id)
|
||||
self._deconz_order.append(event.controller_id)
|
||||
self._deconz_pending[event.controller_id] = event
|
||||
self._condition.notify()
|
||||
|
||||
def put_sensor(self, event: DeconzSensorEvent) -> None:
|
||||
with self._condition:
|
||||
self._sensor_queue.append(event)
|
||||
self._condition.notify()
|
||||
|
||||
def get(self) -> HaCommandEvent | DeconzEvent | DeconzSensorEvent | None:
|
||||
with self._condition:
|
||||
while (
|
||||
not self._closed
|
||||
and not self._ha_order
|
||||
and not self._deconz_order
|
||||
and not self._sensor_queue
|
||||
):
|
||||
self._condition.wait()
|
||||
if self._closed:
|
||||
return None
|
||||
if self._sensor_queue:
|
||||
return self._sensor_queue.popleft()
|
||||
if self._ha_order:
|
||||
light_name = self._ha_order.popleft()
|
||||
return self._ha_pending.pop(light_name)
|
||||
if self._deconz_order:
|
||||
controller_id = self._deconz_order.popleft()
|
||||
return self._deconz_pending.pop(controller_id)
|
||||
raise RuntimeError("EventBroker woke without an event.")
|
||||
|
||||
def close(self) -> None:
|
||||
with self._condition:
|
||||
self._closed = True
|
||||
self._condition.notify_all()
|
||||
|
||||
|
||||
def _merge_ha_command(previous: HaCommandEvent, current: HaCommandEvent) -> HaCommandEvent:
|
||||
rgb = previous.rgb
|
||||
color_temp = previous.color_temp
|
||||
if current.rgb is not None:
|
||||
rgb = current.rgb
|
||||
color_temp = None
|
||||
elif current.color_temp is not None:
|
||||
rgb = None
|
||||
color_temp = current.color_temp
|
||||
|
||||
return HaCommandEvent(
|
||||
light_name=current.light_name,
|
||||
on=current.on if current.on is not None else previous.on,
|
||||
brightness=current.brightness if current.brightness is not None else previous.brightness,
|
||||
rgb=rgb,
|
||||
color_temp=color_temp,
|
||||
received_at=current.received_at,
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Settings:
|
||||
mqtt_host: str = "homeassistant"
|
||||
mqtt_port: int = 1883
|
||||
mqtt_user: str | None = None
|
||||
mqtt_password: str | None = None
|
||||
mqtt_discovery_prefix: str = "homeassistant"
|
||||
mqtt_topic_prefix: str = "ha-deconz-bridge"
|
||||
|
||||
deconz_host: str = "localhost"
|
||||
deconz_http_port: int = 80
|
||||
deconz_ws_port: int = 443
|
||||
deconz_api_key: str = ""
|
||||
|
||||
deconz_retry_timeout: float = 2.0
|
||||
deconz_max_retries: int = 4
|
||||
deconz_network_retries: int = 2
|
||||
http_timeout: float = 5.0
|
||||
|
||||
chroma_tolerance: float = 1e-6
|
||||
approximate_chroma_band: float = 0.025
|
||||
xy_distance_tolerance: float = 0.005
|
||||
brightness_tolerance: int = 2
|
||||
ct_tolerance: int = 1
|
||||
ct_endpoint_tolerance: int = 8
|
||||
target_cache_scale: int = 10000
|
||||
default_double_press_window: float = 0.45
|
||||
warmup_on_startup: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
defaults = cls()
|
||||
return cls(
|
||||
mqtt_host=_env("HA_DECONZ_BRIDGE_MQTT_HOST", defaults.mqtt_host),
|
||||
mqtt_port=int(_env("HA_DECONZ_BRIDGE_MQTT_PORT", defaults.mqtt_port)),
|
||||
mqtt_user=_env("HA_DECONZ_BRIDGE_MQTT_USER"),
|
||||
mqtt_password=_env("HA_DECONZ_BRIDGE_MQTT_PASSWORD"),
|
||||
mqtt_discovery_prefix=os.getenv(
|
||||
"HA_DECONZ_BRIDGE_MQTT_DISCOVERY_PREFIX",
|
||||
_env("HOME_LIGHTS_MQTT_DISCOVERY_PREFIX", defaults.mqtt_discovery_prefix),
|
||||
),
|
||||
mqtt_topic_prefix=os.getenv(
|
||||
"HA_DECONZ_BRIDGE_MQTT_TOPIC_PREFIX",
|
||||
_env("HOME_LIGHTS_MQTT_TOPIC_PREFIX", defaults.mqtt_topic_prefix),
|
||||
),
|
||||
deconz_host=_env("HA_DECONZ_BRIDGE_DECONZ_HOST", defaults.deconz_host),
|
||||
deconz_http_port=int(
|
||||
_env("HA_DECONZ_BRIDGE_DECONZ_HTTP_PORT", defaults.deconz_http_port)
|
||||
),
|
||||
deconz_ws_port=int(
|
||||
_env("HA_DECONZ_BRIDGE_DECONZ_WS_PORT", defaults.deconz_ws_port)
|
||||
),
|
||||
deconz_api_key=_env("HA_DECONZ_BRIDGE_DECONZ_API_KEY", ""),
|
||||
deconz_retry_timeout=float(
|
||||
_env(
|
||||
"HA_DECONZ_BRIDGE_DECONZ_RETRY_TIMEOUT",
|
||||
defaults.deconz_retry_timeout,
|
||||
)
|
||||
),
|
||||
deconz_max_retries=int(
|
||||
_env("HA_DECONZ_BRIDGE_DECONZ_MAX_RETRIES", defaults.deconz_max_retries)
|
||||
),
|
||||
deconz_network_retries=int(
|
||||
_env(
|
||||
"HA_DECONZ_BRIDGE_DECONZ_NETWORK_RETRIES",
|
||||
defaults.deconz_network_retries,
|
||||
)
|
||||
),
|
||||
http_timeout=float(
|
||||
_env("HA_DECONZ_BRIDGE_HTTP_TIMEOUT", defaults.http_timeout)
|
||||
),
|
||||
chroma_tolerance=float(
|
||||
_env("HA_DECONZ_BRIDGE_CHROMA_TOLERANCE", defaults.chroma_tolerance)
|
||||
),
|
||||
approximate_chroma_band=float(
|
||||
_env(
|
||||
"HA_DECONZ_BRIDGE_APPROXIMATE_CHROMA_BAND",
|
||||
defaults.approximate_chroma_band,
|
||||
)
|
||||
),
|
||||
xy_distance_tolerance=float(
|
||||
_env(
|
||||
"HA_DECONZ_BRIDGE_XY_DISTANCE_TOLERANCE",
|
||||
defaults.xy_distance_tolerance,
|
||||
)
|
||||
),
|
||||
brightness_tolerance=int(
|
||||
_env(
|
||||
"HA_DECONZ_BRIDGE_BRIGHTNESS_TOLERANCE",
|
||||
defaults.brightness_tolerance,
|
||||
)
|
||||
),
|
||||
ct_tolerance=int(
|
||||
_env("HA_DECONZ_BRIDGE_CT_TOLERANCE", defaults.ct_tolerance)
|
||||
),
|
||||
ct_endpoint_tolerance=int(
|
||||
_env(
|
||||
"HA_DECONZ_BRIDGE_CT_ENDPOINT_TOLERANCE",
|
||||
defaults.ct_endpoint_tolerance,
|
||||
)
|
||||
),
|
||||
target_cache_scale=int(
|
||||
_env(
|
||||
"HA_DECONZ_BRIDGE_TARGET_CACHE_SCALE",
|
||||
defaults.target_cache_scale,
|
||||
)
|
||||
),
|
||||
default_double_press_window=float(
|
||||
_env(
|
||||
"HA_DECONZ_BRIDGE_DEFAULT_DOUBLE_PRESS_WINDOW",
|
||||
defaults.default_double_press_window,
|
||||
)
|
||||
),
|
||||
warmup_on_startup=_env_bool(
|
||||
"HA_DECONZ_BRIDGE_WARMUP_ON_STARTUP",
|
||||
defaults.warmup_on_startup,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _env(name: str, default: object | None = None) -> str | object | None:
|
||||
return os.getenv(name, os.getenv(name.replace("HA_DECONZ_BRIDGE", "HOME_LIGHTS"), default))
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
value = _env(name)
|
||||
if value is None:
|
||||
return default
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import itertools
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial import ConvexHull, QhullError
|
||||
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.lights import ControllerModeName, VirtualLightSpec
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class CompiledChannel:
|
||||
address: str
|
||||
controller_id: str
|
||||
mode_name: ControllerModeName
|
||||
role: str
|
||||
color: Color
|
||||
semantic_label: str | None
|
||||
order_index: int
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class CompiledOption:
|
||||
key: str
|
||||
controller_modes: dict[str, ControllerModeName | None]
|
||||
dimmable_channels: tuple[CompiledChannel, ...]
|
||||
onoff_channels: tuple[CompiledChannel, ...]
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class CompiledCombination:
|
||||
key: str
|
||||
controller_modes: dict[str, ControllerModeName | None]
|
||||
dimmable_channels: tuple[CompiledChannel, ...]
|
||||
onoff_channels: tuple[CompiledChannel, ...]
|
||||
matrix: np.ndarray
|
||||
base_xyz: np.ndarray
|
||||
xy_bounds: tuple[float, float, float, float]
|
||||
xy_hull_equations: np.ndarray
|
||||
output_channel_count: int
|
||||
total_available_lumens: float
|
||||
order_key: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CompiledLightModel:
|
||||
spec: VirtualLightSpec
|
||||
combinations: tuple[CompiledCombination, ...]
|
||||
reference_combinations: tuple[CompiledCombination, ...]
|
||||
chroma_tolerance: float
|
||||
target_cache_scale: int
|
||||
approximate_chroma_band: float = 0.025
|
||||
reference_cache: dict[tuple[str, int, int], tuple[object, ...]] = field(default_factory=dict)
|
||||
max_brightness_cache: dict[tuple[str, int, int], float] = field(default_factory=dict)
|
||||
|
||||
|
||||
def compile_light_model(
|
||||
spec: VirtualLightSpec,
|
||||
*,
|
||||
chroma_tolerance: float = 1e-6,
|
||||
target_cache_scale: int = 10000,
|
||||
approximate_chroma_band: float = 0.025,
|
||||
) -> CompiledLightModel:
|
||||
choice_groups: list[tuple[CompiledOption, ...]] = []
|
||||
order_index = 0
|
||||
for controller in spec.controllers.values():
|
||||
options: list[CompiledOption] = []
|
||||
if controller.supported_modes == {"onoff"}:
|
||||
options.append(
|
||||
CompiledOption(
|
||||
key=f"{controller.zigbee_id}:off",
|
||||
controller_modes={controller.zigbee_id: None},
|
||||
dimmable_channels=(),
|
||||
onoff_channels=(),
|
||||
)
|
||||
)
|
||||
for mode in controller.modes.values():
|
||||
dimmable_channels: list[CompiledChannel] = []
|
||||
onoff_channels: list[CompiledChannel] = []
|
||||
target_list = onoff_channels if mode.name == "onoff" else dimmable_channels
|
||||
for role, channel in mode.channels.items():
|
||||
target_list.append(
|
||||
CompiledChannel(
|
||||
address=f"{controller.zigbee_id}.{mode.name}.{role}",
|
||||
controller_id=controller.zigbee_id,
|
||||
mode_name=mode.name,
|
||||
role=role,
|
||||
color=channel.color,
|
||||
semantic_label=channel.effective_label(mode.name),
|
||||
order_index=order_index,
|
||||
)
|
||||
)
|
||||
order_index += 1
|
||||
options.append(
|
||||
CompiledOption(
|
||||
key=f"{controller.zigbee_id}:{mode.name}",
|
||||
controller_modes={controller.zigbee_id: mode.name},
|
||||
dimmable_channels=tuple(dimmable_channels),
|
||||
onoff_channels=tuple(onoff_channels),
|
||||
)
|
||||
)
|
||||
choice_groups.append(tuple(options))
|
||||
|
||||
combinations: list[CompiledCombination] = []
|
||||
for product in itertools.product(*choice_groups):
|
||||
controller_modes: dict[str, ControllerModeName | None] = {}
|
||||
dimmable_channels: list[CompiledChannel] = []
|
||||
onoff_channels: list[CompiledChannel] = []
|
||||
order_key: list[str] = []
|
||||
for option in product:
|
||||
controller_modes.update(option.controller_modes)
|
||||
dimmable_channels.extend(option.dimmable_channels)
|
||||
onoff_channels.extend(option.onoff_channels)
|
||||
order_key.append(option.key)
|
||||
|
||||
dimmable_channels.sort(key=lambda channel: channel.order_index)
|
||||
onoff_channels.sort(key=lambda channel: channel.order_index)
|
||||
|
||||
if dimmable_channels:
|
||||
matrix = np.column_stack([channel.color.xyz for channel in dimmable_channels])
|
||||
else:
|
||||
matrix = np.zeros((3, 0), dtype=float)
|
||||
if onoff_channels:
|
||||
base_xyz = Color.weighted_sum(
|
||||
[channel.color for channel in onoff_channels],
|
||||
[1.0] * len(onoff_channels),
|
||||
).xyz
|
||||
else:
|
||||
base_xyz = np.zeros(3, dtype=float)
|
||||
|
||||
all_channels = dimmable_channels + onoff_channels
|
||||
if all_channels:
|
||||
xs = [channel.color.xy[0] for channel in all_channels]
|
||||
ys = [channel.color.xy[1] for channel in all_channels]
|
||||
xy_bounds = (min(xs), max(xs), min(ys), max(ys))
|
||||
else:
|
||||
xy_bounds = (0.0, 0.0, 0.0, 0.0)
|
||||
xy_hull_equations = _xy_hull_equations(all_channels, base_xyz)
|
||||
combinations.append(
|
||||
CompiledCombination(
|
||||
key="|".join(order_key),
|
||||
controller_modes=controller_modes,
|
||||
dimmable_channels=tuple(dimmable_channels),
|
||||
onoff_channels=tuple(onoff_channels),
|
||||
matrix=matrix,
|
||||
base_xyz=base_xyz,
|
||||
xy_bounds=xy_bounds,
|
||||
xy_hull_equations=xy_hull_equations,
|
||||
output_channel_count=len(all_channels),
|
||||
total_available_lumens=sum(channel.color.brightness for channel in all_channels),
|
||||
order_key=tuple(order_key),
|
||||
)
|
||||
)
|
||||
|
||||
return CompiledLightModel(
|
||||
spec=spec,
|
||||
combinations=tuple(combinations),
|
||||
reference_combinations=_deduplicate_reference_combinations(combinations),
|
||||
chroma_tolerance=chroma_tolerance,
|
||||
target_cache_scale=max(1, int(target_cache_scale)),
|
||||
approximate_chroma_band=approximate_chroma_band,
|
||||
)
|
||||
|
||||
|
||||
def _deduplicate_reference_combinations(
|
||||
combinations: list[CompiledCombination],
|
||||
) -> tuple[CompiledCombination, ...]:
|
||||
seen: set[tuple[object, ...]] = set()
|
||||
unique: list[CompiledCombination] = []
|
||||
for combination in combinations:
|
||||
key = _reference_signature(combination)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(combination)
|
||||
return tuple(unique)
|
||||
|
||||
|
||||
def _reference_signature(combination: CompiledCombination) -> tuple[object, ...]:
|
||||
dimmable = tuple(
|
||||
sorted(
|
||||
(
|
||||
tuple(round(float(value), 8) for value in channel.color.xyz),
|
||||
channel.semantic_label,
|
||||
channel.mode_name,
|
||||
channel.role,
|
||||
)
|
||||
for channel in combination.dimmable_channels
|
||||
)
|
||||
)
|
||||
onoff = tuple(
|
||||
sorted(
|
||||
(
|
||||
tuple(round(float(value), 8) for value in channel.color.xyz),
|
||||
channel.semantic_label,
|
||||
channel.mode_name,
|
||||
channel.role,
|
||||
)
|
||||
for channel in combination.onoff_channels
|
||||
)
|
||||
)
|
||||
return (dimmable, onoff)
|
||||
|
||||
|
||||
def _xy_hull_equations(
|
||||
channels: list[CompiledChannel],
|
||||
base_xyz: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
points = [channel.color.xy for channel in channels]
|
||||
if float(base_xyz[1]) > 0.0:
|
||||
base_sum = float(np.sum(base_xyz))
|
||||
if base_sum > 0.0:
|
||||
points.append((float(base_xyz[0] / base_sum), float(base_xyz[1] / base_sum)))
|
||||
unique = sorted({(round(x, 10), round(y, 10)) for x, y in points})
|
||||
if len(unique) < 3:
|
||||
return np.zeros((0, 3), dtype=float)
|
||||
try:
|
||||
hull = ConvexHull(np.asarray(unique, dtype=float))
|
||||
except QhullError:
|
||||
return np.zeros((0, 3), dtype=float)
|
||||
return np.asarray(hull.equations, dtype=float)
|
||||
@@ -0,0 +1,547 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import numpy as np
|
||||
from scipy.optimize import linprog, lsq_linear
|
||||
|
||||
|
||||
BRIGHTNESS_TOLERANCE = 1e-3
|
||||
LINPROG_OPTIONS = {"presolve": False}
|
||||
NUMERIC_BACKEND = (
|
||||
os.getenv("HA_DECONZ_BRIDGE_NUMERIC_BACKEND")
|
||||
or os.getenv("HOME_LIGHTS_NUMERIC_BACKEND")
|
||||
or "scipy"
|
||||
).strip().lower()
|
||||
MAX_NUMBA_UB_CHANNELS = 8
|
||||
|
||||
try:
|
||||
from ha_deconz_bridge.solver_numeric_numba import solve_box_eq_lp, solve_box_ub_lp
|
||||
except Exception:
|
||||
solve_box_eq_lp = None
|
||||
solve_box_ub_lp = None
|
||||
|
||||
|
||||
def _numba_enabled() -> bool:
|
||||
return (
|
||||
NUMERIC_BACKEND in {"numba", "auto"}
|
||||
and solve_box_eq_lp is not None
|
||||
and solve_box_ub_lp is not None
|
||||
)
|
||||
|
||||
|
||||
def _numba_objective(objective: np.ndarray) -> np.ndarray:
|
||||
# Tiny deterministic tie-breaker avoids degenerate bases when several
|
||||
# channels are numerically identical. It is far below lumen-scale policy
|
||||
# tolerances and only affects otherwise equivalent active-set choices.
|
||||
objective = np.asarray(objective, dtype=float)
|
||||
if objective.size == 0:
|
||||
return objective
|
||||
tie_break = np.linspace(objective.size, 1.0, objective.size, dtype=float) * 1e-6
|
||||
return objective + tie_break
|
||||
|
||||
|
||||
def _chroma_constraints(
|
||||
matrix: np.ndarray,
|
||||
base_xyz: np.ndarray,
|
||||
target_xy: tuple[float, float],
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
x_t, y_t = target_xy
|
||||
Xi = matrix[0]
|
||||
Yi = matrix[1]
|
||||
Zi = matrix[2]
|
||||
Si = Xi + Yi + Zi
|
||||
base_sum = float(np.sum(base_xyz))
|
||||
|
||||
A = np.vstack([Xi - x_t * Si, Yi - y_t * Si])
|
||||
b = np.asarray(
|
||||
[
|
||||
-(base_xyz[0] - x_t * base_sum),
|
||||
-(base_xyz[1] - y_t * base_sum),
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
return A, b
|
||||
|
||||
|
||||
def _ct_line_constraint(
|
||||
matrix: np.ndarray,
|
||||
base_xyz: np.ndarray,
|
||||
target_xy: tuple[float, float],
|
||||
) -> tuple[np.ndarray, float]:
|
||||
x_t, y_t = target_xy
|
||||
n = (x_t - 0.3320) / (0.1858 - y_t)
|
||||
c = 0.3320 + 0.1858 * n
|
||||
row = (1.0 - c) * matrix[0] + (n - c) * matrix[1] - c * matrix[2]
|
||||
rhs = -((1.0 - c) * base_xyz[0] + (n - c) * base_xyz[1] - c * base_xyz[2])
|
||||
return (row, float(rhs))
|
||||
|
||||
|
||||
def maximize_for_ct_line(
|
||||
matrix: np.ndarray,
|
||||
base_xyz: np.ndarray,
|
||||
target_xy: tuple[float, float],
|
||||
*,
|
||||
chroma_tolerance: float,
|
||||
) -> tuple[np.ndarray, bool] | None:
|
||||
if matrix.shape[1] == 0:
|
||||
return None
|
||||
|
||||
row, rhs = _ct_line_constraint(matrix, base_xyz, target_xy)
|
||||
objective = matrix[1]
|
||||
if _numba_enabled():
|
||||
result = solve_box_eq_lp(
|
||||
row.reshape(1, -1),
|
||||
np.asarray([rhs], dtype=float),
|
||||
_numba_objective(objective),
|
||||
tolerance=max(chroma_tolerance, 1e-9),
|
||||
)
|
||||
if result is not None:
|
||||
return (result, True)
|
||||
|
||||
exact_lp = linprog(
|
||||
-objective,
|
||||
A_eq=row.reshape(1, -1),
|
||||
b_eq=np.asarray([rhs], dtype=float),
|
||||
bounds=[(0.0, 1.0)] * matrix.shape[1],
|
||||
method="highs",
|
||||
options=LINPROG_OPTIONS,
|
||||
)
|
||||
if exact_lp.success:
|
||||
return (np.asarray(exact_lp.x, dtype=float), True)
|
||||
|
||||
closest = lsq_linear(
|
||||
row.reshape(1, -1),
|
||||
np.asarray([rhs], dtype=float),
|
||||
bounds=(0.0, 1.0),
|
||||
method="bvls",
|
||||
)
|
||||
if not closest.success:
|
||||
return None
|
||||
residual = abs(float(row @ closest.x - rhs))
|
||||
if _numba_enabled() and matrix.shape[1] <= MAX_NUMBA_UB_CHANNELS:
|
||||
result = solve_box_ub_lp(
|
||||
np.vstack([row, -row]),
|
||||
np.asarray([rhs + residual, -rhs + residual], dtype=float),
|
||||
_numba_objective(objective),
|
||||
tolerance=max(chroma_tolerance, 1e-9),
|
||||
)
|
||||
if result is not None:
|
||||
return (result, residual <= chroma_tolerance)
|
||||
|
||||
approximate_lp = linprog(
|
||||
-objective,
|
||||
A_ub=np.vstack([row, -row]),
|
||||
b_ub=np.asarray([rhs + residual, -rhs + residual], dtype=float),
|
||||
bounds=[(0.0, 1.0)] * matrix.shape[1],
|
||||
method="highs",
|
||||
options=LINPROG_OPTIONS,
|
||||
)
|
||||
if not approximate_lp.success:
|
||||
return (np.asarray(closest.x, dtype=float), residual <= chroma_tolerance)
|
||||
return (np.asarray(approximate_lp.x, dtype=float), residual <= chroma_tolerance)
|
||||
|
||||
|
||||
def maximize_exact_for_xy(
|
||||
matrix: np.ndarray,
|
||||
base_xyz: np.ndarray,
|
||||
target_xy: tuple[float, float],
|
||||
*,
|
||||
chroma_tolerance: float,
|
||||
) -> np.ndarray | None:
|
||||
A, b = _chroma_constraints(matrix, base_xyz, target_xy)
|
||||
if _numba_enabled() and matrix.shape[1] >= A.shape[0]:
|
||||
result = solve_box_eq_lp(
|
||||
A,
|
||||
b,
|
||||
_numba_objective(matrix[1]),
|
||||
tolerance=max(chroma_tolerance, 1e-9),
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
if matrix.shape[1] <= MAX_NUMBA_UB_CHANNELS:
|
||||
result = solve_box_ub_lp(
|
||||
np.vstack([A, -A]),
|
||||
np.hstack([b + chroma_tolerance, -b + chroma_tolerance]),
|
||||
_numba_objective(matrix[1]),
|
||||
tolerance=max(chroma_tolerance, 1e-9),
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
exact_lp = linprog(
|
||||
-matrix[1],
|
||||
A_ub=np.vstack([A, -A]),
|
||||
b_ub=np.hstack([b + chroma_tolerance, -b + chroma_tolerance]),
|
||||
bounds=[(0.0, 1.0)] * matrix.shape[1],
|
||||
method="highs",
|
||||
options=LINPROG_OPTIONS,
|
||||
)
|
||||
if not exact_lp.success:
|
||||
return None
|
||||
return np.asarray(exact_lp.x, dtype=float)
|
||||
|
||||
|
||||
def maximize_for_xy(
|
||||
matrix: np.ndarray,
|
||||
base_xyz: np.ndarray,
|
||||
target_xy: tuple[float, float],
|
||||
*,
|
||||
chroma_tolerance: float,
|
||||
try_exact: bool = True,
|
||||
) -> tuple[np.ndarray, bool]:
|
||||
Yi = matrix[1]
|
||||
A, b = _chroma_constraints(matrix, base_xyz, target_xy)
|
||||
|
||||
if try_exact:
|
||||
exact_dims = maximize_exact_for_xy(
|
||||
matrix,
|
||||
base_xyz,
|
||||
target_xy,
|
||||
chroma_tolerance=chroma_tolerance,
|
||||
)
|
||||
if exact_dims is not None:
|
||||
return (exact_dims, True)
|
||||
|
||||
res_ls = lsq_linear(A, b, bounds=(0.0, 1.0), method="bvls")
|
||||
residual_inf = float(np.max(np.abs(A @ res_ls.x - b)))
|
||||
epsilon = max(chroma_tolerance, residual_inf)
|
||||
lp = linprog(
|
||||
-Yi,
|
||||
A_ub=np.vstack([A, -A]),
|
||||
b_ub=np.hstack([b + epsilon, -b + epsilon]),
|
||||
bounds=[(0.0, 1.0)] * matrix.shape[1],
|
||||
method="highs",
|
||||
options=LINPROG_OPTIONS,
|
||||
)
|
||||
if lp.success:
|
||||
dims = lp.x
|
||||
else:
|
||||
dims = np.clip(res_ls.x, 0.0, 1.0)
|
||||
return (np.asarray(dims, dtype=float), residual_inf <= chroma_tolerance)
|
||||
|
||||
|
||||
def fit_target_profile(
|
||||
matrix: np.ndarray,
|
||||
base_xyz: np.ndarray,
|
||||
target_xyz: np.ndarray,
|
||||
*,
|
||||
chroma_tolerance: float,
|
||||
) -> np.ndarray | None:
|
||||
result = lsq_linear(
|
||||
matrix,
|
||||
np.asarray(target_xyz - base_xyz, dtype=float),
|
||||
bounds=(0.0, np.inf),
|
||||
method="bvls",
|
||||
)
|
||||
if not result.success:
|
||||
return None
|
||||
dims = np.asarray(result.x, dtype=float)
|
||||
peak = float(np.max(dims))
|
||||
if peak <= chroma_tolerance:
|
||||
return None
|
||||
return np.clip(dims / peak, 0.0, 1.0)
|
||||
|
||||
|
||||
def solve_feasible_levels(
|
||||
matrix: np.ndarray,
|
||||
base_xyz: np.ndarray,
|
||||
target_xy: tuple[float, float],
|
||||
*,
|
||||
y_target: float,
|
||||
objective: np.ndarray,
|
||||
chroma_tolerance: float,
|
||||
) -> np.ndarray | None:
|
||||
x_t, y_t = target_xy
|
||||
Xi = matrix[0]
|
||||
Yi = matrix[1]
|
||||
Zi = matrix[2]
|
||||
Si = Xi + Yi + Zi
|
||||
base_sum = float(np.sum(base_xyz))
|
||||
|
||||
A = np.vstack([Xi - x_t * Si, Yi - y_t * Si])
|
||||
b = np.asarray(
|
||||
[
|
||||
-(base_xyz[0] - x_t * base_sum),
|
||||
-(base_xyz[1] - y_t * base_sum),
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
|
||||
y_offset = y_target - base_xyz[1]
|
||||
if y_offset < -chroma_tolerance:
|
||||
return None
|
||||
|
||||
if _numba_enabled() and matrix.shape[1] >= 3:
|
||||
result = solve_box_eq_lp(
|
||||
np.vstack([A, Yi]),
|
||||
np.asarray([b[0], b[1], y_offset], dtype=float),
|
||||
_numba_objective(objective),
|
||||
tolerance=max(chroma_tolerance, 1e-9),
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
if matrix.shape[1] <= MAX_NUMBA_UB_CHANNELS:
|
||||
result = solve_box_ub_lp(
|
||||
np.vstack([A, -A, Yi, -Yi]),
|
||||
np.hstack(
|
||||
[
|
||||
b + chroma_tolerance,
|
||||
-b + chroma_tolerance,
|
||||
[
|
||||
y_offset + BRIGHTNESS_TOLERANCE,
|
||||
-(y_offset - BRIGHTNESS_TOLERANCE),
|
||||
],
|
||||
]
|
||||
),
|
||||
_numba_objective(objective),
|
||||
tolerance=max(chroma_tolerance, 1e-9),
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
lp = linprog(
|
||||
-objective,
|
||||
A_ub=np.vstack([A, -A, Yi, -Yi]),
|
||||
b_ub=np.hstack(
|
||||
[
|
||||
b + chroma_tolerance,
|
||||
-b + chroma_tolerance,
|
||||
[y_offset + BRIGHTNESS_TOLERANCE, -(y_offset - BRIGHTNESS_TOLERANCE)],
|
||||
]
|
||||
),
|
||||
bounds=[(0.0, 1.0)] * matrix.shape[1],
|
||||
method="highs",
|
||||
options=LINPROG_OPTIONS,
|
||||
)
|
||||
if not lp.success:
|
||||
return None
|
||||
return np.asarray(lp.x, dtype=float)
|
||||
|
||||
|
||||
def solve_feasible_ct_line_levels(
|
||||
matrix: np.ndarray,
|
||||
base_xyz: np.ndarray,
|
||||
target_xy: tuple[float, float],
|
||||
*,
|
||||
y_target: float,
|
||||
objective: np.ndarray,
|
||||
chroma_tolerance: float,
|
||||
) -> np.ndarray | None:
|
||||
row, rhs = _ct_line_constraint(matrix, base_xyz, target_xy)
|
||||
Yi = matrix[1]
|
||||
y_offset = y_target - base_xyz[1]
|
||||
if y_offset < -chroma_tolerance:
|
||||
return None
|
||||
|
||||
if _numba_enabled() and matrix.shape[1] >= 2:
|
||||
result = solve_box_eq_lp(
|
||||
np.vstack([row, Yi]),
|
||||
np.asarray([rhs, y_offset], dtype=float),
|
||||
_numba_objective(objective),
|
||||
tolerance=max(chroma_tolerance, 1e-9),
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
lp = linprog(
|
||||
-objective,
|
||||
A_ub=np.vstack([row, -row, Yi, -Yi]),
|
||||
b_ub=np.asarray(
|
||||
[
|
||||
rhs + chroma_tolerance,
|
||||
-rhs + chroma_tolerance,
|
||||
y_offset + BRIGHTNESS_TOLERANCE,
|
||||
-(y_offset - BRIGHTNESS_TOLERANCE),
|
||||
],
|
||||
dtype=float,
|
||||
),
|
||||
bounds=[(0.0, 1.0)] * matrix.shape[1],
|
||||
method="highs",
|
||||
options=LINPROG_OPTIONS,
|
||||
)
|
||||
if not lp.success:
|
||||
return None
|
||||
return np.asarray(lp.x, dtype=float)
|
||||
|
||||
|
||||
def minimize_chroma_error_on_ct_line_levels(
|
||||
matrix: np.ndarray,
|
||||
base_xyz: np.ndarray,
|
||||
target_xy: tuple[float, float],
|
||||
*,
|
||||
y_target: float,
|
||||
objective: np.ndarray,
|
||||
chroma_tolerance: float,
|
||||
) -> np.ndarray | None:
|
||||
if matrix.shape[1] == 0:
|
||||
return None
|
||||
|
||||
A, b = _chroma_constraints(matrix, base_xyz, target_xy)
|
||||
ct_row, ct_rhs = _ct_line_constraint(matrix, base_xyz, target_xy)
|
||||
Yi = matrix[1]
|
||||
y_offset = y_target - base_xyz[1]
|
||||
if y_offset < -chroma_tolerance:
|
||||
return None
|
||||
|
||||
channel_count = matrix.shape[1]
|
||||
error_column = -np.ones((A.shape[0], 1), dtype=float)
|
||||
zero_error_column = np.zeros((1, 1), dtype=float)
|
||||
A_ub = np.vstack(
|
||||
[
|
||||
np.hstack([A, error_column]),
|
||||
np.hstack([-A, error_column]),
|
||||
np.hstack([Yi.reshape(1, -1), zero_error_column]),
|
||||
np.hstack([-Yi.reshape(1, -1), zero_error_column]),
|
||||
]
|
||||
)
|
||||
b_ub = np.hstack(
|
||||
[
|
||||
b,
|
||||
-b,
|
||||
[y_offset + BRIGHTNESS_TOLERANCE, -(y_offset - BRIGHTNESS_TOLERANCE)],
|
||||
]
|
||||
)
|
||||
cost = np.hstack([-np.asarray(objective, dtype=float) * 1e-9, [1.0]])
|
||||
lp = linprog(
|
||||
cost,
|
||||
A_ub=A_ub,
|
||||
b_ub=b_ub,
|
||||
A_eq=np.hstack([ct_row.reshape(1, -1), zero_error_column]),
|
||||
b_eq=np.asarray([ct_rhs], dtype=float),
|
||||
bounds=[(0.0, 1.0)] * channel_count + [(0.0, None)],
|
||||
method="highs",
|
||||
options=LINPROG_OPTIONS,
|
||||
)
|
||||
if not lp.success:
|
||||
return None
|
||||
return np.asarray(lp.x[:channel_count], dtype=float)
|
||||
|
||||
|
||||
def balance_levels(
|
||||
matrix: np.ndarray,
|
||||
base_xyz: np.ndarray,
|
||||
target_xy: tuple[float, float],
|
||||
*,
|
||||
y_target: float,
|
||||
same_label_weights: np.ndarray,
|
||||
group_indices: tuple[tuple[int, ...], ...],
|
||||
baseline: np.ndarray,
|
||||
chroma_tolerance: float,
|
||||
) -> np.ndarray | None:
|
||||
x_t, y_t = target_xy
|
||||
Xi = matrix[0]
|
||||
Yi = matrix[1]
|
||||
Zi = matrix[2]
|
||||
Si = Xi + Yi + Zi
|
||||
base_sum = float(np.sum(base_xyz))
|
||||
A = np.vstack([Xi - x_t * Si, Yi - y_t * Si])
|
||||
b = np.asarray(
|
||||
[
|
||||
-(base_xyz[0] - x_t * base_sum),
|
||||
-(base_xyz[1] - y_t * base_sum),
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
y_offset = y_target - base_xyz[1]
|
||||
if y_offset < -chroma_tolerance:
|
||||
return None
|
||||
|
||||
rows: list[np.ndarray] = []
|
||||
rhs: list[float] = []
|
||||
|
||||
for row, value in zip(A, b):
|
||||
rows.append(row * 2000.0)
|
||||
rhs.append(float(value) * 2000.0)
|
||||
|
||||
rows.append(Yi * 2000.0)
|
||||
rhs.append(float(y_offset) * 2000.0)
|
||||
|
||||
target_same = float(same_label_weights @ baseline)
|
||||
if target_same > chroma_tolerance:
|
||||
rows.append(same_label_weights * 40.0)
|
||||
rhs.append(target_same * 40.0)
|
||||
|
||||
# Softly minimize total relative channel load. With the color and brightness
|
||||
# constraints already pinned, this acts like an intensity-squared penalty.
|
||||
regularization = np.eye(matrix.shape[1], dtype=float) * 0.25
|
||||
rows.extend(regularization)
|
||||
rhs.extend([0.0] * matrix.shape[1])
|
||||
|
||||
# Within each label bucket, softly equalize relative intensities. This is
|
||||
# intentionally a preference, not a hard constraint.
|
||||
for indices in group_indices:
|
||||
if len(indices) < 2:
|
||||
continue
|
||||
for left, right in zip(indices, indices[1:]):
|
||||
row = np.zeros(matrix.shape[1], dtype=float)
|
||||
row[left] = 120.0
|
||||
row[right] = -120.0
|
||||
rows.append(row)
|
||||
rhs.append(0.0)
|
||||
|
||||
result = lsq_linear(
|
||||
np.vstack(rows),
|
||||
np.asarray(rhs, dtype=float),
|
||||
bounds=(0.0, 1.0),
|
||||
method="bvls",
|
||||
)
|
||||
if not result.success:
|
||||
return None
|
||||
return np.asarray(result.x, dtype=float)
|
||||
|
||||
|
||||
def balance_ct_line_levels(
|
||||
matrix: np.ndarray,
|
||||
base_xyz: np.ndarray,
|
||||
target_xy: tuple[float, float],
|
||||
*,
|
||||
y_target: float,
|
||||
same_label_weights: np.ndarray,
|
||||
group_indices: tuple[tuple[int, ...], ...],
|
||||
baseline: np.ndarray,
|
||||
chroma_tolerance: float,
|
||||
) -> np.ndarray | None:
|
||||
row, rhs = _ct_line_constraint(matrix, base_xyz, target_xy)
|
||||
y_offset = y_target - base_xyz[1]
|
||||
if y_offset < -chroma_tolerance:
|
||||
return None
|
||||
|
||||
rows: list[np.ndarray] = []
|
||||
values: list[float] = []
|
||||
rows.append(row * 200000.0)
|
||||
values.append(rhs * 200000.0)
|
||||
rows.append(matrix[1] * 200000.0)
|
||||
values.append(float(y_offset) * 200000.0)
|
||||
|
||||
total_capacity = float(np.sum(matrix[1]))
|
||||
target_load = 0.0 if total_capacity <= 0.0 else min(1.0, max(0.0, y_offset / total_capacity))
|
||||
neutral_baseline = np.full(matrix.shape[1], target_load, dtype=float)
|
||||
|
||||
target_same = float(same_label_weights @ neutral_baseline)
|
||||
if target_same > chroma_tolerance:
|
||||
rows.append(same_label_weights * 40.0)
|
||||
values.append(target_same * 40.0)
|
||||
|
||||
regularization = np.eye(matrix.shape[1], dtype=float) * 25.0
|
||||
rows.extend(regularization)
|
||||
values.extend((neutral_baseline * 25.0).tolist())
|
||||
|
||||
for indices in group_indices:
|
||||
if len(indices) < 2:
|
||||
continue
|
||||
for left, right in zip(indices, indices[1:]):
|
||||
balance_row = np.zeros(matrix.shape[1], dtype=float)
|
||||
balance_row[left] = 500.0
|
||||
balance_row[right] = -500.0
|
||||
rows.append(balance_row)
|
||||
values.append(0.0)
|
||||
|
||||
result = lsq_linear(
|
||||
np.vstack(rows),
|
||||
np.asarray(values, dtype=float),
|
||||
bounds=(0.0, 1.0),
|
||||
method="bvls",
|
||||
)
|
||||
if not result.success:
|
||||
return None
|
||||
return np.asarray(result.x, dtype=float)
|
||||
@@ -0,0 +1,529 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from numba import njit
|
||||
|
||||
|
||||
def solve_box_eq_lp(
|
||||
a_eq: np.ndarray,
|
||||
b_eq: np.ndarray,
|
||||
objective: np.ndarray,
|
||||
*,
|
||||
tolerance: float,
|
||||
) -> np.ndarray | None:
|
||||
success, solution = _solve_box_eq_lp_numba(
|
||||
np.asarray(a_eq, dtype=np.float64),
|
||||
np.asarray(b_eq, dtype=np.float64),
|
||||
np.asarray(objective, dtype=np.float64),
|
||||
float(tolerance),
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
return np.asarray(solution, dtype=float)
|
||||
|
||||
|
||||
def solve_box_ub_lp(
|
||||
a_ub: np.ndarray,
|
||||
b_ub: np.ndarray,
|
||||
objective: np.ndarray,
|
||||
*,
|
||||
tolerance: float,
|
||||
) -> np.ndarray | None:
|
||||
success, solution = _solve_box_ub_lp_numba(
|
||||
np.asarray(a_ub, dtype=np.float64),
|
||||
np.asarray(b_ub, dtype=np.float64),
|
||||
np.asarray(objective, dtype=np.float64),
|
||||
float(tolerance),
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
return np.asarray(solution, dtype=float)
|
||||
|
||||
|
||||
@njit(cache=True)
|
||||
def _solve_box_ub_lp_numba(
|
||||
a_ub: np.ndarray,
|
||||
b_ub: np.ndarray,
|
||||
objective: np.ndarray,
|
||||
tolerance: float,
|
||||
) -> tuple[bool, np.ndarray]:
|
||||
rows = a_ub.shape[0]
|
||||
cols = a_ub.shape[1]
|
||||
best = np.zeros(cols, dtype=np.float64)
|
||||
work = np.zeros(cols, dtype=np.float64)
|
||||
best_objective = -1.0e300
|
||||
found = False
|
||||
|
||||
for col in range(cols):
|
||||
work[col] = 1.0 if objective[col] >= 0.0 else 0.0
|
||||
ok, value = _check_ub_solution(a_ub, b_ub, objective, tolerance, work)
|
||||
if ok:
|
||||
best_objective = value
|
||||
best[:] = work
|
||||
found = True
|
||||
|
||||
max_masks = 1 << rows
|
||||
for mask in range(1, max_masks):
|
||||
active_count = 0
|
||||
for row in range(rows):
|
||||
if mask & (1 << row):
|
||||
active_count += 1
|
||||
if active_count > cols or active_count > 6:
|
||||
continue
|
||||
|
||||
active_a = np.zeros((active_count, cols), dtype=np.float64)
|
||||
active_b = np.zeros(active_count, dtype=np.float64)
|
||||
active_row = 0
|
||||
for row in range(rows):
|
||||
if mask & (1 << row):
|
||||
active_b[active_row] = b_ub[row]
|
||||
for col in range(cols):
|
||||
active_a[active_row, col] = a_ub[row, col]
|
||||
active_row += 1
|
||||
|
||||
ok, value, candidate = _solve_active_ub_basis(
|
||||
active_a,
|
||||
active_b,
|
||||
a_ub,
|
||||
b_ub,
|
||||
objective,
|
||||
tolerance,
|
||||
)
|
||||
if ok and value > best_objective + tolerance:
|
||||
best_objective = value
|
||||
best[:] = candidate
|
||||
found = True
|
||||
|
||||
return found, best
|
||||
|
||||
|
||||
@njit(cache=True)
|
||||
def _solve_active_ub_basis(
|
||||
active_a: np.ndarray,
|
||||
active_b: np.ndarray,
|
||||
a_ub: np.ndarray,
|
||||
b_ub: np.ndarray,
|
||||
objective: np.ndarray,
|
||||
tolerance: float,
|
||||
) -> tuple[bool, float, np.ndarray]:
|
||||
rows = active_a.shape[0]
|
||||
cols = active_a.shape[1]
|
||||
basis = np.empty(rows, dtype=np.int64)
|
||||
for row in range(rows):
|
||||
basis[row] = row
|
||||
|
||||
best = np.zeros(cols, dtype=np.float64)
|
||||
work = np.zeros(cols, dtype=np.float64)
|
||||
best_objective = -1.0e300
|
||||
found = False
|
||||
|
||||
while True:
|
||||
ok, value = _try_dynamic_basis(
|
||||
active_a,
|
||||
active_b,
|
||||
a_ub,
|
||||
b_ub,
|
||||
objective,
|
||||
tolerance,
|
||||
basis,
|
||||
work,
|
||||
)
|
||||
if ok and value > best_objective + tolerance:
|
||||
best_objective = value
|
||||
best[:] = work
|
||||
found = True
|
||||
|
||||
index = rows - 1
|
||||
while index >= 0 and basis[index] == cols - rows + index:
|
||||
index -= 1
|
||||
if index < 0:
|
||||
break
|
||||
basis[index] += 1
|
||||
for next_index in range(index + 1, rows):
|
||||
basis[next_index] = basis[next_index - 1] + 1
|
||||
|
||||
return found, best_objective, best
|
||||
|
||||
|
||||
@njit(cache=True)
|
||||
def _try_dynamic_basis(
|
||||
active_a: np.ndarray,
|
||||
active_b: np.ndarray,
|
||||
a_ub: np.ndarray,
|
||||
b_ub: np.ndarray,
|
||||
objective: np.ndarray,
|
||||
tolerance: float,
|
||||
basis: np.ndarray,
|
||||
out: np.ndarray,
|
||||
) -> tuple[bool, float]:
|
||||
rows = active_a.shape[0]
|
||||
cols = active_a.shape[1]
|
||||
basis_matrix = np.zeros((rows, rows), dtype=np.float64)
|
||||
objective_basis = np.zeros(rows, dtype=np.float64)
|
||||
for row in range(rows):
|
||||
objective_basis[row] = objective[basis[row]]
|
||||
for col in range(rows):
|
||||
basis_matrix[row, col] = active_a[row, basis[col]]
|
||||
|
||||
basis_matrix_t = np.zeros((rows, rows), dtype=np.float64)
|
||||
for row in range(rows):
|
||||
for col in range(rows):
|
||||
basis_matrix_t[row, col] = basis_matrix[col, row]
|
||||
solved_lambdas, lambdas = _solve_small_linear_system(
|
||||
basis_matrix_t,
|
||||
objective_basis,
|
||||
tolerance,
|
||||
)
|
||||
if not solved_lambdas:
|
||||
return False, 0.0
|
||||
rhs = active_b.copy()
|
||||
|
||||
for col in range(cols):
|
||||
is_basis = False
|
||||
for basis_col in range(rows):
|
||||
if col == basis[basis_col]:
|
||||
is_basis = True
|
||||
break
|
||||
if is_basis:
|
||||
out[col] = 0.0
|
||||
continue
|
||||
reduced = objective[col]
|
||||
for row in range(rows):
|
||||
reduced -= active_a[row, col] * lambdas[row]
|
||||
value = 1.0 if reduced >= -tolerance else 0.0
|
||||
out[col] = value
|
||||
for row in range(rows):
|
||||
rhs[row] -= active_a[row, col] * value
|
||||
|
||||
solved_basis, basis_values = _solve_small_linear_system(
|
||||
basis_matrix,
|
||||
rhs,
|
||||
tolerance,
|
||||
)
|
||||
if not solved_basis:
|
||||
return False, 0.0
|
||||
for row in range(rows):
|
||||
value = basis_values[row]
|
||||
if value < -tolerance or value > 1.0 + tolerance:
|
||||
return False, 0.0
|
||||
out[basis[row]] = min(1.0, max(0.0, value))
|
||||
|
||||
return _check_ub_solution(a_ub, b_ub, objective, tolerance, out)
|
||||
|
||||
|
||||
@njit(cache=True)
|
||||
def _solve_small_linear_system(
|
||||
matrix: np.ndarray,
|
||||
rhs: np.ndarray,
|
||||
tolerance: float,
|
||||
) -> tuple[bool, np.ndarray]:
|
||||
n = matrix.shape[0]
|
||||
a = matrix.copy()
|
||||
b = rhs.copy()
|
||||
x = np.zeros(n, dtype=np.float64)
|
||||
pivot_tolerance = max(tolerance * 100.0, 1.0e-10)
|
||||
|
||||
for col in range(n):
|
||||
pivot_row = col
|
||||
pivot_abs = abs(a[col, col])
|
||||
for row in range(col + 1, n):
|
||||
candidate_abs = abs(a[row, col])
|
||||
if candidate_abs > pivot_abs:
|
||||
pivot_abs = candidate_abs
|
||||
pivot_row = row
|
||||
if pivot_abs <= pivot_tolerance:
|
||||
return False, x
|
||||
|
||||
if pivot_row != col:
|
||||
for swap_col in range(n):
|
||||
temp = a[col, swap_col]
|
||||
a[col, swap_col] = a[pivot_row, swap_col]
|
||||
a[pivot_row, swap_col] = temp
|
||||
temp_b = b[col]
|
||||
b[col] = b[pivot_row]
|
||||
b[pivot_row] = temp_b
|
||||
|
||||
pivot = a[col, col]
|
||||
for row in range(col + 1, n):
|
||||
factor = a[row, col] / pivot
|
||||
if factor == 0.0:
|
||||
continue
|
||||
a[row, col] = 0.0
|
||||
for elim_col in range(col + 1, n):
|
||||
a[row, elim_col] -= factor * a[col, elim_col]
|
||||
b[row] -= factor * b[col]
|
||||
|
||||
for row in range(n - 1, -1, -1):
|
||||
total = b[row]
|
||||
for col in range(row + 1, n):
|
||||
total -= a[row, col] * x[col]
|
||||
pivot = a[row, row]
|
||||
if abs(pivot) <= pivot_tolerance:
|
||||
return False, x
|
||||
x[row] = total / pivot
|
||||
return True, x
|
||||
|
||||
|
||||
@njit(cache=True)
|
||||
def _solve_box_eq_lp_numba(
|
||||
a_eq: np.ndarray,
|
||||
b_eq: np.ndarray,
|
||||
objective: np.ndarray,
|
||||
tolerance: float,
|
||||
) -> tuple[bool, np.ndarray]:
|
||||
rows = a_eq.shape[0]
|
||||
cols = a_eq.shape[1]
|
||||
best = np.zeros(cols, dtype=np.float64)
|
||||
work = np.zeros(cols, dtype=np.float64)
|
||||
best_objective = -1.0e300
|
||||
found = False
|
||||
|
||||
if rows == 1:
|
||||
for i in range(cols):
|
||||
ok, value = _try_basis1(a_eq, b_eq, objective, tolerance, i, work)
|
||||
if ok and value > best_objective + tolerance:
|
||||
best_objective = value
|
||||
best[:] = work
|
||||
found = True
|
||||
elif rows == 2:
|
||||
for i in range(cols - 1):
|
||||
for j in range(i + 1, cols):
|
||||
ok, value = _try_basis2(a_eq, b_eq, objective, tolerance, i, j, work)
|
||||
if ok and value > best_objective + tolerance:
|
||||
best_objective = value
|
||||
best[:] = work
|
||||
found = True
|
||||
elif rows == 3:
|
||||
for i in range(cols - 2):
|
||||
for j in range(i + 1, cols - 1):
|
||||
for k in range(j + 1, cols):
|
||||
ok, value = _try_basis3(
|
||||
a_eq,
|
||||
b_eq,
|
||||
objective,
|
||||
tolerance,
|
||||
i,
|
||||
j,
|
||||
k,
|
||||
work,
|
||||
)
|
||||
if ok and value > best_objective + tolerance:
|
||||
best_objective = value
|
||||
best[:] = work
|
||||
found = True
|
||||
|
||||
return found, best
|
||||
|
||||
|
||||
@njit(cache=True)
|
||||
def _try_basis1(
|
||||
a_eq: np.ndarray,
|
||||
b_eq: np.ndarray,
|
||||
objective: np.ndarray,
|
||||
tolerance: float,
|
||||
i: int,
|
||||
out: np.ndarray,
|
||||
) -> tuple[bool, float]:
|
||||
pivot = a_eq[0, i]
|
||||
if abs(pivot) <= tolerance:
|
||||
return False, 0.0
|
||||
|
||||
lam0 = objective[i] / pivot
|
||||
rhs0 = b_eq[0]
|
||||
for col in range(a_eq.shape[1]):
|
||||
if col == i:
|
||||
out[col] = 0.0
|
||||
continue
|
||||
reduced = objective[col] - a_eq[0, col] * lam0
|
||||
value = 1.0 if reduced >= -tolerance else 0.0
|
||||
out[col] = value
|
||||
rhs0 -= a_eq[0, col] * value
|
||||
|
||||
x0 = rhs0 / pivot
|
||||
if x0 < -tolerance or x0 > 1.0 + tolerance:
|
||||
return False, 0.0
|
||||
out[i] = min(1.0, max(0.0, x0))
|
||||
return _check_solution(a_eq, b_eq, objective, tolerance, out)
|
||||
|
||||
|
||||
@njit(cache=True)
|
||||
def _try_basis2(
|
||||
a_eq: np.ndarray,
|
||||
b_eq: np.ndarray,
|
||||
objective: np.ndarray,
|
||||
tolerance: float,
|
||||
i: int,
|
||||
j: int,
|
||||
out: np.ndarray,
|
||||
) -> tuple[bool, float]:
|
||||
a00 = a_eq[0, i]
|
||||
a01 = a_eq[1, i]
|
||||
a10 = a_eq[0, j]
|
||||
a11 = a_eq[1, j]
|
||||
det = a00 * a11 - a10 * a01
|
||||
if abs(det) <= tolerance:
|
||||
return False, 0.0
|
||||
|
||||
lam0 = (objective[i] * a11 - objective[j] * a01) / det
|
||||
lam1 = (a00 * objective[j] - a10 * objective[i]) / det
|
||||
|
||||
rhs0 = b_eq[0]
|
||||
rhs1 = b_eq[1]
|
||||
for col in range(a_eq.shape[1]):
|
||||
if col == i or col == j:
|
||||
out[col] = 0.0
|
||||
continue
|
||||
reduced = objective[col] - (a_eq[0, col] * lam0 + a_eq[1, col] * lam1)
|
||||
value = 1.0 if reduced >= -tolerance else 0.0
|
||||
out[col] = value
|
||||
rhs0 -= a_eq[0, col] * value
|
||||
rhs1 -= a_eq[1, col] * value
|
||||
|
||||
x_i = (rhs0 * a11 - a10 * rhs1) / det
|
||||
x_j = (a00 * rhs1 - rhs0 * a01) / det
|
||||
if x_i < -tolerance or x_i > 1.0 + tolerance:
|
||||
return False, 0.0
|
||||
if x_j < -tolerance or x_j > 1.0 + tolerance:
|
||||
return False, 0.0
|
||||
out[i] = min(1.0, max(0.0, x_i))
|
||||
out[j] = min(1.0, max(0.0, x_j))
|
||||
return _check_solution(a_eq, b_eq, objective, tolerance, out)
|
||||
|
||||
|
||||
@njit(cache=True)
|
||||
def _try_basis3(
|
||||
a_eq: np.ndarray,
|
||||
b_eq: np.ndarray,
|
||||
objective: np.ndarray,
|
||||
tolerance: float,
|
||||
i: int,
|
||||
j: int,
|
||||
k: int,
|
||||
out: np.ndarray,
|
||||
) -> tuple[bool, float]:
|
||||
a00 = a_eq[0, i]
|
||||
a01 = a_eq[1, i]
|
||||
a02 = a_eq[2, i]
|
||||
a10 = a_eq[0, j]
|
||||
a11 = a_eq[1, j]
|
||||
a12 = a_eq[2, j]
|
||||
a20 = a_eq[0, k]
|
||||
a21 = a_eq[1, k]
|
||||
a22 = a_eq[2, k]
|
||||
|
||||
det = (
|
||||
a00 * (a11 * a22 - a12 * a21)
|
||||
- a10 * (a01 * a22 - a02 * a21)
|
||||
+ a20 * (a01 * a12 - a02 * a11)
|
||||
)
|
||||
if abs(det) <= tolerance:
|
||||
return False, 0.0
|
||||
|
||||
c0 = objective[i]
|
||||
c1 = objective[j]
|
||||
c2 = objective[k]
|
||||
lam0 = (
|
||||
c0 * (a11 * a22 - a12 * a21)
|
||||
- c1 * (a01 * a22 - a02 * a21)
|
||||
+ c2 * (a01 * a12 - a02 * a11)
|
||||
) / det
|
||||
lam1 = (
|
||||
a00 * (c1 * a22 - a12 * c2)
|
||||
- a10 * (c0 * a22 - a02 * c2)
|
||||
+ a20 * (c0 * a12 - a02 * c1)
|
||||
) / det
|
||||
lam2 = (
|
||||
a00 * (a11 * c2 - c1 * a21)
|
||||
- a10 * (a01 * c2 - c0 * a21)
|
||||
+ a20 * (a01 * c1 - c0 * a11)
|
||||
) / det
|
||||
|
||||
rhs0 = b_eq[0]
|
||||
rhs1 = b_eq[1]
|
||||
rhs2 = b_eq[2]
|
||||
for col in range(a_eq.shape[1]):
|
||||
if col == i or col == j or col == k:
|
||||
out[col] = 0.0
|
||||
continue
|
||||
reduced = objective[col] - (
|
||||
a_eq[0, col] * lam0 + a_eq[1, col] * lam1 + a_eq[2, col] * lam2
|
||||
)
|
||||
value = 1.0 if reduced >= -tolerance else 0.0
|
||||
out[col] = value
|
||||
rhs0 -= a_eq[0, col] * value
|
||||
rhs1 -= a_eq[1, col] * value
|
||||
rhs2 -= a_eq[2, col] * value
|
||||
|
||||
x_i = (
|
||||
rhs0 * (a11 * a22 - a12 * a21)
|
||||
- a10 * (rhs1 * a22 - rhs2 * a21)
|
||||
+ a20 * (rhs1 * a12 - rhs2 * a11)
|
||||
) / det
|
||||
x_j = (
|
||||
a00 * (rhs1 * a22 - a12 * rhs2)
|
||||
- rhs0 * (a01 * a22 - a02 * a21)
|
||||
+ a20 * (a01 * rhs2 - a02 * rhs1)
|
||||
) / det
|
||||
x_k = (
|
||||
a00 * (a11 * rhs2 - rhs1 * a21)
|
||||
- a10 * (a01 * rhs2 - a02 * rhs1)
|
||||
+ rhs0 * (a01 * a12 - a02 * a11)
|
||||
) / det
|
||||
|
||||
if x_i < -tolerance or x_i > 1.0 + tolerance:
|
||||
return False, 0.0
|
||||
if x_j < -tolerance or x_j > 1.0 + tolerance:
|
||||
return False, 0.0
|
||||
if x_k < -tolerance or x_k > 1.0 + tolerance:
|
||||
return False, 0.0
|
||||
out[i] = min(1.0, max(0.0, x_i))
|
||||
out[j] = min(1.0, max(0.0, x_j))
|
||||
out[k] = min(1.0, max(0.0, x_k))
|
||||
return _check_solution(a_eq, b_eq, objective, tolerance, out)
|
||||
|
||||
|
||||
@njit(cache=True)
|
||||
def _check_solution(
|
||||
a_eq: np.ndarray,
|
||||
b_eq: np.ndarray,
|
||||
objective: np.ndarray,
|
||||
tolerance: float,
|
||||
values: np.ndarray,
|
||||
) -> tuple[bool, float]:
|
||||
objective_value = 0.0
|
||||
for col in range(a_eq.shape[1]):
|
||||
if values[col] < -tolerance or values[col] > 1.0 + tolerance:
|
||||
return False, 0.0
|
||||
objective_value += objective[col] * values[col]
|
||||
|
||||
for row in range(a_eq.shape[0]):
|
||||
total = 0.0
|
||||
for col in range(a_eq.shape[1]):
|
||||
total += a_eq[row, col] * values[col]
|
||||
if abs(total - b_eq[row]) > max(1.0e-7, tolerance * 20.0):
|
||||
return False, 0.0
|
||||
return True, objective_value
|
||||
|
||||
|
||||
@njit(cache=True)
|
||||
def _check_ub_solution(
|
||||
a_ub: np.ndarray,
|
||||
b_ub: np.ndarray,
|
||||
objective: np.ndarray,
|
||||
tolerance: float,
|
||||
values: np.ndarray,
|
||||
) -> tuple[bool, float]:
|
||||
objective_value = 0.0
|
||||
for col in range(a_ub.shape[1]):
|
||||
if values[col] < -tolerance or values[col] > 1.0 + tolerance:
|
||||
return False, 0.0
|
||||
objective_value += objective[col] * values[col]
|
||||
|
||||
for row in range(a_ub.shape[0]):
|
||||
total = 0.0
|
||||
for col in range(a_ub.shape[1]):
|
||||
total += a_ub[row, col] * values[col]
|
||||
if total - b_ub[row] > max(1.0e-7, tolerance * 20.0):
|
||||
return False, 0.0
|
||||
return True, objective_value
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ha_deconz_bridge.color import Color
|
||||
|
||||
ControllerMode = Literal["onoff", "brightness", "rgb", "color_temp"]
|
||||
HaMode = Literal["onoff", "brightness", "rgb", "color_temp"]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ControllerCommand:
|
||||
on: bool
|
||||
brightness: int | None = None
|
||||
mode: ControllerMode | None = None
|
||||
xy: tuple[float, float] | None = None
|
||||
color_temp: int | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ControllerState:
|
||||
on: bool
|
||||
reachable: bool = True
|
||||
brightness: int | None = None
|
||||
mode: ControllerMode | None = None
|
||||
xy: tuple[float, float] | None = None
|
||||
color_temp: int | None = None
|
||||
|
||||
@classmethod
|
||||
def unreachable(cls) -> "ControllerState":
|
||||
return cls(on=False, reachable=False)
|
||||
|
||||
def similar_to(
|
||||
self,
|
||||
command: ControllerCommand,
|
||||
*,
|
||||
brightness_tolerance: int,
|
||||
ct_tolerance: int,
|
||||
xy_distance_tolerance: float,
|
||||
) -> bool:
|
||||
if not self.reachable:
|
||||
return False
|
||||
if self.on != command.on:
|
||||
return False
|
||||
if not command.on:
|
||||
return True
|
||||
if command.brightness is not None:
|
||||
if self.brightness is None:
|
||||
return False
|
||||
if abs(self.brightness - command.brightness) > brightness_tolerance:
|
||||
return False
|
||||
if command.mode != self.mode:
|
||||
return False
|
||||
if command.mode == "rgb":
|
||||
if self.xy is None or command.xy is None:
|
||||
return False
|
||||
if np.linalg.norm(np.asarray(self.xy) - np.asarray(command.xy)) > xy_distance_tolerance:
|
||||
return False
|
||||
if command.mode == "color_temp":
|
||||
if self.color_temp is None or command.color_temp is None:
|
||||
return False
|
||||
if abs(self.color_temp - command.color_temp) > ct_tolerance:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AcceptedControllerState:
|
||||
command: ControllerCommand
|
||||
state: ControllerState
|
||||
channel_levels: dict[str, float]
|
||||
accepted_color: Color
|
||||
requested_mode: HaMode
|
||||
requested_brightness: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VirtualLightState:
|
||||
on: bool
|
||||
color: Color
|
||||
ha_mode: HaMode
|
||||
brightness: int
|
||||
exact_chroma_match: bool
|
||||
dominance: dict[HaMode, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SolveResult:
|
||||
achieved_color: Color
|
||||
reference_color: Color
|
||||
exact_chroma_match: bool
|
||||
clamped: bool
|
||||
requested_mode: HaMode
|
||||
channel_levels: dict[str, float]
|
||||
controller_modes: dict[str, ControllerMode | None]
|
||||
controller_commands: dict[str, ControllerCommand]
|
||||
chroma_error: float
|
||||
brightness_error: float
|
||||
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.lights import ControllerModeSpec, ControllerSpec, VirtualLightSpec
|
||||
from ha_deconz_bridge.state import AcceptedControllerState, ControllerCommand, ControllerState, HaMode, VirtualLightState
|
||||
|
||||
|
||||
def ct_from_mix(cool: float, warm: float, mireds_range: tuple[int, int]) -> int:
|
||||
low, high = tuple(sorted(mireds_range))
|
||||
total = cool + warm
|
||||
if total <= 0.0:
|
||||
return int(round((low + high) / 2))
|
||||
warm_fraction = warm / total
|
||||
return int(round(low + warm_fraction * (high - low)))
|
||||
|
||||
|
||||
def channel_levels_from_state(
|
||||
mode_spec: ControllerModeSpec,
|
||||
state: ControllerState,
|
||||
*,
|
||||
mireds_range: tuple[int, int] | None = None,
|
||||
) -> dict[str, float]:
|
||||
if not state.on:
|
||||
return {role: 0.0 for role in mode_spec.channels}
|
||||
|
||||
if mode_spec.name == "onoff":
|
||||
return {role: 1.0 for role in mode_spec.channels}
|
||||
|
||||
if mode_spec.name == "brightness":
|
||||
level = 0.0 if state.brightness is None else max(0.0, state.brightness / 255.0)
|
||||
return {role: level for role in mode_spec.channels}
|
||||
|
||||
if mode_spec.name == "rgb":
|
||||
if state.xy is None:
|
||||
return {role: 0.0 for role in mode_spec.channels}
|
||||
peak = 0.0 if state.brightness is None else max(0.0, state.brightness / 255.0)
|
||||
rgb = Color.from_xy(state.xy, brightness=1.0).rgb
|
||||
return {
|
||||
role: float(peak * rgb[index])
|
||||
for index, role in enumerate(("r", "g", "b"))
|
||||
if role in mode_spec.channels
|
||||
}
|
||||
|
||||
if mode_spec.name == "color_temp":
|
||||
if state.color_temp is None or mireds_range is None:
|
||||
return {role: 0.0 for role in mode_spec.channels}
|
||||
peak = 0.0 if state.brightness is None else max(0.0, state.brightness / 255.0)
|
||||
if "cw" in mode_spec.channels and "ww" not in mode_spec.channels:
|
||||
return {"cw": peak}
|
||||
if "ww" in mode_spec.channels and "cw" not in mode_spec.channels:
|
||||
return {"ww": peak}
|
||||
cool, warm = Color.from_color_temp(
|
||||
state.color_temp,
|
||||
brightness=1.0,
|
||||
temp_range=mireds_range,
|
||||
).ct_mix(mireds_range)
|
||||
levels: dict[str, float] = {}
|
||||
if "cw" in mode_spec.channels:
|
||||
levels["cw"] = peak * cool
|
||||
if "ww" in mode_spec.channels:
|
||||
levels["ww"] = peak * warm
|
||||
return levels
|
||||
|
||||
raise ValueError(f"Unsupported controller mode {mode_spec.name!r}.")
|
||||
|
||||
|
||||
def actual_color_from_channel_levels(
|
||||
mode_spec: ControllerModeSpec,
|
||||
levels: dict[str, float],
|
||||
) -> Color:
|
||||
colors: list[Color] = []
|
||||
weights: list[float] = []
|
||||
for role, channel in mode_spec.channels.items():
|
||||
colors.append(channel.color)
|
||||
weights.append(float(levels.get(role, 0.0)))
|
||||
return Color.weighted_sum(colors, weights)
|
||||
|
||||
|
||||
def controller_state_to_actual_color(
|
||||
controller_spec: ControllerSpec,
|
||||
state: ControllerState,
|
||||
*,
|
||||
mireds_range: tuple[int, int] | None = None,
|
||||
) -> tuple[Color, str]:
|
||||
if not state.reachable or not state.on:
|
||||
return (Color.off(), "onoff")
|
||||
if state.mode is None:
|
||||
state_mode = "onoff"
|
||||
else:
|
||||
state_mode = state.mode
|
||||
mode_spec = controller_spec.modes[state_mode]
|
||||
levels = channel_levels_from_state(mode_spec, state, mireds_range=mireds_range)
|
||||
actual = actual_color_from_channel_levels(mode_spec, levels)
|
||||
return (actual.as_mode(state_mode), state_mode)
|
||||
|
||||
|
||||
def accepted_controller_state_from_command(
|
||||
controller_spec: ControllerSpec,
|
||||
command: ControllerCommand,
|
||||
channel_levels: dict[str, float],
|
||||
*,
|
||||
controller_id: str,
|
||||
requested_mode: str,
|
||||
requested_brightness: int,
|
||||
requested_color: Color | None = None,
|
||||
mireds_range: tuple[int, int] | None = None,
|
||||
) -> AcceptedControllerState:
|
||||
mode_name = command.mode
|
||||
controller_levels = {
|
||||
address: level
|
||||
for address, level in channel_levels.items()
|
||||
if address.startswith(f"{controller_id}.")
|
||||
}
|
||||
role_levels = {
|
||||
address.split(".")[2]: level
|
||||
for address, level in controller_levels.items()
|
||||
if mode_name is not None and address.split(".")[1] == mode_name
|
||||
}
|
||||
state = controller_command_to_state(command)
|
||||
if mode_name is None:
|
||||
accepted_color = Color.off()
|
||||
elif _should_preserve_native_ct_command(controller_spec, command):
|
||||
calibrated_color = actual_color_from_channel_levels(
|
||||
controller_spec.modes[mode_name],
|
||||
role_levels,
|
||||
)
|
||||
accepted_mireds = (
|
||||
requested_color.color_temp
|
||||
if requested_color is not None and requested_mode == "color_temp"
|
||||
else command.color_temp or calibrated_color.color_temp
|
||||
)
|
||||
accepted_color = Color.from_color_temp(
|
||||
accepted_mireds,
|
||||
brightness=calibrated_color.brightness,
|
||||
temp_range=mireds_range or controller_spec.native_ct_mireds or (153, 500),
|
||||
)
|
||||
else:
|
||||
accepted_color = actual_color_from_channel_levels(controller_spec.modes[mode_name], role_levels)
|
||||
return AcceptedControllerState(
|
||||
command=command,
|
||||
state=state,
|
||||
channel_levels=controller_levels,
|
||||
accepted_color=accepted_color,
|
||||
requested_mode=requested_mode,
|
||||
requested_brightness=requested_brightness,
|
||||
)
|
||||
|
||||
|
||||
def _is_native_two_channel_ct(mode_spec: ControllerModeSpec) -> bool:
|
||||
return mode_spec.name == "color_temp" and {"ww", "cw"}.issubset(mode_spec.channels)
|
||||
|
||||
|
||||
def _should_preserve_native_ct_command(
|
||||
controller_spec: ControllerSpec,
|
||||
command: ControllerCommand,
|
||||
) -> bool:
|
||||
if command.mode != "color_temp" or command.color_temp is None:
|
||||
return False
|
||||
mode = controller_spec.modes.get("color_temp")
|
||||
return mode is not None and _is_native_two_channel_ct(mode)
|
||||
|
||||
|
||||
def controller_command_to_state(command: ControllerCommand) -> ControllerState:
|
||||
return ControllerState(
|
||||
on=command.on,
|
||||
reachable=True,
|
||||
brightness=command.brightness,
|
||||
mode=command.mode,
|
||||
xy=command.xy,
|
||||
color_temp=command.color_temp,
|
||||
)
|
||||
|
||||
|
||||
def controller_command_from_channel_levels(
|
||||
controller_spec: ControllerSpec,
|
||||
mode_name: str | None,
|
||||
levels: dict[str, float],
|
||||
*,
|
||||
mireds_range: tuple[int, int] | None = None,
|
||||
) -> ControllerCommand:
|
||||
if mode_name is None:
|
||||
return ControllerCommand(on=False)
|
||||
|
||||
mode_spec = controller_spec.modes[mode_name]
|
||||
if mode_spec.name == "onoff":
|
||||
return ControllerCommand(on=any(level > 0.0 for level in levels.values()), mode="onoff")
|
||||
|
||||
if mode_spec.name == "brightness":
|
||||
level = max(levels.values(), default=0.0)
|
||||
brightness = int(round(level * 255.0))
|
||||
return ControllerCommand(
|
||||
on=brightness > 0,
|
||||
brightness=brightness,
|
||||
mode="brightness",
|
||||
)
|
||||
|
||||
if mode_spec.name == "rgb":
|
||||
red = float(levels.get("r", 0.0))
|
||||
green = float(levels.get("g", 0.0))
|
||||
blue = float(levels.get("b", 0.0))
|
||||
peak = max(red, green, blue)
|
||||
brightness = int(round(peak * 255.0))
|
||||
if brightness <= 0:
|
||||
return ControllerCommand(on=False, brightness=0, mode="rgb")
|
||||
normalized = (red / peak, green / peak, blue / peak)
|
||||
color = Color.from_rgb(normalized, brightness=peak)
|
||||
return ControllerCommand(
|
||||
on=True,
|
||||
brightness=brightness,
|
||||
mode="rgb",
|
||||
xy=color.xy,
|
||||
)
|
||||
|
||||
if mode_spec.name == "color_temp":
|
||||
if mireds_range is None:
|
||||
raise ValueError("Color temperature controller command requires a mireds range.")
|
||||
cool = float(levels.get("cw", 0.0))
|
||||
warm = float(levels.get("ww", 0.0))
|
||||
peak = max(cool, warm)
|
||||
brightness = int(round(peak * 255.0))
|
||||
if brightness <= 0:
|
||||
return ControllerCommand(on=False, brightness=0, mode="color_temp")
|
||||
command_mireds = ct_from_mix(cool / peak, warm / peak, mireds_range)
|
||||
return ControllerCommand(
|
||||
on=True,
|
||||
brightness=brightness,
|
||||
mode="color_temp",
|
||||
color_temp=command_mireds,
|
||||
)
|
||||
|
||||
raise ValueError(f"Unsupported controller mode {mode_spec.name!r}.")
|
||||
|
||||
|
||||
def choose_ha_mode(
|
||||
spec: VirtualLightSpec,
|
||||
dominance: dict[str, float],
|
||||
*,
|
||||
preferred_mode: HaMode | None = None,
|
||||
) -> HaMode:
|
||||
if spec.supported_color_modes == ("onoff",):
|
||||
return "onoff"
|
||||
if spec.supported_color_modes == ("brightness",):
|
||||
return "brightness"
|
||||
allowed = [mode for mode in ("color_temp", "rgb") if mode in spec.mode_set]
|
||||
if preferred_mode in allowed:
|
||||
return preferred_mode
|
||||
return max(allowed, key=lambda mode: (dominance.get(mode, 0.0), mode == "color_temp"))
|
||||
|
||||
|
||||
def ha_payload_from_state(state: VirtualLightState) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"state": "ON" if state.on else "OFF",
|
||||
}
|
||||
if state.ha_mode != "onoff":
|
||||
payload["brightness"] = int(state.brightness)
|
||||
if state.ha_mode == "rgb":
|
||||
rgb = tuple(int(round(component * 255.0)) for component in state.color.rgb)
|
||||
payload["color_mode"] = "rgb"
|
||||
payload["color"] = {"r": rgb[0], "g": rgb[1], "b": rgb[2]}
|
||||
elif state.ha_mode == "color_temp":
|
||||
payload["color_mode"] = "color_temp"
|
||||
payload["color_temp"] = int(state.color.color_temp)
|
||||
elif state.ha_mode == "brightness":
|
||||
payload["color_mode"] = "brightness"
|
||||
return payload
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.lights import VirtualLightSpec
|
||||
|
||||
|
||||
WarmupRequest = tuple[Color, str, int]
|
||||
|
||||
|
||||
def warmup_requests(spec: VirtualLightSpec) -> tuple[WarmupRequest, ...]:
|
||||
requests: list[WarmupRequest] = []
|
||||
if "onoff" in spec.mode_set:
|
||||
requests.append((Color.from_rgb((1.0, 1.0, 1.0), brightness=1.0), "onoff", 255))
|
||||
if "brightness" in spec.mode_set:
|
||||
requests.append((Color.from_rgb((1.0, 1.0, 1.0), brightness=1.0), "brightness", 255))
|
||||
if "rgb" in spec.mode_set:
|
||||
requests.append((Color.from_rgb((255, 255, 255), brightness=1.0), "rgb", 255))
|
||||
if "color_temp" in spec.mode_set:
|
||||
low, high = spec.exposed_mireds or (153, 500)
|
||||
mid = int(round((low + high) / 2))
|
||||
requests.append((Color.from_color_temp(mid, brightness=1.0, temp_range=(low, high)), "color_temp", 255))
|
||||
return tuple(requests)
|
||||
|
||||
|
||||
def run_warmup(
|
||||
specs: Iterable[VirtualLightSpec],
|
||||
solve: Callable[[VirtualLightSpec, WarmupRequest], None],
|
||||
) -> int:
|
||||
count = 0
|
||||
warmed_modes: set[str] = set()
|
||||
for spec in specs:
|
||||
for request in warmup_requests(spec):
|
||||
mode = request[1]
|
||||
if mode in warmed_modes:
|
||||
continue
|
||||
solve(spec, request)
|
||||
warmed_modes.add(mode)
|
||||
count += 1
|
||||
return count
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
|
||||
from ha_deconz_bridge.settings import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
def _rgb_controller(zigbee_id: str) -> ControllerSpec:
|
||||
return ControllerSpec(
|
||||
zigbee_id=zigbee_id,
|
||||
name=f"RGB {zigbee_id}",
|
||||
modes={
|
||||
"rgb": ControllerModeSpec(
|
||||
name="rgb",
|
||||
channels={
|
||||
"r": ChannelSpec("r", Color.from_rgb((1.0, 0.0, 0.0), brightness=0.9)),
|
||||
"g": ChannelSpec("g", Color.from_rgb((0.0, 1.0, 0.0), brightness=1.2)),
|
||||
"b": ChannelSpec("b", Color.from_rgb((0.0, 0.0, 1.0), brightness=0.8)),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _ct_controller(zigbee_id: str) -> ControllerSpec:
|
||||
return ControllerSpec(
|
||||
zigbee_id=zigbee_id,
|
||||
name=f"CT {zigbee_id}",
|
||||
modes={
|
||||
"color_temp": ControllerModeSpec(
|
||||
name="color_temp",
|
||||
channels={
|
||||
"ww": ChannelSpec(
|
||||
"ww",
|
||||
Color.from_color_temp(500, brightness=1.0, temp_range=(153, 500)),
|
||||
),
|
||||
"cw": ChannelSpec(
|
||||
"cw",
|
||||
Color.from_color_temp(153, brightness=1.0, temp_range=(153, 500)),
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rgb_light_spec() -> VirtualLightSpec:
|
||||
return VirtualLightSpec(
|
||||
name="rgb_light",
|
||||
supported_color_modes=("rgb",),
|
||||
controllers={"1": _rgb_controller("1")},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ct_light_spec() -> VirtualLightSpec:
|
||||
return VirtualLightSpec(
|
||||
name="ct_light",
|
||||
supported_color_modes=("color_temp",),
|
||||
controllers={"2": _ct_controller("2")},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mixed_light_spec() -> VirtualLightSpec:
|
||||
shared = ControllerSpec(
|
||||
zigbee_id="3",
|
||||
name="Shared",
|
||||
modes={
|
||||
"rgb": ControllerModeSpec(
|
||||
name="rgb",
|
||||
channels={
|
||||
"r": ChannelSpec("r", Color.from_rgb((1.0, 0.0, 0.0), brightness=0.6)),
|
||||
"g": ChannelSpec("g", Color.from_rgb((0.0, 1.0, 0.0), brightness=0.6)),
|
||||
"b": ChannelSpec("b", Color.from_rgb((0.0, 0.0, 1.0), brightness=0.4)),
|
||||
},
|
||||
),
|
||||
"color_temp": ControllerModeSpec(
|
||||
name="color_temp",
|
||||
channels={
|
||||
"ww": ChannelSpec(
|
||||
"ww",
|
||||
Color.from_color_temp(500, brightness=0.9, temp_range=(153, 500)),
|
||||
),
|
||||
"cw": ChannelSpec(
|
||||
"cw",
|
||||
Color.from_color_temp(153, brightness=0.9, temp_range=(153, 500)),
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
onoff = ControllerSpec(
|
||||
zigbee_id="4",
|
||||
name="Accent",
|
||||
modes={
|
||||
"onoff": ControllerModeSpec(
|
||||
name="onoff",
|
||||
channels={"w": ChannelSpec("w", Color.from_rgb((1.0, 1.0, 1.0), brightness=0.3))},
|
||||
)
|
||||
},
|
||||
)
|
||||
return VirtualLightSpec(
|
||||
name="mixed_light",
|
||||
supported_color_modes=("rgb", "color_temp"),
|
||||
controllers={
|
||||
"2": _ct_controller("2"),
|
||||
"3": shared,
|
||||
"4": onoff,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def red_only_spec() -> VirtualLightSpec:
|
||||
return VirtualLightSpec(
|
||||
name="red_only",
|
||||
supported_color_modes=("rgb",),
|
||||
controllers={
|
||||
"1": ControllerSpec(
|
||||
zigbee_id="1",
|
||||
name="Red only",
|
||||
modes={
|
||||
"rgb": ControllerModeSpec(
|
||||
name="rgb",
|
||||
channels={
|
||||
"r": ChannelSpec("r", Color.from_rgb((1.0, 0.0, 0.0), brightness=1.0)),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ha_deconz_bridge.automation import (
|
||||
ActionSpec,
|
||||
AutomationEngine,
|
||||
AutomationRule,
|
||||
RemoteGestureDetector,
|
||||
RemoteSpec,
|
||||
TimeCondition,
|
||||
automation_rules,
|
||||
)
|
||||
|
||||
|
||||
def test_hue_dimmer_short_press_maps_to_single_and_double() -> None:
|
||||
remote = RemoteSpec("4", "room", "Room remote", double_press_window=0.5)
|
||||
detector = RemoteGestureDetector(default_double_press_window=0.45)
|
||||
|
||||
first = detector.handle(
|
||||
remote,
|
||||
{"buttonevent": 1002, "eventduration": 1},
|
||||
received_at=10.0,
|
||||
)
|
||||
detector.handle(remote, {"buttonevent": 1000, "eventduration": 0}, received_at=10.2)
|
||||
second = detector.handle(
|
||||
remote,
|
||||
{"buttonevent": 1002, "eventduration": 1},
|
||||
received_at=10.3,
|
||||
)
|
||||
|
||||
assert [(event.button, event.gesture) for event in first] == [("top", "single")]
|
||||
assert [(event.button, event.gesture) for event in second] == [("top", "double")]
|
||||
|
||||
|
||||
def test_double_press_state_is_per_physical_remote() -> None:
|
||||
detector = RemoteGestureDetector(default_double_press_window=0.45)
|
||||
first_remote = RemoteSpec("4", "room", "First")
|
||||
second_remote = RemoteSpec("5", "room", "Second")
|
||||
|
||||
detector.handle(first_remote, {"buttonevent": 1002}, received_at=10.0)
|
||||
events = detector.handle(second_remote, {"buttonevent": 1002}, received_at=10.2)
|
||||
|
||||
assert [event.gesture for event in events] == ["single"]
|
||||
|
||||
|
||||
def test_hue_dimmer_long_hold_maps_to_repeat_and_release() -> None:
|
||||
remote = RemoteSpec("4", "room", "Room remote")
|
||||
detector = RemoteGestureDetector(default_double_press_window=0.45)
|
||||
|
||||
repeat = detector.handle(
|
||||
remote,
|
||||
{"buttonevent": 2001, "eventduration": 8},
|
||||
received_at=10.8,
|
||||
)
|
||||
release = detector.handle(
|
||||
remote,
|
||||
{"buttonevent": 2003, "eventduration": 22},
|
||||
received_at=12.2,
|
||||
)
|
||||
|
||||
assert [(event.button, event.gesture, event.eventduration, event.hold_count) for event in repeat] == [
|
||||
("up", "hold", 8, 1)
|
||||
]
|
||||
assert [(event.button, event.gesture, event.eventduration) for event in release] == [
|
||||
("up", "long", 22)
|
||||
]
|
||||
|
||||
|
||||
def test_double_hold_and_double_long_use_same_physical_remote() -> None:
|
||||
remote = RemoteSpec("4", "room", "Room remote", double_press_window=0.5)
|
||||
detector = RemoteGestureDetector(default_double_press_window=0.45)
|
||||
|
||||
detector.handle(remote, {"buttonevent": 1002}, received_at=10.0)
|
||||
detector.handle(remote, {"buttonevent": 1000}, received_at=10.2)
|
||||
first_hold = detector.handle(remote, {"buttonevent": 1001, "eventduration": 8}, received_at=10.4)
|
||||
repeat_hold = detector.handle(remote, {"buttonevent": 1001, "eventduration": 16}, received_at=11.2)
|
||||
release = detector.handle(remote, {"buttonevent": 1003, "eventduration": 20}, received_at=11.6)
|
||||
|
||||
assert [(event.gesture, event.hold_count) for event in first_hold] == [("double_hold", 1)]
|
||||
assert [(event.gesture, event.hold_count) for event in repeat_hold] == [("double_hold", 2)]
|
||||
assert [event.gesture for event in release] == ["double_long"]
|
||||
|
||||
|
||||
def test_double_long_state_is_per_physical_remote() -> None:
|
||||
detector = RemoteGestureDetector(default_double_press_window=0.45)
|
||||
first_remote = RemoteSpec("4", "room", "First")
|
||||
second_remote = RemoteSpec("5", "room", "Second")
|
||||
|
||||
detector.handle(first_remote, {"buttonevent": 1002}, received_at=10.0)
|
||||
detector.handle(second_remote, {"buttonevent": 1000}, received_at=10.1)
|
||||
events = detector.handle(second_remote, {"buttonevent": 1001}, received_at=10.2)
|
||||
|
||||
assert [event.gesture for event in events] == ["hold"]
|
||||
|
||||
|
||||
def test_hold_count_gesture_patterns() -> None:
|
||||
action = ActionSpec("turn_off", target="Example Living")
|
||||
engine = AutomationEngine(
|
||||
remotes={"4": RemoteSpec("4", "room", "Room remote")},
|
||||
rules=(
|
||||
AutomationRule("room", "up", "hold", (action,)),
|
||||
AutomationRule("room", "up", "hold_2", (action,)),
|
||||
AutomationRule("room", "up", "hold_2_2", (action,)),
|
||||
AutomationRule("room", "up", "hold_2_3", (action,)),
|
||||
AutomationRule("room", "up", "hold_3_2", (action,)),
|
||||
),
|
||||
default_double_press_window=0.45,
|
||||
)
|
||||
|
||||
first = engine.handle_sensor_event("4", {"buttonevent": 2001}, received_at=10.0)
|
||||
second = engine.handle_sensor_event("4", {"buttonevent": 2001}, received_at=11.0)
|
||||
third = engine.handle_sensor_event("4", {"buttonevent": 2001}, received_at=12.0)
|
||||
fourth = engine.handle_sensor_event("4", {"buttonevent": 2001}, received_at=13.0)
|
||||
|
||||
assert first is not None
|
||||
assert second is not None
|
||||
assert third is not None
|
||||
assert fourth is not None
|
||||
assert len(first[1]) == 1
|
||||
assert len(second[1]) == 4
|
||||
assert len(third[1]) == 3
|
||||
assert len(fourth[1]) == 2
|
||||
|
||||
|
||||
def test_automation_rules_bind_to_internal_remote_id() -> None:
|
||||
action = ActionSpec("turn_off", target="Example Living")
|
||||
engine = AutomationEngine(
|
||||
remotes={
|
||||
"4": RemoteSpec("4", "room", "First"),
|
||||
"5": RemoteSpec("5", "room", "Second"),
|
||||
},
|
||||
rules=(
|
||||
AutomationRule(
|
||||
remote_id="room",
|
||||
button="bottom",
|
||||
gesture="single",
|
||||
actions=(action,),
|
||||
),
|
||||
),
|
||||
default_double_press_window=0.45,
|
||||
)
|
||||
|
||||
first = engine.handle_sensor_event(
|
||||
"4",
|
||||
{"buttonevent": 4002},
|
||||
received_at=10.0,
|
||||
)
|
||||
second = engine.handle_sensor_event(
|
||||
"5",
|
||||
{"buttonevent": 4002},
|
||||
received_at=20.0,
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert second is not None
|
||||
assert first[1] == (action,)
|
||||
assert second[1] == (action,)
|
||||
|
||||
|
||||
def test_time_condition_handles_midnight_wrap() -> None:
|
||||
condition = TimeCondition("22:00", "06:00")
|
||||
|
||||
assert condition.matches(datetime(2026, 1, 1, 23, 0))
|
||||
assert condition.matches(datetime(2026, 1, 1, 5, 59))
|
||||
assert not condition.matches(datetime(2026, 1, 1, 12, 0))
|
||||
|
||||
|
||||
def test_automation_rules_can_be_built_from_nested_config() -> None:
|
||||
action = ActionSpec("turn_off", target="Example Living")
|
||||
|
||||
rules = automation_rules(
|
||||
{
|
||||
"remote": {
|
||||
"bottom": {
|
||||
"single": (action,),
|
||||
"hold_2_0": (action,),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert [(rule.remote_id, rule.button, rule.gesture, rule.actions) for rule in rules] == [
|
||||
("remote", "bottom", "single", (action,)),
|
||||
("remote", "bottom", "hold_2_0", (action,)),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ha_deconz_bridge.color import Color
|
||||
|
||||
|
||||
def test_rgb_roundtrip_is_stable() -> None:
|
||||
color = Color.from_rgb((32, 128, 255), brightness=0.5)
|
||||
roundtrip = Color.from_xy(color.xy, brightness=color.brightness)
|
||||
assert np.linalg.norm(color.uv - roundtrip.uv) < 0.05
|
||||
assert roundtrip.brightness == color.brightness
|
||||
|
||||
|
||||
def test_weighted_sum_of_ct_colors_uses_xyz_mixing() -> None:
|
||||
warm = Color.from_color_temp(450, brightness=0.8)
|
||||
cool = Color.from_color_temp(200, brightness=0.2)
|
||||
mixed = Color.weighted_sum((warm, cool), (1.0, 1.0))
|
||||
assert mixed.mode == "rgb"
|
||||
assert 200 < mixed.color_temp < 450
|
||||
assert mixed.brightness == 1.0
|
||||
|
||||
|
||||
def test_weighted_sum_promotes_to_rgb_when_any_rgb_present() -> None:
|
||||
white = Color.from_color_temp(300, brightness=0.5)
|
||||
red = Color.from_rgb((1.0, 0.0, 0.0), brightness=0.25)
|
||||
mixed = Color.weighted_sum((white, red), (1.0, 1.0))
|
||||
assert mixed.mode == "rgb"
|
||||
assert mixed.brightness > 0.0
|
||||
|
||||
|
||||
def test_ct_mix_inverts_back_to_midpoint() -> None:
|
||||
color = Color.from_color_temp(326, brightness=1.0, temp_range=(153, 500))
|
||||
cool, warm = color.ct_mix((153, 500))
|
||||
assert 0.0 < cool <= 1.0
|
||||
assert 0.0 < warm <= 1.0
|
||||
|
||||
|
||||
def test_color_temp_preserves_requested_mireds_for_serialization() -> None:
|
||||
color = Color.from_color_temp(455, brightness=1.0, temp_range=(153, 500))
|
||||
dimmed = color.with_brightness(0.2)
|
||||
|
||||
assert color.color_temp == 455
|
||||
assert dimmed.color_temp == 455
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from ha_deconz_bridge.groups import GroupMemberSpec, GroupSpec, derive_group_modes
|
||||
|
||||
|
||||
def test_group_modes_are_member_union(rgb_light_spec, ct_light_spec) -> None:
|
||||
group = GroupSpec(
|
||||
name="mixed",
|
||||
members=(rgb_light_spec.name, ct_light_spec.name),
|
||||
)
|
||||
|
||||
modes = derive_group_modes(
|
||||
group,
|
||||
{
|
||||
rgb_light_spec.name: rgb_light_spec,
|
||||
ct_light_spec.name: ct_light_spec,
|
||||
},
|
||||
)
|
||||
|
||||
assert modes == ("rgb", "color_temp")
|
||||
|
||||
|
||||
def test_fixed_color_member_acts_as_brightness_mode(ct_light_spec) -> None:
|
||||
group = GroupSpec(
|
||||
name="fixed",
|
||||
members=(GroupMemberSpec(ct_light_spec.name, fixed_color=500),),
|
||||
)
|
||||
|
||||
assert derive_group_modes(group, {ct_light_spec.name: ct_light_spec}) == ("brightness",)
|
||||
|
||||
|
||||
def test_fixed_brightness_member_acts_as_onoff_mode(ct_light_spec) -> None:
|
||||
group = GroupSpec(
|
||||
name="fixed",
|
||||
members=(GroupMemberSpec(ct_light_spec.name, fixed_color=500, fixed_brightness=0.5),),
|
||||
)
|
||||
|
||||
assert derive_group_modes(group, {ct_light_spec.name: ct_light_spec}) == ("onoff",)
|
||||
|
||||
|
||||
def test_fixed_color_normalizes_mired_kelvin_rgb_and_xy() -> None:
|
||||
mired_member = GroupMemberSpec("a", fixed_color=500)
|
||||
kelvin_member = GroupMemberSpec("a", fixed_color=4000)
|
||||
rgb_member = GroupMemberSpec("a", fixed_color=(255, 128, 0))
|
||||
xy_member = GroupMemberSpec("a", fixed_color=(0.31, 0.32))
|
||||
|
||||
assert mired_member.fixed_color is not None
|
||||
assert mired_member.fixed_color.kind == "color_temp"
|
||||
assert mired_member.fixed_color.color_temp == 500
|
||||
assert kelvin_member.fixed_color is not None
|
||||
assert kelvin_member.fixed_color.color_temp == 250
|
||||
assert rgb_member.fixed_color is not None
|
||||
assert rgb_member.fixed_color.kind == "rgb"
|
||||
assert max(rgb_member.fixed_color.rgb255) == 255
|
||||
assert xy_member.fixed_color is not None
|
||||
assert xy_member.fixed_color.kind == "xy"
|
||||
|
||||
|
||||
def test_fixed_brightness_requires_fixed_color() -> None:
|
||||
with pytest.raises(ValueError, match="fixed_brightness requires fixed_color"):
|
||||
GroupMemberSpec("a", fixed_brightness=128)
|
||||
|
||||
|
||||
def test_fixed_brightness_normalizes_relative_and_ha_values() -> None:
|
||||
relative = GroupMemberSpec("a", fixed_color=500, fixed_brightness=0.5)
|
||||
ha_value = GroupMemberSpec("a", fixed_color=500, fixed_brightness=127)
|
||||
|
||||
assert relative.fixed_brightness == 128
|
||||
assert ha_value.fixed_brightness == 127
|
||||
|
||||
|
||||
def test_example_groups_reference_existing_lights() -> None:
|
||||
from configs.example_groups import GROUPS
|
||||
from configs.example_lights import LIGHTS
|
||||
|
||||
missing = {
|
||||
group_name: [
|
||||
member.light_name
|
||||
for member in group.member_specs()
|
||||
if member.light_name not in LIGHTS
|
||||
]
|
||||
for group_name, group in GROUPS.items()
|
||||
}
|
||||
|
||||
assert {name: names for name, names in missing.items() if names} == {}
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from ha_deconz_bridge.ha import HomeAssistantBridge
|
||||
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
|
||||
from ha_deconz_bridge.light import VirtualLightState
|
||||
from ha_deconz_bridge.color import Color
|
||||
|
||||
|
||||
class FakeMQTTClient:
|
||||
def __init__(self) -> None:
|
||||
self.connected = False
|
||||
self.on_connect = None
|
||||
self.on_message = None
|
||||
self.published: list[tuple[str, str, bool]] = []
|
||||
self.subscriptions: list[str] = []
|
||||
|
||||
def username_pw_set(self, username, password) -> None:
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
def connect(self, host: str, port: int, keepalive: int) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.keepalive = keepalive
|
||||
|
||||
def loop_start(self) -> None:
|
||||
self.connected = True
|
||||
if self.on_connect is not None:
|
||||
self.on_connect(self, None, None, 0)
|
||||
|
||||
def loop_stop(self) -> None:
|
||||
self.connected = False
|
||||
|
||||
def disconnect(self) -> None:
|
||||
self.connected = False
|
||||
|
||||
def publish(self, topic: str, payload: str, retain: bool = False):
|
||||
if not self.connected:
|
||||
raise RuntimeError("publish before MQTT connect")
|
||||
self.published.append((topic, payload, retain))
|
||||
|
||||
def subscribe(self, topic: str) -> None:
|
||||
if not self.connected:
|
||||
raise RuntimeError("subscribe before MQTT connect")
|
||||
self.subscriptions.append(topic)
|
||||
|
||||
|
||||
def test_bridge_waits_for_connect_before_discovery(settings, rgb_light_spec) -> None:
|
||||
mqtt = FakeMQTTClient()
|
||||
bridge = HomeAssistantBridge(settings, mqtt_client=mqtt)
|
||||
|
||||
bridge.start(on_command=lambda event: None)
|
||||
bridge.publish_discovery(rgb_light_spec)
|
||||
bridge.publish_state(
|
||||
rgb_light_spec,
|
||||
VirtualLightState(
|
||||
on=True,
|
||||
color=rgb_light_spec.controllers["1"].modes["rgb"].channels["r"].color,
|
||||
ha_mode="rgb",
|
||||
brightness=128,
|
||||
exact_chroma_match=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert mqtt.published[0][0] == "homeassistant/light/rgb_light/config"
|
||||
assert json.loads(mqtt.published[0][1])["schema"] == "json"
|
||||
assert json.loads(mqtt.published[0][1])["name"] == "rgb_light"
|
||||
assert mqtt.subscriptions == ["ha-deconz-bridge/light/rgb_light/set"]
|
||||
|
||||
|
||||
def test_bridge_sanitizes_mqtt_object_id_for_discovery_topics(settings) -> None:
|
||||
mqtt = FakeMQTTClient()
|
||||
bridge = HomeAssistantBridge(settings, mqtt_client=mqtt)
|
||||
bridge.start(on_command=lambda event: None)
|
||||
|
||||
spec = VirtualLightSpec(
|
||||
name="Example Light North",
|
||||
supported_color_modes=("brightness",),
|
||||
controllers={
|
||||
"example_controller": ControllerSpec(
|
||||
zigbee_id="example_controller",
|
||||
name="Example controller",
|
||||
modes={
|
||||
"brightness": ControllerModeSpec(
|
||||
name="brightness",
|
||||
channels={
|
||||
"w": ChannelSpec(
|
||||
"w",
|
||||
Color.from_color_temp(182, brightness=1.0),
|
||||
semantic_label="color_temp",
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
bridge.publish_discovery(spec)
|
||||
|
||||
assert mqtt.published[0][0] == "homeassistant/light/Example_Light_North/config"
|
||||
payload = json.loads(mqtt.published[0][1])
|
||||
assert payload["name"] == "Example Light North"
|
||||
assert payload["unique_id"] == "Example_Light_North"
|
||||
assert payload["device"]["identifiers"] == ["ha-deconz-bridge:Example_Light_North"]
|
||||
assert payload["device"]["name"] == "HA deCONZ Bridge: Example Light North"
|
||||
assert payload["command_topic"] == "ha-deconz-bridge/light/Example_Light_North/set"
|
||||
assert payload["state_topic"] == "ha-deconz-bridge/light/Example_Light_North/state"
|
||||
assert mqtt.subscriptions == ["ha-deconz-bridge/light/Example_Light_North/set"]
|
||||
|
||||
|
||||
def test_bridge_resubscribes_to_command_topics_on_reconnect(settings, rgb_light_spec) -> None:
|
||||
mqtt = FakeMQTTClient()
|
||||
bridge = HomeAssistantBridge(settings, mqtt_client=mqtt)
|
||||
bridge.start(on_command=lambda event: None)
|
||||
bridge.publish_discovery(rgb_light_spec)
|
||||
|
||||
mqtt.loop_stop()
|
||||
mqtt.loop_start()
|
||||
|
||||
assert mqtt.subscriptions == [
|
||||
"ha-deconz-bridge/light/rgb_light/set",
|
||||
"ha-deconz-bridge/light/rgb_light/set",
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ha_deconz_bridge.events import DeconzEvent, DeconzSensorEvent, HaCommandEvent
|
||||
from ha_deconz_bridge.queueing import EventBroker
|
||||
|
||||
|
||||
def test_event_broker_coalesces_latest_ha_command() -> None:
|
||||
broker = EventBroker()
|
||||
broker.put_ha(HaCommandEvent("light", on=True, brightness=10))
|
||||
broker.put_ha(HaCommandEvent("light", on=True, brightness=200))
|
||||
broker.put_deconz(DeconzEvent("1", {"on": True}))
|
||||
|
||||
first = broker.get()
|
||||
second = broker.get()
|
||||
|
||||
assert isinstance(first, HaCommandEvent)
|
||||
assert isinstance(second, DeconzEvent)
|
||||
assert first.brightness == 200
|
||||
|
||||
|
||||
def test_event_broker_merges_partial_ha_commands() -> None:
|
||||
broker = EventBroker()
|
||||
broker.put_ha(HaCommandEvent("light", on=True, brightness=120, color_temp=400))
|
||||
broker.put_ha(HaCommandEvent("light", rgb=(10, 20, 30)))
|
||||
|
||||
event = broker.get()
|
||||
|
||||
assert isinstance(event, HaCommandEvent)
|
||||
assert event.on is True
|
||||
assert event.brightness == 120
|
||||
assert event.rgb == (10, 20, 30)
|
||||
assert event.color_temp is None
|
||||
|
||||
|
||||
def test_event_broker_moves_merged_ha_command_to_back() -> None:
|
||||
broker = EventBroker()
|
||||
broker.put_ha(HaCommandEvent("first", brightness=1))
|
||||
broker.put_ha(HaCommandEvent("second", brightness=2))
|
||||
broker.put_ha(HaCommandEvent("first", rgb=(1, 2, 3)))
|
||||
|
||||
first = broker.get()
|
||||
second = broker.get()
|
||||
|
||||
assert isinstance(first, HaCommandEvent)
|
||||
assert isinstance(second, HaCommandEvent)
|
||||
assert first.light_name == "second"
|
||||
assert second.light_name == "first"
|
||||
assert second.brightness == 1
|
||||
assert second.rgb == (1, 2, 3)
|
||||
|
||||
|
||||
def test_event_broker_deduplicates_pending_deconz_events_by_controller() -> None:
|
||||
broker = EventBroker()
|
||||
broker.put_deconz(DeconzEvent("1", {"bri": 10}))
|
||||
broker.put_deconz(DeconzEvent("2", {"bri": 20}))
|
||||
broker.put_deconz(DeconzEvent("1", {"bri": 30}))
|
||||
|
||||
first = broker.get()
|
||||
second = broker.get()
|
||||
|
||||
assert isinstance(first, DeconzEvent)
|
||||
assert isinstance(second, DeconzEvent)
|
||||
assert first.controller_id == "2"
|
||||
assert first.raw_state == {"bri": 20}
|
||||
assert second.controller_id == "1"
|
||||
assert second.raw_state == {"bri": 30}
|
||||
|
||||
|
||||
def test_event_broker_prioritizes_sensor_then_ha_then_light_events() -> None:
|
||||
broker = EventBroker()
|
||||
broker.put_ha(HaCommandEvent("light", on=True, brightness=10))
|
||||
broker.put_deconz(DeconzEvent("1", {"on": True}))
|
||||
broker.put_sensor(DeconzSensorEvent("4", {"buttonevent": 1002}))
|
||||
|
||||
assert isinstance(broker.get(), DeconzSensorEvent)
|
||||
assert isinstance(broker.get(), HaCommandEvent)
|
||||
assert isinstance(broker.get(), DeconzEvent)
|
||||
|
||||
|
||||
def test_event_broker_does_not_let_light_readbacks_starve_ha_commands() -> None:
|
||||
broker = EventBroker()
|
||||
broker.put_deconz(DeconzEvent("1", {"bri": 10}))
|
||||
broker.put_deconz(DeconzEvent("2", {"bri": 20}))
|
||||
broker.put_ha(HaCommandEvent("light", on=True, brightness=255))
|
||||
|
||||
event = broker.get()
|
||||
|
||||
assert isinstance(event, HaCommandEvent)
|
||||
assert event.brightness == 255
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ha_deconz_bridge.settings import Settings
|
||||
|
||||
|
||||
def test_from_env_uses_dataclass_defaults(monkeypatch) -> None:
|
||||
monkeypatch.delenv("HA_DECONZ_BRIDGE_DECONZ_RETRY_TIMEOUT", raising=False)
|
||||
monkeypatch.delenv("HA_DECONZ_BRIDGE_MQTT_HOST", raising=False)
|
||||
monkeypatch.delenv("HA_DECONZ_BRIDGE_TARGET_CACHE_SCALE", raising=False)
|
||||
monkeypatch.delenv("HOME_LIGHTS_DECONZ_RETRY_TIMEOUT", raising=False)
|
||||
monkeypatch.delenv("HOME_LIGHTS_MQTT_HOST", raising=False)
|
||||
monkeypatch.delenv("HOME_LIGHTS_TARGET_CACHE_SCALE", raising=False)
|
||||
|
||||
settings = Settings.from_env()
|
||||
|
||||
assert settings.mqtt_host == "homeassistant"
|
||||
assert settings.deconz_retry_timeout == 2.0
|
||||
assert settings.target_cache_scale == 10000
|
||||
|
||||
|
||||
def test_from_env_reads_target_cache_scale(monkeypatch) -> None:
|
||||
monkeypatch.setenv("HA_DECONZ_BRIDGE_TARGET_CACHE_SCALE", "750")
|
||||
|
||||
settings = Settings.from_env()
|
||||
|
||||
assert settings.target_cache_scale == 750
|
||||
|
||||
|
||||
def test_from_env_reads_warmup_flag(monkeypatch) -> None:
|
||||
monkeypatch.setenv("HA_DECONZ_BRIDGE_WARMUP_ON_STARTUP", "false")
|
||||
|
||||
settings = Settings.from_env()
|
||||
|
||||
assert settings.warmup_on_startup is False
|
||||
|
||||
|
||||
def test_from_env_keeps_legacy_prefix_fallback(monkeypatch) -> None:
|
||||
monkeypatch.delenv("HA_DECONZ_BRIDGE_MQTT_HOST", raising=False)
|
||||
monkeypatch.setenv("HOME_LIGHTS_MQTT_HOST", "legacy-host")
|
||||
|
||||
settings = Settings.from_env()
|
||||
|
||||
assert settings.mqtt_host == "legacy-host"
|
||||
@@ -0,0 +1,688 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from configs.example import GROUPS, LIGHTS
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.debugger.web_debugger import _combine_specs, _group_debug_spec
|
||||
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
|
||||
from ha_deconz_bridge.solver import solve_light
|
||||
from ha_deconz_bridge.solver_model import compile_light_model
|
||||
from ha_deconz_bridge.translate import ct_from_mix
|
||||
|
||||
|
||||
def test_solver_reaches_exact_rgb_target(rgb_light_spec) -> None:
|
||||
model = compile_light_model(rgb_light_spec)
|
||||
target = Color.from_rgb((255, 0, 0), brightness=1.0)
|
||||
result = solve_light(model, target, brightness=255, requested_mode="rgb")
|
||||
assert result.exact_chroma_match is True
|
||||
assert result.achieved_color.rgb[0] > 0.95
|
||||
assert result.achieved_color.rgb[1] < 0.2
|
||||
assert result.controller_modes["1"] == "rgb"
|
||||
|
||||
|
||||
def test_solver_reaches_exact_ct_target(ct_light_spec) -> None:
|
||||
model = compile_light_model(ct_light_spec)
|
||||
target = Color.from_color_temp(400, brightness=1.0, temp_range=ct_light_spec.exposed_mireds)
|
||||
result = solve_light(model, target, brightness=255, requested_mode="color_temp")
|
||||
assert result.exact_chroma_match is False
|
||||
assert result.achieved_color.brightness > 1.0
|
||||
assert result.chroma_error < 0.02
|
||||
|
||||
|
||||
def test_solver_prefers_native_ct_mode(mixed_light_spec) -> None:
|
||||
model = compile_light_model(mixed_light_spec)
|
||||
target = Color.from_color_temp(450, brightness=1.0, temp_range=mixed_light_spec.exposed_mireds)
|
||||
result = solve_light(model, target, brightness=200, requested_mode="color_temp")
|
||||
assert result.controller_modes["2"] == "color_temp"
|
||||
assert result.chroma_error < 0.02
|
||||
|
||||
|
||||
def test_solver_clamps_out_of_gamut_without_drift(red_only_spec) -> None:
|
||||
model = compile_light_model(red_only_spec)
|
||||
request = Color.from_rgb((0, 255, 0), brightness=1.0)
|
||||
first = solve_light(model, request, brightness=255, requested_mode="rgb")
|
||||
assert first.exact_chroma_match is False
|
||||
second = solve_light(
|
||||
model,
|
||||
Color.from_rgb(first.achieved_color.rgb, brightness=1.0),
|
||||
brightness=255,
|
||||
requested_mode="rgb",
|
||||
)
|
||||
assert second.achieved_color.uv_distance(first.achieved_color) < 1e-6
|
||||
|
||||
|
||||
def test_low_brightness_extreme_ct_uses_extreme_channel(ct_light_spec) -> None:
|
||||
model = compile_light_model(ct_light_spec)
|
||||
target = Color.from_color_temp(500, brightness=1.0, temp_range=ct_light_spec.exposed_mireds)
|
||||
result = solve_light(model, target, brightness=16, requested_mode="color_temp")
|
||||
assert result.controller_commands["2"].color_temp >= 480
|
||||
assert result.controller_commands["2"].brightness <= 16
|
||||
|
||||
|
||||
def test_rgb_request_uses_only_rgb_labeled_channels_when_available() -> None:
|
||||
spec = VirtualLightSpec(
|
||||
name="mixed_priority",
|
||||
supported_color_modes=("rgb", "color_temp"),
|
||||
controllers={
|
||||
"rgb": ControllerSpec(
|
||||
zigbee_id="rgb",
|
||||
name="RGB",
|
||||
modes={
|
||||
"rgb": ControllerModeSpec(
|
||||
name="rgb",
|
||||
channels={
|
||||
"r": ChannelSpec("r", Color.from_rgb((1.0, 0.0, 0.0), brightness=0.5), "rgb"),
|
||||
"g": ChannelSpec("g", Color.from_rgb((0.0, 1.0, 0.0), brightness=1.0), "rgb"),
|
||||
"b": ChannelSpec("b", Color.from_rgb((0.0, 0.0, 1.0), brightness=0.5), "rgb"),
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
"ct": ControllerSpec(
|
||||
zigbee_id="ct",
|
||||
name="CT",
|
||||
modes={
|
||||
"color_temp": ControllerModeSpec(
|
||||
name="color_temp",
|
||||
channels={
|
||||
"ww": ChannelSpec(
|
||||
"ww",
|
||||
Color.from_color_temp(450, brightness=0.8, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
),
|
||||
"cw": ChannelSpec(
|
||||
"cw",
|
||||
Color.from_color_temp(200, brightness=0.8, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_rgb((128, 255, 128), brightness=1.0)
|
||||
|
||||
low = solve_light(model, target, brightness=96, requested_mode="rgb")
|
||||
assert low.controller_commands["ct"].on is False
|
||||
|
||||
high = solve_light(model, target, brightness=255, requested_mode="rgb")
|
||||
assert high.controller_commands["ct"].on is False
|
||||
assert all(".rgb." in channel for channel in high.channel_levels)
|
||||
|
||||
|
||||
def test_rgb_request_can_use_non_rgb_labels_when_no_rgb_labels_exist() -> None:
|
||||
spec = VirtualLightSpec(
|
||||
name="miswired_rgb",
|
||||
supported_color_modes=("rgb",),
|
||||
controllers={
|
||||
"rgb": ControllerSpec(
|
||||
zigbee_id="rgb",
|
||||
name="Miswired RGB",
|
||||
modes={
|
||||
"rgb": ControllerModeSpec(
|
||||
name="rgb",
|
||||
channels={
|
||||
"r": ChannelSpec(
|
||||
"r",
|
||||
Color.from_color_temp(500, brightness=1.0, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
),
|
||||
"g": ChannelSpec(
|
||||
"g",
|
||||
Color.from_color_temp(300, brightness=1.0, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
),
|
||||
"b": ChannelSpec(
|
||||
"b",
|
||||
Color.from_color_temp(153, brightness=1.0, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
model = compile_light_model(spec)
|
||||
|
||||
result = solve_light(model, Color.from_rgb((255, 255, 255), brightness=1.0), brightness=255, requested_mode="rgb")
|
||||
|
||||
assert result.controller_commands["rgb"].on is True
|
||||
assert result.channel_levels
|
||||
|
||||
|
||||
def test_onoff_booster_is_held_back_until_dimmables_are_exhausted() -> None:
|
||||
spec = VirtualLightSpec(
|
||||
name="boosters",
|
||||
supported_color_modes=("brightness",),
|
||||
controllers={
|
||||
"dim": ControllerSpec(
|
||||
zigbee_id="dim",
|
||||
name="Dim",
|
||||
modes={
|
||||
"brightness": ControllerModeSpec(
|
||||
name="brightness",
|
||||
channels={
|
||||
"w": ChannelSpec(
|
||||
"w",
|
||||
Color.from_color_temp(350, brightness=1.0, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
"plug": ControllerSpec(
|
||||
zigbee_id="plug",
|
||||
name="Plug",
|
||||
modes={
|
||||
"onoff": ControllerModeSpec(
|
||||
name="onoff",
|
||||
channels={
|
||||
"w": ChannelSpec(
|
||||
"w",
|
||||
Color.from_color_temp(350, brightness=0.7, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_color_temp(350, brightness=1.0, temp_range=(153, 500))
|
||||
|
||||
low = solve_light(model, target, brightness=96, requested_mode="brightness")
|
||||
assert low.controller_commands["plug"].on is False
|
||||
|
||||
high = solve_light(model, target, brightness=255, requested_mode="brightness")
|
||||
assert high.controller_commands["plug"].on is True
|
||||
|
||||
|
||||
def test_brightness_mode_compensates_when_onoff_booster_turns_on() -> None:
|
||||
spec = _combine_specs(LIGHTS, ["Example Plug A", "Example Fixed White A"])
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_rgb((255, 255, 255), brightness=1.0)
|
||||
|
||||
before_switch = solve_light(model, target, brightness=120, requested_mode="brightness")
|
||||
assert before_switch.controller_commands["plug_a"].on is False
|
||||
assert before_switch.controller_commands["fixed_a"].on is True
|
||||
|
||||
after_switch = solve_light(model, target, brightness=200, requested_mode="brightness")
|
||||
assert after_switch.controller_commands["plug_a"].on is True
|
||||
assert after_switch.channel_levels["plug_a.onoff.w"] == 1.0
|
||||
assert after_switch.channel_levels["fixed_a.color_temp.ww"] < 1.0
|
||||
|
||||
above_booster = solve_light(model, target, brightness=255, requested_mode="brightness")
|
||||
assert above_booster.controller_commands["plug_a"].on is True
|
||||
assert above_booster.controller_commands["fixed_a"].on is True
|
||||
assert above_booster.channel_levels["fixed_a.color_temp.ww"] == 1.0
|
||||
|
||||
|
||||
def test_ct_request_keeps_native_ct_saturated_while_rgb_boosting() -> None:
|
||||
spec = VirtualLightSpec(
|
||||
name="ct_priority",
|
||||
supported_color_modes=("rgb", "color_temp"),
|
||||
controllers={
|
||||
"rgb": ControllerSpec(
|
||||
zigbee_id="rgb",
|
||||
name="RGB",
|
||||
modes={
|
||||
"rgb": ControllerModeSpec(
|
||||
name="rgb",
|
||||
channels={
|
||||
"r": ChannelSpec("r", Color.from_xy((0.68, 0.31), brightness=180.0), "rgb"),
|
||||
"g": ChannelSpec("g", Color.from_xy((0.18, 0.70), brightness=360.0), "rgb"),
|
||||
"b": ChannelSpec("b", Color.from_xy((0.14, 0.05), brightness=110.0), "rgb"),
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
"ct": ControllerSpec(
|
||||
zigbee_id="ct",
|
||||
name="CT",
|
||||
modes={
|
||||
"color_temp": ControllerModeSpec(
|
||||
name="color_temp",
|
||||
channels={
|
||||
"ww": ChannelSpec(
|
||||
"ww",
|
||||
Color.from_color_temp(500, brightness=340.0, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
),
|
||||
"cw": ChannelSpec(
|
||||
"cw",
|
||||
Color.from_color_temp(153, brightness=360.0, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_color_temp(330, brightness=1.0, temp_range=(153, 500))
|
||||
|
||||
low = solve_light(model, target, brightness=160, requested_mode="color_temp")
|
||||
threshold = solve_light(model, target, brightness=161, requested_mode="color_temp")
|
||||
assert low.controller_commands["rgb"].on is True
|
||||
assert threshold.controller_commands["rgb"].on is True
|
||||
assert low.controller_commands["ct"].brightness == 255
|
||||
assert threshold.controller_commands["ct"].brightness == 255
|
||||
assert abs(low.controller_commands["ct"].color_temp - threshold.controller_commands["ct"].color_temp) <= 1
|
||||
assert abs(low.channel_levels["ct.color_temp.ww"] - threshold.channel_levels["ct.color_temp.ww"]) < 1e-6
|
||||
assert threshold.channel_levels["ct.color_temp.ww"] > 0.99
|
||||
assert threshold.channel_levels["ct.color_temp.cw"] > low.channel_levels["ct.color_temp.cw"]
|
||||
assert low.controller_commands["rgb"].brightness is not None
|
||||
assert threshold.controller_commands["rgb"].brightness is not None
|
||||
assert threshold.controller_commands["rgb"].brightness > low.controller_commands["rgb"].brightness
|
||||
assert threshold.controller_commands["rgb"].brightness < 100
|
||||
|
||||
|
||||
def test_ct_request_does_not_trade_down_warm_extreme_for_tiny_rgb_gain() -> None:
|
||||
spec = VirtualLightSpec(
|
||||
name="ct_warm_priority",
|
||||
supported_color_modes=("rgb", "color_temp"),
|
||||
controllers={
|
||||
"rgb": ControllerSpec(
|
||||
zigbee_id="rgb",
|
||||
name="RGB",
|
||||
modes={
|
||||
"rgb": ControllerModeSpec(
|
||||
name="rgb",
|
||||
channels={
|
||||
"r": ChannelSpec("r", Color.from_xy((0.68, 0.31), brightness=180.0), "rgb"),
|
||||
"g": ChannelSpec("g", Color.from_xy((0.18, 0.70), brightness=360.0), "rgb"),
|
||||
"b": ChannelSpec("b", Color.from_xy((0.14, 0.05), brightness=110.0), "rgb"),
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
"ct": ControllerSpec(
|
||||
zigbee_id="ct",
|
||||
name="CT",
|
||||
modes={
|
||||
"color_temp": ControllerModeSpec(
|
||||
name="color_temp",
|
||||
channels={
|
||||
"ww": ChannelSpec(
|
||||
"ww",
|
||||
Color.from_color_temp(500, brightness=340.0, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
),
|
||||
"cw": ChannelSpec(
|
||||
"cw",
|
||||
Color.from_color_temp(153, brightness=360.0, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_color_temp(505, brightness=1.0, temp_range=(153, 500))
|
||||
|
||||
low = solve_light(model, target, brightness=126, requested_mode="color_temp")
|
||||
assert low.controller_commands["rgb"].on is True
|
||||
assert low.channel_levels["ct.color_temp.ww"] > 0.9
|
||||
|
||||
near_threshold = solve_light(model, target, brightness=127, requested_mode="color_temp")
|
||||
assert near_threshold.controller_commands["rgb"].on is True
|
||||
assert near_threshold.channel_levels["ct.color_temp.ww"] >= low.channel_levels["ct.color_temp.ww"]
|
||||
assert near_threshold.controller_commands["rgb"].brightness is not None
|
||||
assert low.controller_commands["rgb"].brightness is not None
|
||||
assert near_threshold.controller_commands["rgb"].brightness >= low.controller_commands["rgb"].brightness
|
||||
assert near_threshold.controller_commands["ct"].brightness is not None
|
||||
assert near_threshold.controller_commands["ct"].brightness >= 230
|
||||
|
||||
|
||||
def test_ct_line_balancing_uses_comparable_same_label_channels_stably() -> None:
|
||||
spec = VirtualLightSpec(
|
||||
name="ct_stability",
|
||||
supported_color_modes=("color_temp",),
|
||||
controllers={
|
||||
"near": ControllerSpec(
|
||||
zigbee_id="near",
|
||||
name="Near CW",
|
||||
modes={
|
||||
"color_temp": ControllerModeSpec(
|
||||
name="color_temp",
|
||||
channels={
|
||||
"cw": ChannelSpec(
|
||||
"cw",
|
||||
Color.from_color_temp(190, brightness=0.8, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
"far": ControllerSpec(
|
||||
zigbee_id="far",
|
||||
name="Far CW",
|
||||
modes={
|
||||
"color_temp": ControllerModeSpec(
|
||||
name="color_temp",
|
||||
channels={
|
||||
"cw": ChannelSpec(
|
||||
"cw",
|
||||
Color.from_color_temp(153, brightness=1.0, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
"warm": ControllerSpec(
|
||||
zigbee_id="warm",
|
||||
name="Warm",
|
||||
modes={
|
||||
"color_temp": ControllerModeSpec(
|
||||
name="color_temp",
|
||||
channels={
|
||||
"ww": ChannelSpec(
|
||||
"ww",
|
||||
Color.from_color_temp(500, brightness=1.0, temp_range=(153, 500)),
|
||||
"color_temp",
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_color_temp(330, brightness=1.0, temp_range=(153, 500))
|
||||
|
||||
for brightness in range(96, 181, 7):
|
||||
result = solve_light(model, target, brightness=brightness, requested_mode="color_temp")
|
||||
assert result.channel_levels.get("near.color_temp.cw", 0.0) > 0.0
|
||||
assert result.channel_levels.get("far.color_temp.cw", 0.0) > 0.0
|
||||
assert abs(result.achieved_color.color_temp - 330) <= 2
|
||||
|
||||
|
||||
def test_rgb_request_does_not_use_white_boost_when_rgb_labeled_channels_exist() -> None:
|
||||
spec = _combine_specs(LIGHTS, ["Example Fixed White A", "Example Mixed North"])
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_rgb((255, 255, 255), brightness=1.0)
|
||||
|
||||
result = solve_light(model, target, brightness=103, requested_mode="rgb")
|
||||
assert all(".rgb." in channel for channel in result.channel_levels)
|
||||
assert result.controller_commands["fixed_a"].on is False
|
||||
assert result.controller_commands["mixed_ct"].on is False
|
||||
|
||||
|
||||
def test_example_fixed_white_controller_uses_full_ct_command_range() -> None:
|
||||
controller = LIGHTS["Example Fixed White A"].controllers["fixed_a"]
|
||||
assert controller.native_ct_mireds == (153, 500)
|
||||
|
||||
|
||||
def test_example_standard_ct_controllers_use_configured_ct_command_range() -> None:
|
||||
for light_name in ["Example Mixed North", "Example Hall Floor", "Example Hall Corner", "Example Hall Cabinets"]:
|
||||
for controller in LIGHTS[light_name].controllers.values():
|
||||
if "color_temp" in controller.modes:
|
||||
assert controller.native_ct_mireds == (153, 500)
|
||||
|
||||
|
||||
def test_ct_request_uses_native_ct_mode_on_rgb_cct_controller() -> None:
|
||||
spec = LIGHTS["Example Kitchen Counter"]
|
||||
result = solve_light(
|
||||
compile_light_model(spec),
|
||||
Color.from_color_temp(455, brightness=1.0),
|
||||
brightness=51,
|
||||
requested_mode="color_temp",
|
||||
)
|
||||
|
||||
assert result.controller_modes == {"kitchen_counter": "color_temp"}
|
||||
assert result.controller_commands["kitchen_counter"].mode == "color_temp"
|
||||
peak = max(result.channel_levels["kitchen_counter.color_temp.cw"], result.channel_levels["kitchen_counter.color_temp.ww"])
|
||||
assert result.controller_commands["kitchen_counter"].color_temp == ct_from_mix(
|
||||
result.channel_levels["kitchen_counter.color_temp.cw"] / peak,
|
||||
result.channel_levels["kitchen_counter.color_temp.ww"] / peak,
|
||||
spec.controllers["kitchen_counter"].native_ct_mireds,
|
||||
)
|
||||
|
||||
|
||||
def test_ct_slack_prefers_near_ct_over_exact_rgb_when_within_band() -> None:
|
||||
target = Color.from_color_temp(330, brightness=1.0, temp_range=(153, 500))
|
||||
spec = VirtualLightSpec(
|
||||
name="ct_slack",
|
||||
supported_color_modes=("rgb", "color_temp"),
|
||||
controllers={
|
||||
"shared": ControllerSpec(
|
||||
zigbee_id="shared",
|
||||
name="Shared",
|
||||
mireds=(153, 500),
|
||||
modes={
|
||||
"rgb": ControllerModeSpec(
|
||||
name="rgb",
|
||||
channels={"r": ChannelSpec("r", target, "rgb")},
|
||||
),
|
||||
"color_temp": ControllerModeSpec(
|
||||
name="color_temp",
|
||||
channels={
|
||||
"ww": ChannelSpec(
|
||||
"ww",
|
||||
Color.from_xy((target.xy[0] + 0.002, target.xy[1]), brightness=5.0),
|
||||
"color_temp",
|
||||
),
|
||||
"cw": ChannelSpec(
|
||||
"cw",
|
||||
Color.from_xy((target.xy[0] - 0.002, target.xy[1] + 0.001), brightness=5.0),
|
||||
"color_temp",
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
result = solve_light(
|
||||
compile_light_model(spec, approximate_chroma_band=0.01),
|
||||
target,
|
||||
brightness=255,
|
||||
requested_mode="color_temp",
|
||||
)
|
||||
|
||||
assert result.controller_modes == {"shared": "color_temp"}
|
||||
assert result.exact_chroma_match is False
|
||||
assert result.chroma_error <= 0.01
|
||||
|
||||
|
||||
def test_ct_slack_does_not_use_near_ct_when_outside_band() -> None:
|
||||
target = Color.from_color_temp(330, brightness=1.0, temp_range=(153, 500))
|
||||
spec = VirtualLightSpec(
|
||||
name="ct_slack_strict",
|
||||
supported_color_modes=("rgb", "color_temp"),
|
||||
controllers={
|
||||
"shared": ControllerSpec(
|
||||
zigbee_id="shared",
|
||||
name="Shared",
|
||||
mireds=(153, 500),
|
||||
modes={
|
||||
"rgb": ControllerModeSpec(
|
||||
name="rgb",
|
||||
channels={"r": ChannelSpec("r", target, "rgb")},
|
||||
),
|
||||
"color_temp": ControllerModeSpec(
|
||||
name="color_temp",
|
||||
channels={
|
||||
"ww": ChannelSpec(
|
||||
"ww",
|
||||
Color.from_xy((target.xy[0] + 0.002, target.xy[1]), brightness=5.0),
|
||||
"color_temp",
|
||||
),
|
||||
"cw": ChannelSpec(
|
||||
"cw",
|
||||
Color.from_xy((target.xy[0] - 0.002, target.xy[1] + 0.001), brightness=5.0),
|
||||
"color_temp",
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
result = solve_light(
|
||||
compile_light_model(spec, approximate_chroma_band=0.0001),
|
||||
target,
|
||||
brightness=255,
|
||||
requested_mode="color_temp",
|
||||
)
|
||||
|
||||
assert result.controller_modes == {"shared": "rgb"}
|
||||
assert result.exact_chroma_match is True
|
||||
|
||||
|
||||
def test_wc_ct_sweep_stays_near_requested_ct_line_through_middle_range() -> None:
|
||||
spec = _group_debug_spec(GROUPS["Example Bathroom"], LIGHTS)
|
||||
model = compile_light_model(spec)
|
||||
|
||||
for mireds in [220, 250, 286, 320, 360]:
|
||||
result = solve_light(
|
||||
model,
|
||||
Color.from_color_temp(mireds, brightness=1.0, temp_range=spec.exposed_mireds),
|
||||
brightness=255,
|
||||
requested_mode="color_temp",
|
||||
)
|
||||
|
||||
assert result.chroma_error <= model.approximate_chroma_band
|
||||
assert abs(result.achieved_color.color_temp - mireds) <= 2
|
||||
assert any(".color_temp." in address for address in result.channel_levels)
|
||||
|
||||
|
||||
def test_wc_low_brightness_ct_can_use_rgb_correction_without_redefining_reference() -> None:
|
||||
spec = _group_debug_spec(GROUPS["Example Bathroom"], LIGHTS)
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_color_temp(286, brightness=1.0, temp_range=spec.exposed_mireds)
|
||||
|
||||
low = solve_light(model, target, brightness=50, requested_mode="color_temp")
|
||||
high = solve_light(model, target, brightness=255, requested_mode="color_temp")
|
||||
|
||||
assert low.reference_color.brightness == high.reference_color.brightness
|
||||
assert any(".rgb." in address for address in low.channel_levels)
|
||||
assert low.chroma_error < high.chroma_error
|
||||
assert abs(high.achieved_color.color_temp - 286) <= 2
|
||||
|
||||
|
||||
def test_wc_katto_low_brightness_ct_can_sacrifice_one_bulb_for_rgb_correction() -> None:
|
||||
spec = LIGHTS["Example Bathroom Ceiling"]
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_color_temp(286, brightness=1.0, temp_range=spec.exposed_mireds)
|
||||
|
||||
low = solve_light(model, target, brightness=50, requested_mode="color_temp")
|
||||
exact_edge = solve_light(model, target, brightness=83, requested_mode="color_temp")
|
||||
best_effort = solve_light(model, target, brightness=105, requested_mode="color_temp")
|
||||
high = solve_light(model, target, brightness=255, requested_mode="color_temp")
|
||||
|
||||
assert any(mode == "rgb" for mode in low.controller_modes.values())
|
||||
assert any(mode == "rgb" for mode in exact_edge.controller_modes.values())
|
||||
assert any(mode == "rgb" for mode in best_effort.controller_modes.values())
|
||||
assert all(mode == "color_temp" for mode in high.controller_modes.values())
|
||||
assert exact_edge.chroma_error <= model.chroma_tolerance
|
||||
assert best_effort.chroma_error < high.chroma_error
|
||||
assert low.chroma_error < high.chroma_error
|
||||
assert abs(high.achieved_color.color_temp - 286) <= 2
|
||||
|
||||
|
||||
def test_wc_katto_balances_equivalent_channels() -> None:
|
||||
spec = LIGHTS["Example Bathroom Ceiling"]
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_color_temp(300, brightness=1.0, temp_range=spec.exposed_mireds)
|
||||
|
||||
rgb_correction = solve_light(model, target, brightness=90, requested_mode="color_temp")
|
||||
assert rgb_correction.controller_modes["bath_ceiling_1"] == "rgb"
|
||||
assert rgb_correction.controller_modes["bath_ceiling_2"] == "rgb"
|
||||
assert abs(rgb_correction.channel_levels["bath_ceiling_1.rgb.r"] - rgb_correction.channel_levels["bath_ceiling_2.rgb.r"]) < 1e-6
|
||||
assert abs(rgb_correction.channel_levels["bath_ceiling_1.rgb.g"] - rgb_correction.channel_levels["bath_ceiling_2.rgb.g"]) < 1e-6
|
||||
|
||||
ct_correction = solve_light(model, target, brightness=99, requested_mode="color_temp")
|
||||
assert ct_correction.controller_modes["bath_ceiling_2"] == "color_temp"
|
||||
assert ct_correction.controller_modes["bath_ceiling_3"] == "color_temp"
|
||||
assert abs(ct_correction.channel_levels["bath_ceiling_2.color_temp.ww"] - ct_correction.channel_levels["bath_ceiling_3.color_temp.ww"]) < 1e-6
|
||||
assert abs(ct_correction.channel_levels["bath_ceiling_2.color_temp.cw"] - ct_correction.channel_levels["bath_ceiling_3.color_temp.cw"]) < 1e-6
|
||||
|
||||
|
||||
def test_example_all_lights_does_not_branch_on_dimmable_off_states() -> None:
|
||||
spec = _combine_specs(
|
||||
LIGHTS,
|
||||
[
|
||||
"Example Fixed White A",
|
||||
"Example Fixed White B",
|
||||
"Example Mixed North",
|
||||
"Example Plug A",
|
||||
"Example Plug B",
|
||||
"Example Hall Floor",
|
||||
"Example Hall Corner",
|
||||
"Example Hall Cabinets",
|
||||
],
|
||||
)
|
||||
model = compile_light_model(spec)
|
||||
assert len(model.combinations) == 32
|
||||
|
||||
|
||||
def test_example_all_lights_deduplicates_reference_combinations() -> None:
|
||||
spec = _combine_specs(
|
||||
LIGHTS,
|
||||
[
|
||||
"Example Fixed White A",
|
||||
"Example Fixed White B",
|
||||
"Example Mixed North",
|
||||
"Example Plug A",
|
||||
"Example Plug B",
|
||||
"Example Hall Floor",
|
||||
"Example Hall Corner",
|
||||
"Example Hall Cabinets",
|
||||
],
|
||||
)
|
||||
model = compile_light_model(spec)
|
||||
assert len(model.reference_combinations) < len(model.combinations)
|
||||
|
||||
|
||||
def test_example_all_lights_ct_sweep_does_not_use_unused_onoff_reference() -> None:
|
||||
spec = _combine_specs(
|
||||
LIGHTS,
|
||||
[
|
||||
"Example Fixed White A",
|
||||
"Example Fixed White B",
|
||||
"Example Mixed North",
|
||||
"Example Plug A",
|
||||
"Example Plug B",
|
||||
"Example Hall Floor",
|
||||
"Example Hall Corner",
|
||||
"Example Hall Cabinets",
|
||||
],
|
||||
)
|
||||
model = compile_light_model(spec)
|
||||
previous: dict[str, float] | None = None
|
||||
max_delta = 0.0
|
||||
|
||||
for mireds in range(300, 321):
|
||||
result = solve_light(
|
||||
model,
|
||||
Color.from_color_temp(mireds, brightness=1.0),
|
||||
brightness=181,
|
||||
requested_mode="color_temp",
|
||||
)
|
||||
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
|
||||
levels = {
|
||||
channel: level
|
||||
for channel, level in result.channel_levels.items()
|
||||
if level > 1e-6
|
||||
}
|
||||
if previous is not None:
|
||||
max_delta = max(
|
||||
max_delta,
|
||||
max(abs(levels.get(channel, 0.0) - previous.get(channel, 0.0)) for channel in set(levels) | set(previous)),
|
||||
)
|
||||
previous = levels
|
||||
|
||||
assert max_delta < 0.9
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from ha_deconz_bridge.backends.deconz import DeconzBackend
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
|
||||
|
||||
|
||||
def test_virtual_light_rejects_color_temp_without_native_ct_emitters() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
VirtualLightSpec(
|
||||
name="bad",
|
||||
supported_color_modes=("color_temp",),
|
||||
controllers={
|
||||
"1": ControllerSpec(
|
||||
zigbee_id="1",
|
||||
name="RGB only",
|
||||
modes={
|
||||
"rgb": ControllerModeSpec(
|
||||
name="rgb",
|
||||
channels={
|
||||
"r": ChannelSpec("r", Color.from_rgb((1.0, 0.0, 0.0))),
|
||||
"g": ChannelSpec("g", Color.from_rgb((0.0, 1.0, 0.0))),
|
||||
"b": ChannelSpec("b", Color.from_rgb((0.0, 0.0, 1.0))),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_controller_mode_rejects_invalid_role() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
ControllerModeSpec(
|
||||
name="rgb",
|
||||
channels={"ww": ChannelSpec("ww", Color.from_rgb((1.0, 1.0, 1.0)))},
|
||||
)
|
||||
|
||||
|
||||
def test_deconz_invalid_ct_range_is_rejected() -> None:
|
||||
resource = {
|
||||
"state": {"on": True, "reachable": True, "bri": 254, "ct": 400, "colormode": "ct"},
|
||||
"ctmin": 65279,
|
||||
"ctmax": 0,
|
||||
}
|
||||
assert DeconzBackend.extract_mireds(resource) is None
|
||||
|
||||
Reference in New Issue
Block a user