Restructure public config and deployment
This commit is contained in:
+2
-1
@@ -4,8 +4,9 @@ __pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
old attempt/
|
||||
config/
|
||||
configs/local/
|
||||
scripts/deploy.py
|
||||
scripts/*.local.py
|
||||
*.env
|
||||
*.env.local
|
||||
*.local
|
||||
|
||||
@@ -15,53 +15,116 @@ New code lives under [`src/ha_deconz_bridge`](./src/ha_deconz_bridge/). Local/pr
|
||||
|
||||
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
|
||||
## What You Need
|
||||
|
||||
- [`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
|
||||
- Python 3.11 or newer.
|
||||
- Home Assistant with MQTT discovery enabled.
|
||||
- deCONZ/Phoscon already controlling the physical lights.
|
||||
- SSH access to the Raspberry Pi or other Linux host if you want to deploy it as a service.
|
||||
|
||||
## Quick Start
|
||||
## Configuration Files
|
||||
|
||||
Install in editable mode:
|
||||
The project is meant to be self-contained. Public example files live in [`config.example/`](./config.example/), and your real private files live in ignored `config/`.
|
||||
|
||||
Create your private config:
|
||||
|
||||
```bash
|
||||
python3 -m pip install -e '.[dev]'
|
||||
mkdir -p config
|
||||
cp config.example/*.py config/
|
||||
cp config.example/service.env.example config/service.env
|
||||
cp config.example/deploy.env.example config/deploy.env
|
||||
```
|
||||
|
||||
Run the test suite:
|
||||
Then edit:
|
||||
|
||||
- `config/lights_config.py`: loads the other config files.
|
||||
- `config/lights.py`: virtual lights and physical controller/channel definitions.
|
||||
- `config/groups.py`: room/group definitions.
|
||||
- `config/remotes.py` and `config/actions.py`: optional remote button automation.
|
||||
- `config/service.env`: MQTT and deCONZ connection settings.
|
||||
- `config/deploy.env`: SSH target and deploy settings for the Pi.
|
||||
|
||||
Files under `config/` are ignored by Git.
|
||||
|
||||
## Run Locally
|
||||
|
||||
This runs the bridge on the current machine. It is useful for testing the config or running the web debugger before deploying to a Pi.
|
||||
|
||||
1. Create a Python environment:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
python3 -m venv .venv
|
||||
```
|
||||
|
||||
Create a private config from the public example:
|
||||
2. Install the program into that environment:
|
||||
|
||||
```bash
|
||||
mkdir -p configs/local
|
||||
cp configs/example.py configs/local/lights_config.py
|
||||
cp configs/example_*.py configs/local/
|
||||
.venv/bin/pip install .
|
||||
```
|
||||
|
||||
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`:
|
||||
3. Load the connection settings and run the service:
|
||||
|
||||
```bash
|
||||
ha-deconz-bridge-service configs/local/lights_config.py
|
||||
set -a
|
||||
. config/service.env
|
||||
set +a
|
||||
.venv/bin/ha-deconz-bridge-service config/lights_config.py
|
||||
```
|
||||
|
||||
Run the browser debugger against the same config:
|
||||
4. In another terminal, run the browser debugger if you want to inspect solver behavior:
|
||||
|
||||
```bash
|
||||
ha-deconz-bridge-debug-web configs/local/lights_config.py "Example Mixed North" --host 127.0.0.1 --port 8765
|
||||
set -a
|
||||
. config/service.env
|
||||
set +a
|
||||
.venv/bin/ha-deconz-bridge-debug-web config/lights_config.py "Living Room Ceiling" --host 127.0.0.1 --port 8765
|
||||
```
|
||||
|
||||
## Configuration And Deployment
|
||||
Then open `http://127.0.0.1:8765`.
|
||||
|
||||
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/`.
|
||||
On Windows, use `.venv\Scripts\pip` and `.venv\Scripts\ha-deconz-bridge-service` instead of the `.venv/bin/...` paths.
|
||||
|
||||
See [`docs/deployment.md`](./docs/deployment.md) for the generic service layout and deployment expectations.
|
||||
## Deploy To A Pi
|
||||
|
||||
This is the normal always-on setup. The deploy script copies the app and config to the target, creates or updates a virtualenv there, installs the package, installs systemd user services, and starts the bridge.
|
||||
|
||||
1. Make sure `config/deploy.env` contains your SSH target:
|
||||
|
||||
```bash
|
||||
DEPLOY_HOST=pi@raspberrypi.local
|
||||
DEPLOY_REMOTE_DIR=~/ha-deconz-bridge
|
||||
```
|
||||
|
||||
2. Deploy:
|
||||
|
||||
```bash
|
||||
python deploy/deploy.py
|
||||
```
|
||||
|
||||
3. For later config-only changes, redeploy just the config:
|
||||
|
||||
```bash
|
||||
python deploy/deploy.py --config-only
|
||||
```
|
||||
|
||||
4. Check the service on the Pi:
|
||||
|
||||
```bash
|
||||
ssh pi@raspberrypi.local 'systemctl --user status ha-deconz-bridge.service --no-pager'
|
||||
```
|
||||
|
||||
The optional web debugger service can be started on the Pi with:
|
||||
|
||||
```bash
|
||||
ssh pi@raspberrypi.local 'systemctl --user start ha-deconz-bridge-debug.service'
|
||||
```
|
||||
|
||||
It listens on port `8766` by default.
|
||||
|
||||
## More Documentation
|
||||
|
||||
- [`docs/configuration.md`](./docs/configuration.md): config format and examples.
|
||||
- [`docs/deployment.md`](./docs/deployment.md): deployment details and service layout.
|
||||
- [`docs/debugger.md`](./docs/debugger.md): web debugger behavior.
|
||||
- [`docs/semantics.md`](./docs/semantics.md): solver and Home Assistant behavior.
|
||||
- [`docs/architecture.md`](./docs/architecture.md): package structure and data flow.
|
||||
|
||||
@@ -16,21 +16,21 @@ def scene(target: str, *, brightness: int, kelvin: int) -> ActionSpec:
|
||||
|
||||
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),
|
||||
scene("Living Room", brightness=brightness, kelvin=kelvin),
|
||||
scene("Hallway", brightness=brightness, kelvin=kelvin),
|
||||
scene("Kitchen", brightness=brightness, kelvin=kelvin),
|
||||
scene("Bathroom", brightness=brightness, kelvin=kelvin),
|
||||
scene("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"),
|
||||
ActionSpec("turn_off", target="Living Room"),
|
||||
ActionSpec("turn_off", target="Hallway"),
|
||||
ActionSpec("turn_off", target="Kitchen"),
|
||||
ActionSpec("turn_off", target="Bathroom"),
|
||||
ActionSpec("turn_off", target="Bedroom"),
|
||||
)
|
||||
|
||||
|
||||
@@ -60,11 +60,11 @@ def room_automation(room: str) -> dict[str, dict[str, tuple[ActionSpec, ...]]]:
|
||||
|
||||
|
||||
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"),
|
||||
"example_living_remote": room_automation("Living Room"),
|
||||
"example_kitchen_remote": room_automation("Kitchen"),
|
||||
"example_hall_remote": room_automation("Hallway"),
|
||||
"example_bathroom_remote": room_automation("Bathroom"),
|
||||
"example_bedroom_remote": room_automation("Bedroom"),
|
||||
}
|
||||
|
||||
AUTOMATIONS = automation_rules(AUTOMATION_CONFIG)
|
||||
@@ -0,0 +1,35 @@
|
||||
# Copy this file to config/deploy.env and edit it for your target machine.
|
||||
#
|
||||
# The deploy script reads this file by default:
|
||||
# python deploy/deploy.py
|
||||
|
||||
# SSH target for the Linux host that will run the service.
|
||||
DEPLOY_HOST=pi@raspberrypi.local
|
||||
|
||||
# Install location on the target. "~" is expanded by the remote shell.
|
||||
DEPLOY_REMOTE_DIR=~/ha-deconz-bridge
|
||||
|
||||
# Local config entry point and service env.
|
||||
# All .py files next to DEPLOY_CONFIG are copied to the target config directory.
|
||||
DEPLOY_CONFIG=config/lights_config.py
|
||||
DEPLOY_SERVICE_ENV=config/service.env
|
||||
|
||||
# Optional behavior. Values: true/false.
|
||||
DEPLOY_INSTALL_SPEED=false
|
||||
DEPLOY_RESTART_DEBUG=false
|
||||
|
||||
# Runtime environment copied into the target service.env.
|
||||
HA_DECONZ_BRIDGE_MQTT_HOST=homeassistant.local
|
||||
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=replace-with-your-deconz-api-key
|
||||
|
||||
HA_DECONZ_BRIDGE_TARGET_CACHE_SCALE=10000
|
||||
HA_DECONZ_BRIDGE_APPROXIMATE_CHROMA_BAND=0.025
|
||||
HA_DECONZ_BRIDGE_WARMUP_ON_STARTUP=true
|
||||
HA_DECONZ_BRIDGE_NUMERIC_BACKEND=scipy
|
||||
@@ -0,0 +1,42 @@
|
||||
from ha_deconz_bridge.groups import GroupMemberSpec, GroupSpec
|
||||
|
||||
from controllers import DAYLIGHT_MIREDS
|
||||
|
||||
|
||||
GROUPS: dict[str, GroupSpec] = {
|
||||
"Hallway": GroupSpec(
|
||||
name="Hallway",
|
||||
members=("Hallway Floor Light", "Hallway Corner Light", "Hallway Cabinet Lights"),
|
||||
),
|
||||
"Kitchen": GroupSpec(
|
||||
name="Kitchen",
|
||||
members=("Kitchen Counter Lights",),
|
||||
),
|
||||
"Bathroom": GroupSpec(
|
||||
name="Bathroom",
|
||||
members=("Bathroom Ceiling Light", "Bathroom Mirror Light"),
|
||||
),
|
||||
"Bedroom": GroupSpec(
|
||||
name="Bedroom",
|
||||
members=("Bedroom Left Lamp", "Bedroom Right Lamp"),
|
||||
),
|
||||
"Living Room": GroupSpec(
|
||||
name="Living Room",
|
||||
members=(
|
||||
"Plant Light Left",
|
||||
"Plant Light Right",
|
||||
"Living Room Ceiling",
|
||||
"Floor Lamp Plug",
|
||||
"Desk Lamp Plug",
|
||||
),
|
||||
),
|
||||
"Plant Lights": GroupSpec(
|
||||
name="Plant Lights",
|
||||
members=(
|
||||
"Plant Light Left",
|
||||
"Plant Light Right",
|
||||
GroupMemberSpec("Living Room Ceiling", fixed_color=DAYLIGHT_MIREDS),
|
||||
),
|
||||
balance="relative_member",
|
||||
),
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
from configs.example_controllers import (
|
||||
from controllers import (
|
||||
CT_RANGE,
|
||||
DAYLIGHT_MIREDS,
|
||||
RGB_BLUE,
|
||||
@@ -10,11 +10,11 @@ from configs.example_controllers import (
|
||||
|
||||
LIGHTS = define_lights(
|
||||
{
|
||||
"Example Fixed White A": {
|
||||
"Plant Light Left": {
|
||||
"supported_color_modes": ("brightness",),
|
||||
"controllers": {
|
||||
"fixed_a": {
|
||||
"name": "Example Fixed White A",
|
||||
"name": "Plant Light Left",
|
||||
"mireds": CT_RANGE,
|
||||
"color_temp": {
|
||||
"ww": {"color": DAYLIGHT_MIREDS, "brightness": 9000},
|
||||
@@ -22,11 +22,11 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Fixed White B": {
|
||||
"Plant Light Right": {
|
||||
"supported_color_modes": ("brightness",),
|
||||
"controllers": {
|
||||
"fixed_b": {
|
||||
"name": "Example Fixed White B",
|
||||
"name": "Plant Light Right",
|
||||
"mireds": CT_RANGE,
|
||||
"color_temp": {
|
||||
"ww": {"color": DAYLIGHT_MIREDS, "brightness": 7000},
|
||||
@@ -34,33 +34,33 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Plug A": {
|
||||
"Floor Lamp Plug": {
|
||||
"supported_color_modes": ("onoff",),
|
||||
"controllers": {
|
||||
"plug_a": {
|
||||
"name": "Example Plug A",
|
||||
"name": "Floor Lamp Plug",
|
||||
"onoff": {
|
||||
"w": {"color": DAYLIGHT_MIREDS, "brightness": 8000, "label": "color_temp"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Plug B": {
|
||||
"Desk Lamp Plug": {
|
||||
"supported_color_modes": ("onoff",),
|
||||
"controllers": {
|
||||
"plug_b": {
|
||||
"name": "Example Plug B",
|
||||
"name": "Desk Lamp Plug",
|
||||
"onoff": {
|
||||
"w": {"color": DAYLIGHT_MIREDS, "brightness": 8000, "label": "color_temp"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Mixed North": {
|
||||
"Living Room Ceiling": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"mixed_rgb": {
|
||||
"name": "Example Mixed North RGB",
|
||||
"name": "Living Room Ceiling RGB",
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 3000},
|
||||
"g": {"color": RGB_GREEN, "brightness": 2480},
|
||||
@@ -68,7 +68,7 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
"mixed_ct": {
|
||||
"name": "Example Mixed North CCT",
|
||||
"name": "Living Room Ceiling CCT",
|
||||
"mireds": CT_RANGE,
|
||||
"color_temp": {
|
||||
"ww": {"color": 500, "brightness": 25600},
|
||||
@@ -77,11 +77,11 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Hall Floor": {
|
||||
"Hallway Floor Light": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"hall_floor": {
|
||||
"name": "Example Hall Floor",
|
||||
"name": "Hallway Floor Light",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 2460},
|
||||
@@ -95,11 +95,11 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Hall Corner": {
|
||||
"Hallway Corner Light": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"hall_corner": {
|
||||
"name": "Example Hall Corner",
|
||||
"name": "Hallway Corner Light",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 820},
|
||||
@@ -113,11 +113,11 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Hall Cabinets": {
|
||||
"Hallway Cabinet Lights": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"hall_cabinets": {
|
||||
"name": "Example Hall Cabinets",
|
||||
"name": "Hallway Cabinet Lights",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 2460},
|
||||
@@ -131,11 +131,11 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Kitchen Counter": {
|
||||
"Kitchen Counter Lights": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"kitchen_counter": {
|
||||
"name": "Example Kitchen Counter",
|
||||
"name": "Kitchen Counter Lights",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 1800},
|
||||
@@ -149,11 +149,11 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Bathroom Ceiling": {
|
||||
"Bathroom Ceiling Light": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"bath_ceiling_1": {
|
||||
"name": "Example Bathroom Ceiling 1",
|
||||
"name": "Bathroom Ceiling Light 1",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 900},
|
||||
@@ -166,7 +166,7 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
"bath_ceiling_2": {
|
||||
"name": "Example Bathroom Ceiling 2",
|
||||
"name": "Bathroom Ceiling Light 2",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 900},
|
||||
@@ -179,7 +179,7 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
"bath_ceiling_3": {
|
||||
"name": "Example Bathroom Ceiling 3",
|
||||
"name": "Bathroom Ceiling Light 3",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 900},
|
||||
@@ -193,11 +193,11 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Bathroom Mirror": {
|
||||
"Bathroom Mirror Light": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"bath_mirror": {
|
||||
"name": "Example Bathroom Mirror",
|
||||
"name": "Bathroom Mirror Light",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 4100},
|
||||
@@ -211,11 +211,11 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Bedroom West": {
|
||||
"Bedroom Left Lamp": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"bedroom_w": {
|
||||
"name": "Example Bedroom West",
|
||||
"name": "Bedroom Left Lamp",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 1100},
|
||||
@@ -229,11 +229,11 @@ LIGHTS = define_lights(
|
||||
},
|
||||
},
|
||||
},
|
||||
"Example Bedroom East": {
|
||||
"Bedroom Right Lamp": {
|
||||
"supported_color_modes": ("rgb", "color_temp"),
|
||||
"controllers": {
|
||||
"bedroom_e": {
|
||||
"name": "Example Bedroom East",
|
||||
"name": "Bedroom Right Lamp",
|
||||
"mireds": CT_RANGE,
|
||||
"rgb": {
|
||||
"r": {"color": RGB_RED, "brightness": 1100},
|
||||
@@ -0,0 +1,9 @@
|
||||
from ha_deconz_bridge.settings import Settings
|
||||
|
||||
from actions import AUTOMATIONS
|
||||
from groups import GROUPS
|
||||
from lights import LIGHTS
|
||||
from remotes import REMOTES
|
||||
|
||||
|
||||
SETTINGS = Settings.from_env()
|
||||
@@ -5,13 +5,13 @@ REMOTES = {
|
||||
"example_sensor_1": RemoteSpec(
|
||||
sensor_id="example_sensor_1",
|
||||
remote_id="example_living_remote",
|
||||
name="Example living remote",
|
||||
name="Living room remote",
|
||||
model="RWL021",
|
||||
),
|
||||
"example_sensor_2": RemoteSpec(
|
||||
sensor_id="example_sensor_2",
|
||||
remote_id="example_kitchen_remote",
|
||||
name="Example kitchen remote",
|
||||
name="Kitchen remote",
|
||||
model="RWL021",
|
||||
),
|
||||
}
|
||||
@@ -23,6 +23,6 @@ HA_DECONZ_BRIDGE_APPROXIMATE_CHROMA_BAND=0.025
|
||||
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]'.
|
||||
# dependencies were installed, for example with pip install ".[speed]".
|
||||
# Allowed values: scipy, numba, auto.
|
||||
HA_DECONZ_BRIDGE_NUMERIC_BACKEND=scipy
|
||||
@@ -1,9 +0,0 @@
|
||||
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()
|
||||
@@ -1,42 +0,0 @@
|
||||
from ha_deconz_bridge.groups import GroupMemberSpec, GroupSpec
|
||||
|
||||
from configs.example_controllers import DAYLIGHT_MIREDS
|
||||
|
||||
|
||||
GROUPS: dict[str, GroupSpec] = {
|
||||
"Example Hall": GroupSpec(
|
||||
name="Example Hall",
|
||||
members=("Example Hall Floor", "Example Hall Corner", "Example Hall Cabinets"),
|
||||
),
|
||||
"Example Kitchen": GroupSpec(
|
||||
name="Example Kitchen",
|
||||
members=("Example Kitchen Counter",),
|
||||
),
|
||||
"Example Bathroom": GroupSpec(
|
||||
name="Example Bathroom",
|
||||
members=("Example Bathroom Ceiling", "Example Bathroom Mirror"),
|
||||
),
|
||||
"Example Bedroom": GroupSpec(
|
||||
name="Example Bedroom",
|
||||
members=("Example Bedroom West", "Example Bedroom East"),
|
||||
),
|
||||
"Example Living": GroupSpec(
|
||||
name="Example Living",
|
||||
members=(
|
||||
"Example Fixed White A",
|
||||
"Example Fixed White B",
|
||||
"Example Mixed North",
|
||||
"Example Plug A",
|
||||
"Example Plug B",
|
||||
),
|
||||
),
|
||||
"Example Plant": GroupSpec(
|
||||
name="Example Plant",
|
||||
members=(
|
||||
"Example Fixed White A",
|
||||
"Example Fixed White B",
|
||||
GroupMemberSpec("Example Mixed North", fixed_color=DAYLIGHT_MIREDS),
|
||||
),
|
||||
balance="relative_member",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
|
||||
DEFAULT_DEPLOY_ENV = Path("config/deploy.env")
|
||||
DEFAULT_REMOTE_DIR = "~/ha-deconz-bridge"
|
||||
APP_ARCHIVE_NAME = "ha-deconz-bridge.tar.gz"
|
||||
RUNTIME_ENV_PREFIX = "HA_DECONZ_BRIDGE_"
|
||||
EXCLUDED_DIR_NAMES = {
|
||||
".git",
|
||||
".venv",
|
||||
".pytest_cache",
|
||||
"__pycache__",
|
||||
"config",
|
||||
"old attempt",
|
||||
}
|
||||
EXCLUDED_SUFFIXES = {".pyc"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Deploy ha-deconz-bridge to a Raspberry Pi or similar Linux host."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deploy-env",
|
||||
default=str(DEFAULT_DEPLOY_ENV),
|
||||
help="Local deployment env file. Defaults to config/deploy.env.",
|
||||
)
|
||||
parser.add_argument("--host", help="SSH target, overriding DEPLOY_HOST.")
|
||||
parser.add_argument("--remote-dir", help="Remote install directory, overriding DEPLOY_REMOTE_DIR.")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
help=(
|
||||
"Local runtime config entry point. All .py files next to it are copied "
|
||||
"to the remote config directory; the entry point is also copied as "
|
||||
"lights_config.py."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--service-env", help="Local service env copied to remote config/service.env.")
|
||||
parser.add_argument(
|
||||
"--config-only",
|
||||
action="store_true",
|
||||
help="Only copy config/service env and restart the service.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-start",
|
||||
action="store_true",
|
||||
help="Deploy files but do not enable, start, or restart the service.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restart-debug",
|
||||
action="store_true",
|
||||
help="Restart the optional web debugger service after deployment.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--with-speed",
|
||||
action="store_true",
|
||||
help="Install optional speed dependencies, overriding DEPLOY_INSTALL_SPEED.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
deploy_env_path = _resolve_path(repo_root, args.deploy_env)
|
||||
deploy_env = _read_env_file(deploy_env_path)
|
||||
|
||||
host = args.host or deploy_env.get("DEPLOY_HOST")
|
||||
if not host:
|
||||
raise SystemExit(
|
||||
"Missing DEPLOY_HOST. Copy config.example/deploy.env.example to "
|
||||
"config/deploy.env and edit it, or pass --host."
|
||||
)
|
||||
|
||||
remote_dir = args.remote_dir or deploy_env.get("DEPLOY_REMOTE_DIR", DEFAULT_REMOTE_DIR)
|
||||
config_path = _local_file(
|
||||
repo_root,
|
||||
args.config or deploy_env.get("DEPLOY_CONFIG", "config/lights_config.py"),
|
||||
required=True,
|
||||
)
|
||||
service_env_path = _service_env_path(repo_root, args.service_env, deploy_env)
|
||||
install_speed = args.with_speed or _env_bool(deploy_env.get("DEPLOY_INSTALL_SPEED", "false"))
|
||||
restart_debug = args.restart_debug or _env_bool(deploy_env.get("DEPLOY_RESTART_DEBUG", "false"))
|
||||
|
||||
_deploy(
|
||||
host=host,
|
||||
remote_dir=remote_dir,
|
||||
repo_root=repo_root,
|
||||
deploy_env=deploy_env,
|
||||
config_path=config_path,
|
||||
service_env_path=service_env_path,
|
||||
config_only=args.config_only,
|
||||
no_start=args.no_start,
|
||||
install_speed=install_speed,
|
||||
restart_debug=restart_debug,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _deploy(
|
||||
*,
|
||||
host: str,
|
||||
remote_dir: str,
|
||||
repo_root: Path,
|
||||
deploy_env: dict[str, str],
|
||||
config_path: Path,
|
||||
service_env_path: Path | None,
|
||||
config_only: bool,
|
||||
no_start: bool,
|
||||
install_speed: bool,
|
||||
restart_debug: bool,
|
||||
) -> None:
|
||||
print(f"Deploying to {host}:{remote_dir}")
|
||||
_ssh(host, f"mkdir -p {_q(remote_dir)}")
|
||||
install_dir = _remote_realpath(host, remote_dir)
|
||||
remote_app = f"{install_dir}/app"
|
||||
remote_config = f"{install_dir}/config"
|
||||
remote_venv = f"{install_dir}/.venv"
|
||||
|
||||
_ssh(host, f"mkdir -p {_q(remote_app)} {_q(remote_config)} {_q('~/.config/systemd/user')}")
|
||||
|
||||
if not config_only:
|
||||
_ssh(host, f"rm -rf {_q(remote_app)} && mkdir -p {_q(remote_app)}")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
archive = Path(temp_dir) / APP_ARCHIVE_NAME
|
||||
_create_archive(repo_root, archive)
|
||||
remote_archive = f"{install_dir}/{APP_ARCHIVE_NAME}"
|
||||
_scp(host, archive, remote_archive)
|
||||
_ssh(host, f"tar -xzf {_q(remote_archive)} -C {_q(remote_app)} && rm -f {_q(remote_archive)}")
|
||||
|
||||
_copy_runtime_config(host, config_path, remote_config)
|
||||
_write_and_copy_service_env(host, deploy_env, service_env_path, f"{remote_config}/service.env")
|
||||
_install_service_units(host, repo_root, install_dir)
|
||||
|
||||
if not config_only:
|
||||
_ssh(host, f"python3 -m venv {_q(remote_venv)}")
|
||||
_ssh(host, f"{_q(f'{remote_venv}/bin/pip')} install --upgrade pip")
|
||||
package = f"{remote_app}[speed]" if install_speed else remote_app
|
||||
_ssh(host, f"{_q(f'{remote_venv}/bin/pip')} install -e {_q(package)}")
|
||||
|
||||
_ssh(host, "systemctl --user daemon-reload")
|
||||
if not no_start:
|
||||
_ssh(host, "systemctl --user enable --now ha-deconz-bridge.service")
|
||||
if config_only:
|
||||
_ssh(host, "systemctl --user restart ha-deconz-bridge.service")
|
||||
if restart_debug:
|
||||
_ssh(host, "systemctl --user restart ha-deconz-bridge-debug.service")
|
||||
|
||||
print()
|
||||
print("Deploy complete.")
|
||||
print(f"Install dir: {install_dir}")
|
||||
print(f"Remote config: {remote_config}/lights_config.py")
|
||||
print(f"Remote env file: {remote_config}/service.env")
|
||||
print("Service unit: ~/.config/systemd/user/ha-deconz-bridge.service")
|
||||
print()
|
||||
print("Useful commands:")
|
||||
print(f" ssh {host} 'systemctl --user status ha-deconz-bridge.service --no-pager'")
|
||||
print(f" ssh {host} 'journalctl --user -u ha-deconz-bridge.service -f'")
|
||||
print(f" ssh {host} 'systemctl --user start ha-deconz-bridge-debug.service'")
|
||||
|
||||
|
||||
def _read_env_file(path: Path) -> dict[str, str]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
values: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if "=" not in stripped:
|
||||
raise SystemExit(f"Invalid env line in {path}: {line!r}")
|
||||
key, value = stripped.split("=", 1)
|
||||
values[key.strip()] = _strip_env_value(value.strip())
|
||||
return values
|
||||
|
||||
|
||||
def _strip_env_value(value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def _service_env_path(
|
||||
repo_root: Path,
|
||||
override: str | None,
|
||||
deploy_env: dict[str, str],
|
||||
) -> Path | None:
|
||||
candidate = override or deploy_env.get("DEPLOY_SERVICE_ENV")
|
||||
if candidate:
|
||||
return _local_file(repo_root, candidate, required=True)
|
||||
default = repo_root / "config" / "service.env"
|
||||
return default if default.exists() else None
|
||||
|
||||
|
||||
def _local_file(repo_root: Path, value: str, *, required: bool) -> Path:
|
||||
path = _resolve_path(repo_root, value)
|
||||
if required and not path.is_file():
|
||||
raise SystemExit(f"Required file not found: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def _resolve_path(repo_root: Path, value: str | Path) -> Path:
|
||||
path = Path(value).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = repo_root / path
|
||||
return path
|
||||
|
||||
|
||||
def _write_and_copy_service_env(
|
||||
host: str,
|
||||
deploy_env: dict[str, str],
|
||||
service_env_path: Path | None,
|
||||
remote_path: str,
|
||||
) -> None:
|
||||
runtime_values = {
|
||||
key: value
|
||||
for key, value in deploy_env.items()
|
||||
if key.startswith(RUNTIME_ENV_PREFIX)
|
||||
}
|
||||
if service_env_path is not None:
|
||||
runtime_values.update(_read_env_file(service_env_path))
|
||||
if not runtime_values:
|
||||
if _remote_file_exists(host, remote_path):
|
||||
print(f"Keeping existing remote service env: {remote_path}")
|
||||
return
|
||||
raise SystemExit(
|
||||
"No service environment values found and no remote service.env exists. "
|
||||
"Add HA_DECONZ_BRIDGE_* values to config/deploy.env or provide "
|
||||
"DEPLOY_SERVICE_ENV=config/service.env."
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
local_env = Path(temp_dir) / "service.env"
|
||||
lines = [f"{key}={_quote_env_value(value)}" for key, value in sorted(runtime_values.items())]
|
||||
local_env.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
_scp(host, local_env, remote_path)
|
||||
|
||||
|
||||
def _copy_runtime_config(host: str, config_path: Path, remote_config: str) -> None:
|
||||
config_dir = config_path.parent
|
||||
config_files = sorted(path for path in config_dir.glob("*.py") if path.is_file())
|
||||
if config_path not in config_files:
|
||||
config_files.append(config_path)
|
||||
|
||||
_ssh(host, f"find {_q(remote_config)} -maxdepth 1 -type f -name '*.py' -delete")
|
||||
for path in config_files:
|
||||
_scp(host, path, f"{remote_config}/{path.name}")
|
||||
|
||||
if config_path.name != "lights_config.py":
|
||||
_scp(host, config_path, f"{remote_config}/lights_config.py")
|
||||
|
||||
|
||||
def _quote_env_value(value: str) -> str:
|
||||
if not value or any(character.isspace() or character in {'"', "'", "\\"} for character in value):
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
return value
|
||||
|
||||
|
||||
def _install_service_units(host: str, repo_root: Path, install_dir: str) -> None:
|
||||
for unit_name in ("ha-deconz-bridge.service", "ha-deconz-bridge-debug.service"):
|
||||
template = repo_root / "deploy" / unit_name
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
unit = Path(temp_dir) / unit_name
|
||||
unit.write_text(
|
||||
template.read_text(encoding="utf-8").replace("__INSTALL_DIR__", install_dir),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_scp(host, unit, f"~/.config/systemd/user/{unit_name}")
|
||||
|
||||
|
||||
def _create_archive(repo_root: Path, archive: Path) -> None:
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
for path in repo_root.rglob("*"):
|
||||
relative = path.relative_to(repo_root)
|
||||
if _excluded(relative, path):
|
||||
continue
|
||||
tar.add(path, arcname=relative, recursive=False)
|
||||
|
||||
|
||||
def _excluded(relative: Path, path: Path) -> bool:
|
||||
if any(part in EXCLUDED_DIR_NAMES for part in relative.parts):
|
||||
return True
|
||||
if any(part.endswith(".egg-info") for part in relative.parts):
|
||||
return True
|
||||
if path.is_file() and path.suffix in EXCLUDED_SUFFIXES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _env_bool(value: str) -> bool:
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _remote_realpath(host: str, path: str) -> str:
|
||||
result = subprocess.run(
|
||||
["ssh", host, f"cd {_q(path)} && pwd -P"],
|
||||
check=True,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _remote_file_exists(host: str, path: str) -> bool:
|
||||
return subprocess.run(
|
||||
["ssh", host, f"test -f {_q(path)}"],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).returncode == 0
|
||||
|
||||
|
||||
def _ssh(host: str, command: str) -> None:
|
||||
subprocess.run(["ssh", host, command], check=True)
|
||||
|
||||
|
||||
def _scp(host: str, source: Path, remote_path: str) -> None:
|
||||
subprocess.run(["scp", str(source), f"{host}:{remote_path}"], check=True)
|
||||
|
||||
|
||||
def _q(value: str) -> str:
|
||||
if value.startswith("~/"):
|
||||
return "~/" + shlex.quote(value[2:])
|
||||
return shlex.quote(value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -5,9 +5,9 @@ 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
|
||||
WorkingDirectory=__INSTALL_DIR__/app
|
||||
EnvironmentFile=__INSTALL_DIR__/config/service.env
|
||||
ExecStart=__INSTALL_DIR__/.venv/bin/python -m ha_deconz_bridge.debugger.web_debugger __INSTALL_DIR__/config/lights_config.py --host 0.0.0.0 --port 8766
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -5,11 +5,11 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/ha-deconz-bridge/app
|
||||
EnvironmentFile=-%h/ha-deconz-bridge/config/service.env
|
||||
WorkingDirectory=__INSTALL_DIR__/app
|
||||
EnvironmentFile=-__INSTALL_DIR__/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
|
||||
Environment=HA_DECONZ_BRIDGE_LOG_FILE=__INSTALL_DIR__/ha-deconz-bridge.log
|
||||
ExecStart=__INSTALL_DIR__/.venv/bin/ha-deconz-bridge-service __INSTALL_DIR__/config/lights_config.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
StandardOutput=journal
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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)),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
}
|
||||
+32
-31
@@ -14,7 +14,7 @@ A runtime config module should export:
|
||||
## Example
|
||||
|
||||
```python
|
||||
from configs.example_controllers import define_lights
|
||||
from controllers import define_lights
|
||||
|
||||
LIGHTS = define_lights({
|
||||
"living_room": {
|
||||
@@ -92,29 +92,30 @@ Numba is an optional dependency. Install it with the `speed` extra before select
|
||||
|
||||
## Config File Layout
|
||||
|
||||
The public example entry point is `configs/example.py`. It imports focused files:
|
||||
The public example entry point is `config.example/lights_config.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.
|
||||
- `config.example/controllers.py`: physical controller/channel helper functions and constants.
|
||||
- `config.example/lights.py`: individual virtual lights and which controllers they abstract.
|
||||
- `config.example/groups.py`: room/group membership, optional member restrictions, and group balancing policy.
|
||||
- `config.example/remotes.py`: physical remote sensor IDs mapped to internal `remote_id` values.
|
||||
- `config.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`.
|
||||
Real home configs should stay in ignored local files such as `config/`. 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/
|
||||
mkdir -p config
|
||||
cp config.example/*.py config/
|
||||
cp config.example/service.env.example config/service.env
|
||||
cp config.example/deploy.env.example config/deploy.env
|
||||
```
|
||||
|
||||
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:
|
||||
Then edit the copied files. The example entry point imports sibling files such as `actions.py`, `groups.py`, and `lights.py`, so the same imports continue to work after copying into `config/`. 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"
|
||||
ha-deconz-bridge-service config/lights_config.py
|
||||
ha-deconz-bridge-debug-web config/lights_config.py "Living Room Ceiling"
|
||||
```
|
||||
|
||||
The runtime accepts either a Python module name or a `.py` file path.
|
||||
@@ -125,12 +126,12 @@ Groups are defined with `GroupSpec`. A member can be listed by name, or wrapped
|
||||
from ha_deconz_bridge.groups import GroupMemberSpec, GroupSpec
|
||||
|
||||
GROUPS = {
|
||||
"Example Plant": GroupSpec(
|
||||
name="Example Plant",
|
||||
"Plant Lights": GroupSpec(
|
||||
name="Plant Lights",
|
||||
members=(
|
||||
"Example Fixed White A",
|
||||
"Example Fixed White B",
|
||||
GroupMemberSpec("Example Mixed North", fixed_color=182),
|
||||
"Plant Light Left",
|
||||
"Plant Light Right",
|
||||
GroupMemberSpec("Living Room Ceiling", fixed_color=182),
|
||||
),
|
||||
balance="relative_member",
|
||||
),
|
||||
@@ -156,7 +157,7 @@ REMOTES = {
|
||||
"example_sensor_1": RemoteSpec(
|
||||
sensor_id="example_sensor_1",
|
||||
remote_id="example_living",
|
||||
name="Example living remote",
|
||||
name="Living room remote",
|
||||
model="RWL021",
|
||||
double_press_window=0.45, # optional override
|
||||
),
|
||||
@@ -165,13 +166,13 @@ REMOTES = {
|
||||
AUTOMATION_CONFIG = {
|
||||
"example_living": {
|
||||
"top": {
|
||||
"single": (ActionSpec("set_scene", target="Example Living", brightness=180, color_temp=330),),
|
||||
"single": (ActionSpec("set_scene", target="Living Room", brightness=180, color_temp=330),),
|
||||
},
|
||||
"bottom": {
|
||||
"single": (ActionSpec("turn_off", target="Example Living"),),
|
||||
"single": (ActionSpec("turn_off", target="Living Room"),),
|
||||
},
|
||||
"up": {
|
||||
"hold": (ActionSpec("brightness_step", target="Example Living", brightness_step=16),),
|
||||
"hold": (ActionSpec("brightness_step", target="Living Room", brightness_step=16),),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -211,13 +212,13 @@ Action targets may be exact virtual light names, group names, or group expressio
|
||||
|
||||
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
|
||||
## Sample Groups
|
||||
|
||||
`configs/example.py` exposes sanitized example groups in addition to individual lights:
|
||||
`config.example/lights_config.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.
|
||||
- `Hallway`: three RGB+CCT virtual lights.
|
||||
- `Kitchen`: one RGB+CCT virtual light.
|
||||
- `Bathroom`: one multi-controller ceiling light and one mirror light.
|
||||
- `Bedroom`: two RGB+CCT virtual lights.
|
||||
- `Living Room`: fixed-white, mixed RGB/CCT, and on/off examples.
|
||||
- `Plant Lights`: fixed-white lights plus one constrained mixed light, using relative member brightness balancing.
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ Both variants:
|
||||
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
|
||||
.venv/bin/python -m ha_deconz_bridge.debugger.web_debugger ./config.example/lights_config.py "Living Room Ceiling" --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.
|
||||
@@ -37,7 +37,7 @@ Then open `http://127.0.0.1:8765` in a browser. If the debugger runs on the VM,
|
||||
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
|
||||
.venv/bin/python -m ha_deconz_bridge.debugger.web_debugger ./config/lights_config.py "Your Light Name" --host 127.0.0.1 --port 8765
|
||||
```
|
||||
|
||||
Inside the UI:
|
||||
|
||||
+53
-22
@@ -4,17 +4,15 @@ This project is designed to run as a user-owned Python app on a small Linux host
|
||||
|
||||
## Public Repo Policy
|
||||
|
||||
The public repository intentionally does not track a real deployment script or real home config.
|
||||
|
||||
Keep these in ignored local files:
|
||||
The public repository tracks a generic deployment script and sanitized example config, but does not track real home-specific values. Keep these in ignored local files under `config/`:
|
||||
|
||||
- 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
|
||||
- local deployment settings
|
||||
|
||||
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`.
|
||||
The sanitized example config is [`config.example/lights_config.py`](../config.example/lights_config.py). A real deployment can use an ignored config such as `config/lights_config.py`.
|
||||
|
||||
## Target Layout
|
||||
|
||||
@@ -22,20 +20,58 @@ 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/*.py`: runtime light/group/remote 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
|
||||
|
||||
## One Command Deployment
|
||||
|
||||
Start by copying the example config directory:
|
||||
|
||||
```bash
|
||||
mkdir -p config
|
||||
cp config.example/*.py config/
|
||||
cp config.example/service.env.example config/service.env
|
||||
cp config.example/deploy.env.example config/deploy.env
|
||||
```
|
||||
|
||||
Then edit:
|
||||
|
||||
- `config/lights_config.py` and the sibling Python files for your real lights, groups, and remotes.
|
||||
- `config/service.env` for runtime MQTT/deCONZ values.
|
||||
- `config/deploy.env` for the SSH target and remote install path.
|
||||
|
||||
Deploy or redeploy with:
|
||||
|
||||
```bash
|
||||
python deploy/deploy.py
|
||||
```
|
||||
|
||||
The script copies the app to the target, copies all `.py` files next to the configured `lights_config.py` entry point, copies service environment values, creates or reuses the target virtualenv, installs the package, installs the user systemd units, runs `systemctl --user daemon-reload`, and enables/starts `ha-deconz-bridge.service`.
|
||||
|
||||
Useful options:
|
||||
|
||||
```bash
|
||||
python deploy/deploy.py --config-only
|
||||
python deploy/deploy.py --host pi@raspberrypi.local
|
||||
python deploy/deploy.py --remote-dir ~/ha-deconz-bridge
|
||||
python deploy/deploy.py --with-speed
|
||||
python deploy/deploy.py --no-start
|
||||
```
|
||||
|
||||
`--config-only` copies only config/service env changes and restarts the service. Use it for normal light/config edits after the app is already installed.
|
||||
|
||||
## 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
|
||||
- the deploy script to replace `__INSTALL_DIR__` with the target install path
|
||||
- `<install-dir>/config/lights_config.py` and its imported sibling config files to exist
|
||||
- `<install-dir>/config/service.env` to exist
|
||||
- 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.
|
||||
The environment file should not be committed. Use [`config.example/service.env.example`](../config.example/service.env.example) or [`config.example/deploy.env.example`](../config.example/deploy.env.example) as a template.
|
||||
|
||||
## Manual Deployment Outline
|
||||
|
||||
@@ -46,11 +82,11 @@ The environment file should not be committed. Use [`deploy/service.env.example`]
|
||||
```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]'
|
||||
<install-dir>/.venv/bin/pip install "<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`.
|
||||
4. Copy a real ignored config directory to `<install-dir>/config/`.
|
||||
5. Copy and edit `config.example/service.env.example` to `<install-dir>/config/service.env`.
|
||||
6. Install the systemd user units from `deploy/`.
|
||||
7. Reload and start the service:
|
||||
|
||||
@@ -64,18 +100,13 @@ systemctl --user enable --now ha-deconz-bridge.service
|
||||
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/
|
||||
mkdir -p config
|
||||
cp config.example/*.py config/
|
||||
cp config.example/service.env.example config/service.env
|
||||
cp config.example/deploy.env.example config/deploy.env
|
||||
```
|
||||
|
||||
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.
|
||||
Edit the copied files for your real devices. The example entry point imports sibling files such as `actions.py`, `groups.py`, and `lights.py`, so the same imports continue to work after copying into `config/`. Then deploy or run with `config/lights_config.py`. Keep that path private and uncommitted.
|
||||
|
||||
## Required Runtime Values
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import sys
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "config.example"))
|
||||
|
||||
from ha_deconz_bridge.color import Color
|
||||
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
|
||||
|
||||
@@ -95,7 +95,7 @@ def test_double_long_state_is_per_physical_remote() -> None:
|
||||
|
||||
|
||||
def test_hold_count_gesture_patterns() -> None:
|
||||
action = ActionSpec("turn_off", target="Example Living")
|
||||
action = ActionSpec("turn_off", target="Living Room")
|
||||
engine = AutomationEngine(
|
||||
remotes={"4": RemoteSpec("4", "room", "Room remote")},
|
||||
rules=(
|
||||
@@ -124,7 +124,7 @@ def test_hold_count_gesture_patterns() -> None:
|
||||
|
||||
|
||||
def test_automation_rules_bind_to_internal_remote_id() -> None:
|
||||
action = ActionSpec("turn_off", target="Example Living")
|
||||
action = ActionSpec("turn_off", target="Living Room")
|
||||
engine = AutomationEngine(
|
||||
remotes={
|
||||
"4": RemoteSpec("4", "room", "First"),
|
||||
@@ -167,7 +167,7 @@ def test_time_condition_handles_midnight_wrap() -> None:
|
||||
|
||||
|
||||
def test_automation_rules_can_be_built_from_nested_config() -> None:
|
||||
action = ActionSpec("turn_off", target="Example Living")
|
||||
action = ActionSpec("turn_off", target="Living Room")
|
||||
|
||||
rules = automation_rules(
|
||||
{
|
||||
|
||||
@@ -72,8 +72,8 @@ def test_fixed_brightness_normalizes_relative_and_ha_values() -> None:
|
||||
|
||||
|
||||
def test_example_groups_reference_existing_lights() -> None:
|
||||
from configs.example_groups import GROUPS
|
||||
from configs.example_lights import LIGHTS
|
||||
from groups import GROUPS
|
||||
from lights import LIGHTS
|
||||
|
||||
missing = {
|
||||
group_name: [
|
||||
|
||||
+19
-19
@@ -253,10 +253,10 @@ def test_runtime_ha_command_updates_all_overlapping_virtual_lights(
|
||||
|
||||
|
||||
def test_load_config_module_by_path_supports_repo_config_imports() -> None:
|
||||
module = load_config_module("configs/example.py")
|
||||
module = load_config_module("config.example/lights_config.py")
|
||||
|
||||
assert "Example Mixed North" in module.LIGHTS
|
||||
assert "Example Living" in module.GROUPS
|
||||
assert "Living Room Ceiling" in module.LIGHTS
|
||||
assert "Living Room" in module.GROUPS
|
||||
|
||||
|
||||
def test_warmup_requests_cover_supported_modes(rgb_light_spec, ct_light_spec) -> None:
|
||||
@@ -682,9 +682,9 @@ def test_runtime_relative_group_readback_uses_aggregate_output(settings, monkeyp
|
||||
|
||||
|
||||
def test_example_room_scene_reports_requested_brightness_and_ct(settings, monkeypatch) -> None:
|
||||
from configs.example_actions import scene
|
||||
from configs.example_groups import GROUPS
|
||||
from configs.example_lights import LIGHTS
|
||||
from actions import scene
|
||||
from groups import GROUPS
|
||||
from lights import LIGHTS
|
||||
|
||||
resources = {
|
||||
controller_id: _resource_for_controller(controller)
|
||||
@@ -705,9 +705,9 @@ def test_example_room_scene_reports_requested_brightness_and_ct(settings, monkey
|
||||
(4000, 255, 250),
|
||||
(5500, 255, 182),
|
||||
):
|
||||
app._run_action(scene("Example Kitchen", brightness=brightness, kelvin=kelvin))
|
||||
app._run_action(scene("Kitchen", brightness=brightness, kelvin=kelvin))
|
||||
|
||||
state = app.group_lights["Example Kitchen"].state
|
||||
state = app.group_lights["Kitchen"].state
|
||||
assert state.brightness == brightness
|
||||
assert state.color.color_temp == mireds
|
||||
|
||||
@@ -716,9 +716,9 @@ def test_example_group_readback_uses_aggregate_member_output(
|
||||
settings,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from configs.example_actions import scene
|
||||
from configs.example_groups import GROUPS
|
||||
from configs.example_lights import LIGHTS
|
||||
from actions import scene
|
||||
from groups import GROUPS
|
||||
from lights import LIGHTS
|
||||
|
||||
resources = {
|
||||
controller_id: _resource_for_controller(controller)
|
||||
@@ -730,21 +730,21 @@ def test_example_group_readback_uses_aggregate_member_output(
|
||||
monkeypatch.setattr("ha_deconz_bridge.app.HomeAssistantBridge", lambda _settings: RecordingBridge())
|
||||
app = HaDeconzBridgeApp(settings, LIGHTS, groups=GROUPS)
|
||||
|
||||
app._run_action(scene("Example Living", brightness=51, kelvin=2200))
|
||||
app.group_lights["Example Living"].clear_accepted()
|
||||
state = app.group_lights["Example Living"].handle_member_update()
|
||||
app._run_action(scene("Living Room", brightness=51, kelvin=2200))
|
||||
app.group_lights["Living Room"].clear_accepted()
|
||||
state = app.group_lights["Living Room"].handle_member_update()
|
||||
|
||||
assert 45 <= state.brightness <= 60
|
||||
assert app.lights["Example Plug A"].state.brightness == 255
|
||||
assert app.lights["Example Plug B"].state.brightness == 255
|
||||
assert app.lights["Floor Lamp Plug"].state.brightness == 255
|
||||
assert app.lights["Desk Lamp Plug"].state.brightness == 255
|
||||
|
||||
|
||||
def test_example_native_ct_member_reports_requested_ct(settings) -> None:
|
||||
from configs.example_lights import LIGHTS
|
||||
from lights import LIGHTS
|
||||
|
||||
light, _bridge, _backend = _build_light(LIGHTS["Example Bedroom West"], settings)
|
||||
light, _bridge, _backend = _build_light(LIGHTS["Bedroom Left Lamp"], settings)
|
||||
|
||||
state = light.handle_ha_command(HaCommandEvent("Example Bedroom West", on=True, brightness=153, color_temp=333))
|
||||
state = light.handle_ha_command(HaCommandEvent("Bedroom Left Lamp", on=True, brightness=153, color_temp=333))
|
||||
|
||||
assert state.brightness == 153
|
||||
assert state.color.color_temp == 333
|
||||
|
||||
+34
-34
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from configs.example import GROUPS, LIGHTS
|
||||
from lights_config 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
|
||||
@@ -202,7 +202,7 @@ def test_onoff_booster_is_held_back_until_dimmables_are_exhausted() -> None:
|
||||
|
||||
|
||||
def test_brightness_mode_compensates_when_onoff_booster_turns_on() -> None:
|
||||
spec = _combine_specs(LIGHTS, ["Example Plug A", "Example Fixed White A"])
|
||||
spec = _combine_specs(LIGHTS, ["Floor Lamp Plug", "Plant Light Left"])
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_rgb((255, 255, 255), brightness=1.0)
|
||||
|
||||
@@ -407,7 +407,7 @@ def test_ct_line_balancing_uses_comparable_same_label_channels_stably() -> None:
|
||||
|
||||
|
||||
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"])
|
||||
spec = _combine_specs(LIGHTS, ["Plant Light Left", "Living Room Ceiling"])
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_rgb((255, 255, 255), brightness=1.0)
|
||||
|
||||
@@ -418,19 +418,19 @@ def test_rgb_request_does_not_use_white_boost_when_rgb_labeled_channels_exist()
|
||||
|
||||
|
||||
def test_example_fixed_white_controller_uses_full_ct_command_range() -> None:
|
||||
controller = LIGHTS["Example Fixed White A"].controllers["fixed_a"]
|
||||
controller = LIGHTS["Plant Light Left"].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 light_name in ["Living Room Ceiling", "Hallway Floor Light", "Hallway Corner Light", "Hallway Cabinet Lights"]:
|
||||
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"]
|
||||
spec = LIGHTS["Kitchen Counter Lights"]
|
||||
result = solve_light(
|
||||
compile_light_model(spec),
|
||||
Color.from_color_temp(455, brightness=1.0),
|
||||
@@ -542,7 +542,7 @@ def test_ct_slack_does_not_use_near_ct_when_outside_band() -> None:
|
||||
|
||||
|
||||
def test_wc_ct_sweep_stays_near_requested_ct_line_through_middle_range() -> None:
|
||||
spec = _group_debug_spec(GROUPS["Example Bathroom"], LIGHTS)
|
||||
spec = _group_debug_spec(GROUPS["Bathroom"], LIGHTS)
|
||||
model = compile_light_model(spec)
|
||||
|
||||
for mireds in [220, 250, 286, 320, 360]:
|
||||
@@ -559,7 +559,7 @@ def test_wc_ct_sweep_stays_near_requested_ct_line_through_middle_range() -> None
|
||||
|
||||
|
||||
def test_wc_low_brightness_ct_can_use_rgb_correction_without_redefining_reference() -> None:
|
||||
spec = _group_debug_spec(GROUPS["Example Bathroom"], LIGHTS)
|
||||
spec = _group_debug_spec(GROUPS["Bathroom"], LIGHTS)
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_color_temp(286, brightness=1.0, temp_range=spec.exposed_mireds)
|
||||
|
||||
@@ -573,7 +573,7 @@ def test_wc_low_brightness_ct_can_use_rgb_correction_without_redefining_referenc
|
||||
|
||||
|
||||
def test_wc_katto_low_brightness_ct_can_sacrifice_one_bulb_for_rgb_correction() -> None:
|
||||
spec = LIGHTS["Example Bathroom Ceiling"]
|
||||
spec = LIGHTS["Bathroom Ceiling Light"]
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_color_temp(286, brightness=1.0, temp_range=spec.exposed_mireds)
|
||||
|
||||
@@ -593,7 +593,7 @@ def test_wc_katto_low_brightness_ct_can_sacrifice_one_bulb_for_rgb_correction()
|
||||
|
||||
|
||||
def test_wc_katto_balances_equivalent_channels() -> None:
|
||||
spec = LIGHTS["Example Bathroom Ceiling"]
|
||||
spec = LIGHTS["Bathroom Ceiling Light"]
|
||||
model = compile_light_model(spec)
|
||||
target = Color.from_color_temp(300, brightness=1.0, temp_range=spec.exposed_mireds)
|
||||
|
||||
@@ -614,14 +614,14 @@ 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",
|
||||
"Plant Light Left",
|
||||
"Plant Light Right",
|
||||
"Living Room Ceiling",
|
||||
"Floor Lamp Plug",
|
||||
"Desk Lamp Plug",
|
||||
"Hallway Floor Light",
|
||||
"Hallway Corner Light",
|
||||
"Hallway Cabinet Lights",
|
||||
],
|
||||
)
|
||||
model = compile_light_model(spec)
|
||||
@@ -632,14 +632,14 @@ 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",
|
||||
"Plant Light Left",
|
||||
"Plant Light Right",
|
||||
"Living Room Ceiling",
|
||||
"Floor Lamp Plug",
|
||||
"Desk Lamp Plug",
|
||||
"Hallway Floor Light",
|
||||
"Hallway Corner Light",
|
||||
"Hallway Cabinet Lights",
|
||||
],
|
||||
)
|
||||
model = compile_light_model(spec)
|
||||
@@ -650,14 +650,14 @@ def test_example_all_lights_ct_sweep_does_not_use_unused_onoff_reference() -> No
|
||||
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",
|
||||
"Plant Light Left",
|
||||
"Plant Light Right",
|
||||
"Living Room Ceiling",
|
||||
"Floor Lamp Plug",
|
||||
"Desk Lamp Plug",
|
||||
"Hallway Floor Light",
|
||||
"Hallway Corner Light",
|
||||
"Hallway Cabinet Lights",
|
||||
],
|
||||
)
|
||||
model = compile_light_model(spec)
|
||||
|
||||
Reference in New Issue
Block a user