Initial public release
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# Architecture
|
||||
|
||||
## Core Layers
|
||||
|
||||
- `ha_deconz_bridge.color`: shared color model and conversions
|
||||
- `ha_deconz_bridge.lights`: typed light definitions and validation
|
||||
- `ha_deconz_bridge.solver_model`: compiled light-model and exclusivity combinations
|
||||
- `ha_deconz_bridge.solver_numeric`: shared numeric color-solve helpers
|
||||
- `ha_deconz_bridge.solver`: solve orchestration and deterministic elimination policy
|
||||
- `ha_deconz_bridge.translate`: controller and virtual-light translation helpers
|
||||
|
||||
## Runtime Layers
|
||||
|
||||
- `ha_deconz_bridge.backends.base`: backend protocol
|
||||
- `ha_deconz_bridge.backends.deconz`: deCONZ HTTP and websocket normalization
|
||||
- `ha_deconz_bridge.automation`: remote specs, gesture detection, and automation rule matching
|
||||
- `ha_deconz_bridge.controller`: one physical controller runtime
|
||||
- `ha_deconz_bridge.group_light`: exposed groups that translate commands into member light commands
|
||||
- `ha_deconz_bridge.groups`: group specs, member restrictions, and capability derivation
|
||||
- `ha_deconz_bridge.light`: one virtual Home Assistant light
|
||||
- `ha_deconz_bridge.ha`: MQTT discovery, commands, and state publishing
|
||||
- `ha_deconz_bridge.queueing`: HA command coalescing and event prioritization
|
||||
- `ha_deconz_bridge.app`: service wiring
|
||||
|
||||
## Debug Layer
|
||||
|
||||
- `ha_deconz_bridge.debugger.mpl_debugger` is an offline simulator.
|
||||
- It uses the same `Color`, solver, and translation code as runtime.
|
||||
- It does not have a separate solver branch.
|
||||
|
||||
## Solver Shape
|
||||
|
||||
- Each exposed virtual light or debugger selection is compiled once into a light model.
|
||||
- Only exclusivity groups create solver branches; independent control paths may be active together.
|
||||
- `solve_light()` treats all emitters uniformly in one shared color space.
|
||||
- Semantic labels like `rgb` and `color_temp` are used only during policy stages, not in the mixing math itself.
|
||||
|
||||
## Event Flow
|
||||
|
||||
1. Home Assistant command arrives over MQTT.
|
||||
2. The command is normalized into a typed HA event.
|
||||
3. The event queue coalesces pending commands for the same virtual light. Sensor events run first, HA commands run before pending light readback events, and deCONZ light readbacks are deduplicated by controller.
|
||||
4. Individual `VirtualLight` entities merge the command with their last stable state and ask the solver for controller targets.
|
||||
5. Group entities translate the group command into member `VirtualLight` commands. A group member may have a fixed RGB or CT restriction that applies only when that group is controlled.
|
||||
6. Each `PhysicalController` writes the solver's physical command to deCONZ. HA-driven writes trust a successful HTTP response and keep the accepted logical report state while near-term stale readbacks arrive.
|
||||
7. `VirtualLight` and group entities aggregate accepted-or-physical output state and publish it back to Home Assistant.
|
||||
8. Unsolicited deCONZ events update hardware truth directly when they are not near-term stale readbacks from a just-written command.
|
||||
|
||||
## Remote Event Flow
|
||||
|
||||
1. deCONZ sends a websocket sensor event for a configured remote.
|
||||
2. The raw `buttonevent` is normalized into a button and gesture.
|
||||
3. Double-press detection is tracked per physical sensor ID.
|
||||
4. Automation rules match the remote's internal `remote_id`, button, gesture, and optional time condition.
|
||||
5. Actions execute through the same virtual-light command path as Home Assistant.
|
||||
|
||||
## Shared Controllers
|
||||
|
||||
- Runtime owns exactly one `PhysicalController` object per physical deCONZ controller ID.
|
||||
- Individual virtual lights reference those shared controller objects. Configured groups reference individual virtual lights, not controllers directly.
|
||||
- `ha_deconz_bridge.app` maintains a controller-to-virtual-lights index.
|
||||
- A deCONZ event updates the shared controller once, then every virtual light that includes that controller recomputes and republishes state. Groups containing any changed member recompute after those member updates.
|
||||
- This allows overlapping exposed entities, such as one individual light and one room/group light, to stay consistent after external physical changes.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Configuration
|
||||
|
||||
Configuration is Python, but typed and validated.
|
||||
|
||||
## Expected Module Exports
|
||||
|
||||
A runtime config module should export:
|
||||
|
||||
- `SETTINGS`: `ha_deconz_bridge.settings.Settings`
|
||||
- `LIGHTS`: `dict[str, ha_deconz_bridge.lights.VirtualLightSpec]`
|
||||
- `REMOTES`: optional `dict[str, ha_deconz_bridge.automation.RemoteSpec]`
|
||||
- `AUTOMATIONS`: optional `tuple[ha_deconz_bridge.automation.AutomationRule, ...]`
|
||||
|
||||
## Example
|
||||
|
||||
```python
|
||||
from configs.example_controllers import define_lights
|
||||
|
||||
LIGHTS = define_lights({
|
||||
"living_room": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"7": {
|
||||
"name": "North RGB+CCT",
|
||||
"mireds": (153, 500),
|
||||
"rgb": {
|
||||
"r": {"color": (0.6949, 0.3051), "brightness": 202},
|
||||
"g": {"color": (0.1414, 0.7181), "brightness": 479},
|
||||
"b": {"color": (0.1443, 0.0427), "brightness": 124},
|
||||
},
|
||||
"color_temp": {
|
||||
"ww": {"color": 417, "brightness": 520},
|
||||
"cw": {"color": 154, "brightness": 576},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The controller ID (`"7"`), physical mode names (`"rgb"` / `"color_temp"`), and channel roles (`"r"`, `"g"`, `"b"`, `"ww"`, `"cw"`) are taken from dictionary keys. `color` accepts:
|
||||
|
||||
- a single number as CT, using mireds below `1000` and Kelvin at or above `1000`
|
||||
- an `(x, y)` tuple
|
||||
- an RGB tuple, either `0..1` floats or `0..255` integers
|
||||
|
||||
`label` is optional. If omitted, channels inside physical `rgb` mode default to label `rgb`, and channels inside physical `color_temp` mode default to label `color_temp`. Use `label` only for unusual wiring, such as an RGB controller output connected to white LEDs.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- Supported virtual modes must be one of:
|
||||
- `("onoff",)`
|
||||
- `("brightness",)`
|
||||
- any non-empty subset of `("rgb", "color_temp")`
|
||||
- Controller mode roles must match the controller mode:
|
||||
- `rgb`: `r`, `g`, `b`
|
||||
- `color_temp`: `ww`, `cw`
|
||||
- `brightness`: `w`
|
||||
- `onoff`: `w`
|
||||
- Every channel defines the emitted physical color and max output. The role only describes how the backend controller address is written.
|
||||
- `ChannelSpec.semantic_label` is optional and may be:
|
||||
- `rgb`
|
||||
- `color_temp`
|
||||
- `boost`
|
||||
- If omitted, `rgb` and `color_temp` channels default to their mode name; `brightness` and `onoff` channels default to no label.
|
||||
- Solver priority is staged:
|
||||
- dimmable same-label channels first
|
||||
- then other dimmable channels
|
||||
- then `boost`/unlabeled dimmable channels
|
||||
- then same-label on/off channels
|
||||
- then other on/off channels
|
||||
- then `boost`/unlabeled on/off channels
|
||||
- Controllers may define multiple mutually exclusive modes, and the solver is free to choose one mode or turn that controller fully off.
|
||||
- A virtual light that exposes `color_temp` must have native CT-capable emitters.
|
||||
- A controller that claims a mode unsupported by the real device fails startup validation.
|
||||
|
||||
## Solver Responsiveness
|
||||
|
||||
`Settings.target_cache_scale` controls how precisely requested chromaticity is keyed in the solver cache. The default is `10000`, which keeps adjacent RGB/CT slider positions distinct in normal use. Lower values make nearby requests reuse the same reference solve, but can create visible color dead zones where a small slider movement does not change the achieved color. Treat lower values as an explicit responsiveness experiment, not the default behavior.
|
||||
|
||||
`Settings.approximate_chroma_band` controls how far a CT-line solution may sit from the ideal blackbody point while still being accepted. The default is `0.025` in uv distance. Lower values make CT requests more color-accurate but may choose RGB compensation more often; higher values prefer broad-spectrum CT output more aggressively.
|
||||
|
||||
`HA_DECONZ_BRIDGE_NUMERIC_BACKEND` controls the numeric backend used by the solver:
|
||||
|
||||
- `scipy`: default, current stable backend.
|
||||
- `numba`: try the Numba active-set backend for supported LP solves, with SciPy fallback when needed.
|
||||
- `auto`: same as `numba` when Numba is importable, otherwise SciPy.
|
||||
|
||||
Numba is an optional dependency. Install it with the `speed` extra before selecting `numba` or `auto`.
|
||||
|
||||
`HA_DECONZ_BRIDGE_WARMUP_ON_STARTUP` defaults to `true`. When enabled, the service and debugger start a background warmup that runs one representative solve per supported HA mode. This compiles Numba-backed paths without blocking MQTT or the debugger HTTP port.
|
||||
|
||||
## Config File Layout
|
||||
|
||||
The public example entry point is `configs/example.py`. It imports focused files:
|
||||
|
||||
- `configs/example_controllers.py`: physical controller/channel helper functions and constants.
|
||||
- `configs/example_lights.py`: individual virtual lights and which controllers they abstract.
|
||||
- `configs/example_groups.py`: room/group membership, optional member restrictions, and group balancing policy.
|
||||
- `configs/example_remotes.py`: physical remote sensor IDs mapped to internal `remote_id` values.
|
||||
- `configs/example_actions.py`: scenes, action helpers, and remote automation mappings.
|
||||
|
||||
Real home configs should stay in ignored local files such as `configs/local/`. The entry point should stay small and only assemble `SETTINGS`, `LIGHTS`, `GROUPS`, `REMOTES`, and `AUTOMATIONS`.
|
||||
|
||||
For a private local setup, copy the public example into the ignored local directory:
|
||||
|
||||
```bash
|
||||
mkdir -p configs/local
|
||||
cp configs/example.py configs/local/lights_config.py
|
||||
cp configs/example_*.py configs/local/
|
||||
```
|
||||
|
||||
Then edit the copied files. If you keep the split-file imports, update them from `configs.example_*` to `configs.local.example_*`, or rename the copied split files and import those names instead. Run with:
|
||||
|
||||
```bash
|
||||
ha-deconz-bridge-service configs/local/lights_config.py
|
||||
ha-deconz-bridge-debug-web configs/local/lights_config.py "Example Mixed North"
|
||||
```
|
||||
|
||||
The runtime accepts either a Python module name or a `.py` file path.
|
||||
|
||||
Groups are defined with `GroupSpec`. A member can be listed by name, or wrapped in `GroupMemberSpec` to restrict how that member behaves only inside that group:
|
||||
|
||||
```python
|
||||
from ha_deconz_bridge.groups import GroupMemberSpec, GroupSpec
|
||||
|
||||
GROUPS = {
|
||||
"Example Plant": GroupSpec(
|
||||
name="Example Plant",
|
||||
members=(
|
||||
"Example Fixed White A",
|
||||
"Example Fixed White B",
|
||||
GroupMemberSpec("Example Mixed North", fixed_color=182),
|
||||
),
|
||||
balance="relative_member",
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
By default, a group derives its exposed modes from the union of its members. If any member supports `rgb` or `color_temp`, the group exposes those color modes. If no member supports color but at least one member supports dimming, the group exposes `brightness`. If no member is dimmable, the group is `onoff`. `supported_color_modes` is still available as an explicit override.
|
||||
|
||||
`fixed_color` makes that member act like a dimmable fixed-color contributor only inside that group. It accepts a single number as color temperature (`<1000` is mireds, `>=1000` is kelvin), an RGB triple, or an xy pair. RGB input is normalized, so `(1.0, 1.0, 1.0)` and `(255, 255, 255)` describe the same color.
|
||||
|
||||
`fixed_brightness` requires `fixed_color` and makes that member act like an on/off fixed-output contributor inside the group. Values up to `1.0` are treated as relative brightness; larger values are treated as HA `0..255` brightness. On readback, the member counts as on when it reports at least 90% of that fixed output. Direct control of the member light is unchanged.
|
||||
|
||||
`balance="relative_member"` sends the same relative brightness to each member. The default `passthrough` sends the group command to every compatible member without trying to equalize member brightness.
|
||||
|
||||
## Remote Automation
|
||||
|
||||
Physical remotes are configured by deCONZ sensor ID, but automation rules bind to an internal `remote_id`.
|
||||
|
||||
```python
|
||||
from ha_deconz_bridge.automation import ActionSpec, RemoteSpec, automation_rules
|
||||
|
||||
REMOTES = {
|
||||
"example_sensor_1": RemoteSpec(
|
||||
sensor_id="example_sensor_1",
|
||||
remote_id="example_living",
|
||||
name="Example living remote",
|
||||
model="RWL021",
|
||||
double_press_window=0.45, # optional override
|
||||
),
|
||||
}
|
||||
|
||||
AUTOMATION_CONFIG = {
|
||||
"example_living": {
|
||||
"top": {
|
||||
"single": (ActionSpec("set_scene", target="Example Living", brightness=180, color_temp=330),),
|
||||
},
|
||||
"bottom": {
|
||||
"single": (ActionSpec("turn_off", target="Example Living"),),
|
||||
},
|
||||
"up": {
|
||||
"hold": (ActionSpec("brightness_step", target="Example Living", brightness_step=16),),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
AUTOMATIONS = automation_rules(AUTOMATION_CONFIG)
|
||||
```
|
||||
|
||||
`RemoteSpec.model` is informational in the current implementation. It documents the remote type and gives us a place to add model-specific validation or decoding later. The current Hue dimmer mapping is for `RWL021`.
|
||||
|
||||
Supported base gestures are `press`, `single`, `double`, `long`, `double_long`, `hold`, and `double_hold`.
|
||||
|
||||
Hold-count gesture patterns are also supported:
|
||||
|
||||
- `hold_x`: trigger only on the x'th hold event.
|
||||
- `double_hold_x`: trigger only on the x'th hold event of a double-hold.
|
||||
- `hold_x_y`: trigger from x through y, inclusive.
|
||||
- `double_hold_x_y`: same for double-hold.
|
||||
- If `y < x`, the trigger starts at x and continues until release. The recommended spelling for an unbounded hold is `hold_x_0` or `double_hold_x_0`.
|
||||
- If `y == x`, the trigger fires only on the x'th hold event.
|
||||
|
||||
Supported action kinds are:
|
||||
|
||||
- `turn_on`
|
||||
- `turn_off`
|
||||
- `set_scene`
|
||||
- `brightness_step`
|
||||
- `color_temp_step`
|
||||
|
||||
Action targets may be exact virtual light names, group names, or group expressions:
|
||||
|
||||
- `A+B+C`: union of groups/lights A, B, and C.
|
||||
- `A-B`: all members of A except members of B.
|
||||
- `A+B-C+D`: parsed left-to-right.
|
||||
- `All`: all individual virtual lights.
|
||||
- Empty string: same as `All`.
|
||||
- `-A` or `All-A`: all individual virtual lights except A.
|
||||
|
||||
If the target exactly matches an exposed virtual light or group and contains no `+` or `-`, the action is sent to that virtual entity directly. Compound expressions expand to individual virtual lights.
|
||||
|
||||
## Example Groups
|
||||
|
||||
`configs/example.py` exposes sanitized example groups in addition to individual lights:
|
||||
|
||||
- `Example Hall`: three RGB+CCT virtual lights.
|
||||
- `Example Kitchen`: one RGB+CCT virtual light.
|
||||
- `Example Bathroom`: one multi-controller ceiling light and one mirror light.
|
||||
- `Example Bedroom`: two RGB+CCT virtual lights.
|
||||
- `Example Living`: fixed-white, mixed RGB/CCT, and on/off examples.
|
||||
- `Example Plant`: fixed-white examples plus one constrained mixed light, using relative member brightness balancing.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Debugger
|
||||
|
||||
The debugger is a browser UI around the real core library. It can run in two modes:
|
||||
|
||||
- `virtual`: offline solver only
|
||||
- `live`: solver plus direct deCONZ reads/writes for the selected lights
|
||||
|
||||
## Goals
|
||||
|
||||
- use the exact same solver and translation code as runtime
|
||||
- visualize requested versus achieved color
|
||||
- show per-controller and per-channel activation
|
||||
- inspect timing and chosen controller modes
|
||||
|
||||
## Current Shape
|
||||
|
||||
- `ha-deconz-bridge-debug`: the original local Matplotlib tool
|
||||
- `ha-deconz-bridge-debug-web`: a browser-accessible debugger for remote development on the VM
|
||||
|
||||
Both variants:
|
||||
|
||||
- load a config module and one or more initial virtual lights
|
||||
- use the shared solver and color/translation code
|
||||
- let the user drive RGB or color temperature plus brightness
|
||||
- show achieved color, per-channel activation, controller commands, and solver metadata
|
||||
|
||||
## Web Debugger
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m ha_deconz_bridge.debugger.web_debugger ./configs/example.py "Example Mixed North" --host 127.0.0.1 --port 8765
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:8765` in a browser. If the debugger runs on the VM, tunnel the port over SSH if needed.
|
||||
|
||||
For real hardware, pass your ignored local config instead:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m ha_deconz_bridge.debugger.web_debugger ./configs/local/lights_config.py "Your Light Name" --host 127.0.0.1 --port 8765
|
||||
```
|
||||
|
||||
Inside the UI:
|
||||
|
||||
- `virtual` mode only runs the solver against the selected light set
|
||||
- `live` mode polls deCONZ for the selected lights and only writes to the backend when you move the sliders
|
||||
- changing the selected lights or switching between `rgb` and `color_temp` does not write anything to the backend
|
||||
- in `live` mode, external changes made through Phoscon or elsewhere are pulled back into the debugger on the next poll
|
||||
|
||||
- all configured lights are listed with checkboxes
|
||||
- the selected lights are merged into one temporary solver target
|
||||
- changing either the sliders or the light selection re-runs the solver
|
||||
|
||||
In `live` mode, the current color is reinterpreted for both RGB and CT controls. That means an arbitrary RGB state can still be shown on the CT slider, and switching UI modes does not itself send any backend change.
|
||||
|
||||
This is useful both for inspecting a single virtual light and for experimenting with possible grouped-light behavior before adding it to the runtime service.
|
||||
|
||||
## Running On The Pi
|
||||
|
||||
Once the app has been deployed, you can run the same debugger directly on the Pi:
|
||||
|
||||
```bash
|
||||
ssh user@host.example \
|
||||
'cd /path/to/ha-deconz-bridge/app && set -a && . ../config/service.env && set +a && ../.venv/bin/python -m ha_deconz_bridge.debugger.web_debugger ../config/lights_config.py --host 0.0.0.0 --port 8765'
|
||||
```
|
||||
|
||||
Then open `http://host.example:8765` from your browser, or tunnel the port over SSH if you prefer.
|
||||
|
||||
There is also a dedicated user service unit for the Pi-hosted debugger:
|
||||
|
||||
```bash
|
||||
ssh user@host.example 'systemctl --user start ha-deconz-bridge-debug.service'
|
||||
```
|
||||
|
||||
That service listens on:
|
||||
|
||||
```text
|
||||
http://host.example:8766
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# Deployment
|
||||
|
||||
This project is designed to run as a user-owned Python app on a small Linux host such as a Raspberry Pi.
|
||||
|
||||
## Public Repo Policy
|
||||
|
||||
The public repository intentionally does not track a real deployment script or real home config.
|
||||
|
||||
Keep these in ignored local files:
|
||||
|
||||
- real SSH hostnames, domains, and usernames
|
||||
- real remote install paths
|
||||
- real room, light, group, and remote names
|
||||
- real deCONZ IDs and API keys
|
||||
- local deployment scripts
|
||||
|
||||
The sanitized example config is [`configs/example.py`](../configs/example.py). A real deployment can use an ignored config such as `configs/local/lights_config.py`.
|
||||
|
||||
## Target Layout
|
||||
|
||||
A typical target machine can use this layout:
|
||||
|
||||
- `<install-dir>/app`: synced application code
|
||||
- `<install-dir>/.venv`: virtualenv used by the service
|
||||
- `<install-dir>/config/lights_config.py`: runtime light definitions
|
||||
- `<install-dir>/config/service.env`: runtime environment variables
|
||||
- `~/.config/systemd/user/ha-deconz-bridge.service`: user service unit
|
||||
- `~/.config/systemd/user/ha-deconz-bridge-debug.service`: optional web debugger service
|
||||
|
||||
## Service Units
|
||||
|
||||
The generic user service templates are tracked in [`deploy/`](../deploy/). They expect:
|
||||
|
||||
- `HA_DECONZ_BRIDGE_CONFIG` to point at the runtime config file
|
||||
- `HA_DECONZ_BRIDGE_SERVICE_ENV` or an equivalent environment file to provide secrets and host-specific settings
|
||||
- a virtualenv with the package installed
|
||||
|
||||
The environment file should not be committed. Use [`deploy/service.env.example`](../deploy/service.env.example) as a template.
|
||||
|
||||
## Manual Deployment Outline
|
||||
|
||||
1. Copy the repository to the target machine, excluding `.git`, virtualenvs, caches, and local/private files.
|
||||
2. Create a virtualenv on the target.
|
||||
3. Install the package, optionally with the speed extra:
|
||||
|
||||
```bash
|
||||
python3 -m venv <install-dir>/.venv
|
||||
<install-dir>/.venv/bin/pip install --upgrade pip
|
||||
<install-dir>/.venv/bin/pip install -e '<install-dir>/app[speed]'
|
||||
```
|
||||
|
||||
4. Copy a real ignored config to `<install-dir>/config/lights_config.py`.
|
||||
5. Copy and edit `deploy/service.env.example` to `<install-dir>/config/service.env`.
|
||||
6. Install the systemd user units from `deploy/`.
|
||||
7. Reload and start the service:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now ha-deconz-bridge.service
|
||||
```
|
||||
|
||||
## Creating A Private Config
|
||||
|
||||
Start from the public example:
|
||||
|
||||
```bash
|
||||
mkdir -p configs/local
|
||||
cp configs/example.py configs/local/lights_config.py
|
||||
cp configs/example_*.py configs/local/
|
||||
```
|
||||
|
||||
Edit the copied files for your real devices. If the entry point imports copied split files, make sure the imports point at `configs.local...` modules. For example:
|
||||
|
||||
```python
|
||||
from configs.local.example_lights import LIGHTS
|
||||
```
|
||||
|
||||
Then deploy or run with `configs/local/lights_config.py`. Keep that path private and uncommitted.
|
||||
|
||||
## Required Runtime Values
|
||||
|
||||
These values must be real for hardware use:
|
||||
|
||||
- deCONZ API key
|
||||
- deCONZ host/port
|
||||
- MQTT host/port and credentials if needed
|
||||
- physical controller IDs in `lights_config.py`
|
||||
|
||||
These can be approximate at first:
|
||||
|
||||
- channel chromaticities
|
||||
- per-channel brightness/lumen values
|
||||
- exact CT calibration
|
||||
|
||||
Approximate calibration is enough for integration testing. Exact calibration can come later.
|
||||
|
||||
## Debugger
|
||||
|
||||
The web debugger can run on the target machine against the same runtime config:
|
||||
|
||||
```bash
|
||||
cd <install-dir>/app
|
||||
set -a
|
||||
. ../config/service.env
|
||||
set +a
|
||||
../.venv/bin/python -m ha_deconz_bridge.debugger.web_debugger ../config/lights_config.py --host 0.0.0.0 --port 8765
|
||||
```
|
||||
|
||||
Open `http://<target-host>:8765` from a browser that can reach the target.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Implementation Plan
|
||||
|
||||
This repo reboot implements a deterministic virtual light abstraction with:
|
||||
|
||||
- typed specs and validation
|
||||
- one shared runtime/debug core
|
||||
- a compiled light model plus a shared solve function
|
||||
- exact-target solving with deterministic combination elimination
|
||||
- deCONZ backend support
|
||||
- Home Assistant MQTT bridge
|
||||
- coalesced HA command queueing
|
||||
- test coverage for color, solver, reverse mapping, queueing, and integration flows
|
||||
|
||||
The archived prototype remains available for reference but is not the implementation base.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Semantics
|
||||
|
||||
## Virtual Color Temperature Range
|
||||
|
||||
- A virtual light exposes the broadest native CT range available from its configured CT-capable emitters.
|
||||
- RGB emulation is not used to expand the published Home Assistant CT range.
|
||||
- Bogus deCONZ CT limits are ignored when config or calibration provides a better range.
|
||||
|
||||
## Brightness
|
||||
|
||||
- Home Assistant brightness `255` means the brightest achievable output at the requested color or color temperature.
|
||||
- Lower brightness values are derived after chroma selection. The solver first chooses the best reachable chroma set, then defines `255` from the brightest surviving combination at that chroma, and then scales down to the requested brightness.
|
||||
- Reverse mapping reports brightness relative to the brightest achievable output at the achieved reported color.
|
||||
|
||||
## Forward And Reverse Mapping
|
||||
|
||||
- The solver treats requested RGB or CT as exact whenever possible.
|
||||
- If the request is out of gamut, the solver keeps the combinations within a small chroma-error band of the best result and then continues the same elimination pipeline.
|
||||
- For CT requests, the primary white reference is not the ideal blackbody point. The solver uses the same reverse-CT model as readback and treats a requested CT as a constant-CT line in xy space.
|
||||
- CT-labeled emitters first maximize brightness along that constant-CT line. This defines the broad-spectrum white contribution and keeps forward/reverse CT mapping stable even when calibrated emitters do not lie on the blackbody locus.
|
||||
- RGB correction may be used at lower CT brightness if it can hit or approach the blackbody point while preserving most of the available CT-labeled contribution.
|
||||
- When exact blackbody correction is no longer feasible at the requested brightness, correction degrades by minimizing chroma error while staying on the requested constant-CT line instead of dropping correction abruptly.
|
||||
- At high brightness, CT-labeled output can remain approximate instead of sacrificing large amounts of white output for a small chroma correction.
|
||||
- `Settings.approximate_chroma_band` bounds how far a CT-line result may be from the blackbody target before it is rejected as too inaccurate.
|
||||
- RGB requests do not use this slack path. If any RGB-labeled emitter exists in the virtual light or group, RGB requests are solved using RGB-labeled emitters only. This prevents very bright white emitters from causing brightness cliffs as an RGB color sweep approaches the blackbody locus.
|
||||
- Home Assistant normally sees the achieved physical output, not a remembered request history.
|
||||
- Immediately after our own command, the accepted command state is treated as truth while deCONZ readback remains close enough to the command. This absorbs controller rounding, stale websocket events, and small drift without making HA sliders jump.
|
||||
- Physical controller commands use the solver's computed channel mix. They are not overwritten just to make a controller CT value equal the HA-requested CT.
|
||||
- Accepted CT command state preserves the requested CT chroma for reporting, but uses calibrated solver/channel output for brightness. This keeps HA -> physical -> HA stable even when the calibrated CCT emitters do not lie exactly on the blackbody locus.
|
||||
- Once readback no longer matches the accepted command within tolerance, accepted state is dropped and reverse mapping recomputes from calibrated physical channel output.
|
||||
- Repeated HA -> physical -> HA translation should converge to a stable reported state rather than drift.
|
||||
- When a physical controller changes externally, every exposed virtual light or group containing that controller should recompute from the shared controller state and publish an updated HA state.
|
||||
|
||||
## Groups
|
||||
|
||||
- Groups are exposed as Home Assistant lights, but at runtime they control member virtual lights instead of solving directly against physical controllers.
|
||||
- A group member may be constrained to a fixed RGB color or fixed color temperature. The restriction applies only through that group; direct control of the member light remains unchanged.
|
||||
- A constrained member is effectively a dimmable fixed-color contributor from the group point of view. The member's own solver still decides how to produce that fixed color physically.
|
||||
- Group capabilities are derived from effective member capabilities unless explicitly configured. A group whose members are all fixed-color or brightness-only contributors exposes `brightness`.
|
||||
- Group reverse mapping aggregates each member's accepted-or-physical output color, not the member's already-idealized HA-facing report color.
|
||||
- `relative_member` balancing sends the same relative brightness to every member, which is useful when a group represents physically separated areas that should brighten together.
|
||||
|
||||
## Mode Selection
|
||||
|
||||
- Native mode is not a separate solve path. Labels like `rgb` and `color_temp` are only prioritization hints after chroma and brightness feasibility are established.
|
||||
- Cross-family boosting is allowed when it increases reachable brightness while staying within the chosen chroma target or chroma fallback band.
|
||||
- On/off channels are last-resort brightness boosters and are minimized before final combination selection.
|
||||
- Reverse mapping chooses the dominant physical family for external states. Accepted command state reports the command's requested HA mode while it remains valid.
|
||||
- Ties fall back deterministically: `color_temp`, then `rgb`, then `brightness`, then `onoff`.
|
||||
|
||||
## Combination Elimination
|
||||
|
||||
- Only exclusivity groups create branches. Independent control paths may be active together.
|
||||
- If several combinations survive chroma and brightness filtering, the solver:
|
||||
1. minimizes on/off lumens
|
||||
2. maximizes same-label lumens
|
||||
3. prefers more output channels
|
||||
4. prefers higher total available lumens
|
||||
5. falls back to stable config order
|
||||
- After one combination is chosen, load balancing happens inside that combination rather than across several combinations.
|
||||
|
||||
## Remotes And Automations
|
||||
|
||||
- A physical remote is identified by its deCONZ sensor ID.
|
||||
- Automation rules do not bind directly to the physical sensor ID. They bind to an internal `remote_id`.
|
||||
- Replacing a remote should only require moving an existing `remote_id` to a different physical sensor ID.
|
||||
- Multiple physical remotes may share one `remote_id`, which makes them trigger the same automation rules.
|
||||
- Double-press state is tracked per physical sensor ID, not per `remote_id`, so two different remotes sharing one `remote_id` cannot accidentally combine into one double press.
|
||||
- `Settings.default_double_press_window` defines the global double-press window. A `RemoteSpec.double_press_window` override may replace it for one physical remote.
|
||||
- `RemoteSpec.model` documents the physical remote type. The current runtime does not branch on it yet.
|
||||
- For Philips Hue dimmer `RWL021`, deCONZ `buttonevent` values are interpreted as:
|
||||
- `1000`, `2000`, `3000`, `4000`: press for top/up/down/bottom
|
||||
- `1001`, `2001`, `3001`, `4001`: hold-active, exposed as `hold` or `double_hold`
|
||||
- `1002`, `2002`, `3002`, `4002`: short release, exposed as `single`
|
||||
- `1003`, `2003`, `3003`, `4003`: long release, exposed as `long` or `double_long`
|
||||
- A `single` action fires immediately on short release. If another short release for the same physical remote and button arrives inside the double-press window, an additional `double` action fires.
|
||||
- If a short release is followed by a second press inside the double-press window and that second press becomes a hold, hold events are exposed as `double_hold`; release is exposed as `double_long`.
|
||||
- Hold-count patterns are supported: `hold_x`, `double_hold_x`, `hold_x_y`, and `double_hold_x_y`. `x` must be positive. If `y < x`, the trigger starts at `x` and continues until release. If `y == x`, it fires only on the x'th hold event.
|
||||
- Automation target expressions support `+` and `-` over configured groups/lights. `All` and the empty string mean all individual virtual lights. Expressions are evaluated left-to-right.
|
||||
- Automation actions execute through the same virtual-light command path as Home Assistant commands.
|
||||
Reference in New Issue
Block a user