Initial public release

This commit is contained in:
ajp_anton
2026-05-30 15:44:40 +00:00
commit cc4e0c2c0f
56 changed files with 11840 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.venv/
.pytest_cache/
__pycache__/
*.pyc
*.egg-info/
old attempt/
configs/local/
scripts/deploy.py
*.env
*.env.local
*.local
*.secret
+67
View File
@@ -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.
+9
View File
@@ -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()
+70
View File
@@ -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)
+130
View File
@@ -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}.")
+42
View File
@@ -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",
),
}
+251
View File
@@ -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},
},
},
},
},
}
)
+17
View File
@@ -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",
),
}
+14
View File
@@ -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
+19
View File
@@ -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
+57
View File
@@ -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)),
},
)
},
)
},
),
}
+28
View File
@@ -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
+63
View File
@@ -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.
+223
View File
@@ -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.
+79
View File
@@ -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
```
+109
View File
@@ -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.
+14
View File
@@ -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.
+80
View File
@@ -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.
+40
View File
@@ -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"]
+20
View File
@@ -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",
]
+419
View File
@@ -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())
+240
View File
@@ -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"]
+24
View File
@@ -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:
...
+150
View File
@@ -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")
+49
View File
@@ -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)
+367
View File
@@ -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})"
+179
View File
@@ -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
+47
View File
@@ -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)
+403
View File
@@ -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))
+240
View File
@@ -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))
+114
View File
@@ -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))
+270
View File
@@ -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]
+166
View File
@@ -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)
+85
View File
@@ -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,
)
+135
View File
@@ -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
+223
View File
@@ -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)
+547
View File
@@ -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
+101
View File
@@ -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
+267
View File
@@ -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
+41
View File
@@ -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
+146
View File
@@ -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)),
},
)
},
)
},
)
+186
View File
@@ -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,)),
]
+44
View File
@@ -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
+87
View File
@@ -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} == {}
+125
View File
@@ -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",
]
+89
View File
@@ -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
+43
View File
@@ -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"
+688
View File
@@ -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
+49
View File
@@ -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