Restructure public config and deployment

This commit is contained in:
ajp_anton
2026-05-30 19:17:27 +00:00
parent cc4e0c2c0f
commit cdf1f4e3a1
24 changed files with 710 additions and 302 deletions
+2 -1
View File
@@ -4,8 +4,9 @@ __pycache__/
*.pyc *.pyc
*.egg-info/ *.egg-info/
old attempt/ old attempt/
config/
configs/local/ configs/local/
scripts/deploy.py scripts/*.local.py
*.env *.env
*.env.local *.env.local
*.local *.local
+88 -25
View File
@@ -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. 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 - Python 3.11 or newer.
- [`docs/architecture.md`](./docs/architecture.md): package structure and data flow - Home Assistant with MQTT discovery enabled.
- [`docs/configuration.md`](./docs/configuration.md): typed light definitions - deCONZ/Phoscon already controlling the physical lights.
- [`docs/deployment.md`](./docs/deployment.md): Raspberry Pi deploy and service flow - SSH access to the Raspberry Pi or other Linux host if you want to deploy it as a service.
- [`docs/debugger.md`](./docs/debugger.md): shared-core simulator
- [`docs/plan.md`](./docs/plan.md): implementation plan that drove this reboot
## 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 ```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 ```bash
pytest python3 -m venv .venv
``` ```
Create a private config from the public example: 2. Install the program into that environment:
```bash ```bash
mkdir -p configs/local .venv/bin/pip install .
cp configs/example.py configs/local/lights_config.py
cp configs/example_*.py configs/local/
``` ```
Edit `configs/local/lights_config.py` and the copied split files for your real controller IDs, light names, groups, and calibration values. Files under `configs/local/` are ignored by Git. 3. Load the connection settings and run the service:
Run the service with a Python config path that exports `SETTINGS` and `LIGHTS`:
```bash ```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 ```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, ...]: def all_lights_scene(*, brightness: int, kelvin: int) -> tuple[ActionSpec, ...]:
return ( return (
scene("Example Living", brightness=brightness, kelvin=kelvin), scene("Living Room", brightness=brightness, kelvin=kelvin),
scene("Example Hall", brightness=brightness, kelvin=kelvin), scene("Hallway", brightness=brightness, kelvin=kelvin),
scene("Example Kitchen", brightness=brightness, kelvin=kelvin), scene("Kitchen", brightness=brightness, kelvin=kelvin),
scene("Example Bathroom", brightness=brightness, kelvin=kelvin), scene("Bathroom", brightness=brightness, kelvin=kelvin),
scene("Example Bedroom", brightness=brightness, kelvin=kelvin), scene("Bedroom", brightness=brightness, kelvin=kelvin),
) )
def all_lights_off() -> tuple[ActionSpec, ...]: def all_lights_off() -> tuple[ActionSpec, ...]:
return ( return (
ActionSpec("turn_off", target="Example Living"), ActionSpec("turn_off", target="Living Room"),
ActionSpec("turn_off", target="Example Hall"), ActionSpec("turn_off", target="Hallway"),
ActionSpec("turn_off", target="Example Kitchen"), ActionSpec("turn_off", target="Kitchen"),
ActionSpec("turn_off", target="Example Bathroom"), ActionSpec("turn_off", target="Bathroom"),
ActionSpec("turn_off", target="Example Bedroom"), ActionSpec("turn_off", target="Bedroom"),
) )
@@ -60,11 +60,11 @@ def room_automation(room: str) -> dict[str, dict[str, tuple[ActionSpec, ...]]]:
AUTOMATION_CONFIG = { AUTOMATION_CONFIG = {
"example_living_remote": room_automation("Example Living"), "example_living_remote": room_automation("Living Room"),
"example_kitchen_remote": room_automation("Example Kitchen"), "example_kitchen_remote": room_automation("Kitchen"),
"example_hall_remote": room_automation("Example Hall"), "example_hall_remote": room_automation("Hallway"),
"example_bathroom_remote": room_automation("Example Bathroom"), "example_bathroom_remote": room_automation("Bathroom"),
"example_bedroom_remote": room_automation("Example Bedroom"), "example_bedroom_remote": room_automation("Bedroom"),
} }
AUTOMATIONS = automation_rules(AUTOMATION_CONFIG) AUTOMATIONS = automation_rules(AUTOMATION_CONFIG)
+35
View File
@@ -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
+42
View File
@@ -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, CT_RANGE,
DAYLIGHT_MIREDS, DAYLIGHT_MIREDS,
RGB_BLUE, RGB_BLUE,
@@ -10,11 +10,11 @@ from configs.example_controllers import (
LIGHTS = define_lights( LIGHTS = define_lights(
{ {
"Example Fixed White A": { "Plant Light Left": {
"supported_color_modes": ("brightness",), "supported_color_modes": ("brightness",),
"controllers": { "controllers": {
"fixed_a": { "fixed_a": {
"name": "Example Fixed White A", "name": "Plant Light Left",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"color_temp": { "color_temp": {
"ww": {"color": DAYLIGHT_MIREDS, "brightness": 9000}, "ww": {"color": DAYLIGHT_MIREDS, "brightness": 9000},
@@ -22,11 +22,11 @@ LIGHTS = define_lights(
}, },
}, },
}, },
"Example Fixed White B": { "Plant Light Right": {
"supported_color_modes": ("brightness",), "supported_color_modes": ("brightness",),
"controllers": { "controllers": {
"fixed_b": { "fixed_b": {
"name": "Example Fixed White B", "name": "Plant Light Right",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"color_temp": { "color_temp": {
"ww": {"color": DAYLIGHT_MIREDS, "brightness": 7000}, "ww": {"color": DAYLIGHT_MIREDS, "brightness": 7000},
@@ -34,33 +34,33 @@ LIGHTS = define_lights(
}, },
}, },
}, },
"Example Plug A": { "Floor Lamp Plug": {
"supported_color_modes": ("onoff",), "supported_color_modes": ("onoff",),
"controllers": { "controllers": {
"plug_a": { "plug_a": {
"name": "Example Plug A", "name": "Floor Lamp Plug",
"onoff": { "onoff": {
"w": {"color": DAYLIGHT_MIREDS, "brightness": 8000, "label": "color_temp"}, "w": {"color": DAYLIGHT_MIREDS, "brightness": 8000, "label": "color_temp"},
}, },
}, },
}, },
}, },
"Example Plug B": { "Desk Lamp Plug": {
"supported_color_modes": ("onoff",), "supported_color_modes": ("onoff",),
"controllers": { "controllers": {
"plug_b": { "plug_b": {
"name": "Example Plug B", "name": "Desk Lamp Plug",
"onoff": { "onoff": {
"w": {"color": DAYLIGHT_MIREDS, "brightness": 8000, "label": "color_temp"}, "w": {"color": DAYLIGHT_MIREDS, "brightness": 8000, "label": "color_temp"},
}, },
}, },
}, },
}, },
"Example Mixed North": { "Living Room Ceiling": {
"supported_color_modes": ("rgb", "color_temp"), "supported_color_modes": ("rgb", "color_temp"),
"controllers": { "controllers": {
"mixed_rgb": { "mixed_rgb": {
"name": "Example Mixed North RGB", "name": "Living Room Ceiling RGB",
"rgb": { "rgb": {
"r": {"color": RGB_RED, "brightness": 3000}, "r": {"color": RGB_RED, "brightness": 3000},
"g": {"color": RGB_GREEN, "brightness": 2480}, "g": {"color": RGB_GREEN, "brightness": 2480},
@@ -68,7 +68,7 @@ LIGHTS = define_lights(
}, },
}, },
"mixed_ct": { "mixed_ct": {
"name": "Example Mixed North CCT", "name": "Living Room Ceiling CCT",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"color_temp": { "color_temp": {
"ww": {"color": 500, "brightness": 25600}, "ww": {"color": 500, "brightness": 25600},
@@ -77,11 +77,11 @@ LIGHTS = define_lights(
}, },
}, },
}, },
"Example Hall Floor": { "Hallway Floor Light": {
"supported_color_modes": ("rgb", "color_temp"), "supported_color_modes": ("rgb", "color_temp"),
"controllers": { "controllers": {
"hall_floor": { "hall_floor": {
"name": "Example Hall Floor", "name": "Hallway Floor Light",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"rgb": { "rgb": {
"r": {"color": RGB_RED, "brightness": 2460}, "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"), "supported_color_modes": ("rgb", "color_temp"),
"controllers": { "controllers": {
"hall_corner": { "hall_corner": {
"name": "Example Hall Corner", "name": "Hallway Corner Light",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"rgb": { "rgb": {
"r": {"color": RGB_RED, "brightness": 820}, "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"), "supported_color_modes": ("rgb", "color_temp"),
"controllers": { "controllers": {
"hall_cabinets": { "hall_cabinets": {
"name": "Example Hall Cabinets", "name": "Hallway Cabinet Lights",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"rgb": { "rgb": {
"r": {"color": RGB_RED, "brightness": 2460}, "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"), "supported_color_modes": ("rgb", "color_temp"),
"controllers": { "controllers": {
"kitchen_counter": { "kitchen_counter": {
"name": "Example Kitchen Counter", "name": "Kitchen Counter Lights",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"rgb": { "rgb": {
"r": {"color": RGB_RED, "brightness": 1800}, "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"), "supported_color_modes": ("rgb", "color_temp"),
"controllers": { "controllers": {
"bath_ceiling_1": { "bath_ceiling_1": {
"name": "Example Bathroom Ceiling 1", "name": "Bathroom Ceiling Light 1",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"rgb": { "rgb": {
"r": {"color": RGB_RED, "brightness": 900}, "r": {"color": RGB_RED, "brightness": 900},
@@ -166,7 +166,7 @@ LIGHTS = define_lights(
}, },
}, },
"bath_ceiling_2": { "bath_ceiling_2": {
"name": "Example Bathroom Ceiling 2", "name": "Bathroom Ceiling Light 2",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"rgb": { "rgb": {
"r": {"color": RGB_RED, "brightness": 900}, "r": {"color": RGB_RED, "brightness": 900},
@@ -179,7 +179,7 @@ LIGHTS = define_lights(
}, },
}, },
"bath_ceiling_3": { "bath_ceiling_3": {
"name": "Example Bathroom Ceiling 3", "name": "Bathroom Ceiling Light 3",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"rgb": { "rgb": {
"r": {"color": RGB_RED, "brightness": 900}, "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"), "supported_color_modes": ("rgb", "color_temp"),
"controllers": { "controllers": {
"bath_mirror": { "bath_mirror": {
"name": "Example Bathroom Mirror", "name": "Bathroom Mirror Light",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"rgb": { "rgb": {
"r": {"color": RGB_RED, "brightness": 4100}, "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"), "supported_color_modes": ("rgb", "color_temp"),
"controllers": { "controllers": {
"bedroom_w": { "bedroom_w": {
"name": "Example Bedroom West", "name": "Bedroom Left Lamp",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"rgb": { "rgb": {
"r": {"color": RGB_RED, "brightness": 1100}, "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"), "supported_color_modes": ("rgb", "color_temp"),
"controllers": { "controllers": {
"bedroom_e": { "bedroom_e": {
"name": "Example Bedroom East", "name": "Bedroom Right Lamp",
"mireds": CT_RANGE, "mireds": CT_RANGE,
"rgb": { "rgb": {
"r": {"color": RGB_RED, "brightness": 1100}, "r": {"color": RGB_RED, "brightness": 1100},
+9
View File
@@ -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( "example_sensor_1": RemoteSpec(
sensor_id="example_sensor_1", sensor_id="example_sensor_1",
remote_id="example_living_remote", remote_id="example_living_remote",
name="Example living remote", name="Living room remote",
model="RWL021", model="RWL021",
), ),
"example_sensor_2": RemoteSpec( "example_sensor_2": RemoteSpec(
sensor_id="example_sensor_2", sensor_id="example_sensor_2",
remote_id="example_kitchen_remote", remote_id="example_kitchen_remote",
name="Example kitchen remote", name="Kitchen remote",
model="RWL021", model="RWL021",
), ),
} }
@@ -23,6 +23,6 @@ HA_DECONZ_BRIDGE_APPROXIMATE_CHROMA_BAND=0.025
HA_DECONZ_BRIDGE_WARMUP_ON_STARTUP=true HA_DECONZ_BRIDGE_WARMUP_ON_STARTUP=true
# Numeric backend for color solves. Keep "scipy" unless optional speed # 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. # Allowed values: scipy, numba, auto.
HA_DECONZ_BRIDGE_NUMERIC_BACKEND=scipy HA_DECONZ_BRIDGE_NUMERIC_BACKEND=scipy
-9
View File
@@ -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()
-42
View File
@@ -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",
),
}
+333
View File
@@ -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())
+3 -3
View File
@@ -5,9 +5,9 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=%h/ha-deconz-bridge/app WorkingDirectory=__INSTALL_DIR__/app
EnvironmentFile=%h/ha-deconz-bridge/config/service.env EnvironmentFile=__INSTALL_DIR__/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 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 Restart=on-failure
[Install] [Install]
+4 -4
View File
@@ -5,11 +5,11 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=%h/ha-deconz-bridge/app WorkingDirectory=__INSTALL_DIR__/app
EnvironmentFile=-%h/ha-deconz-bridge/config/service.env EnvironmentFile=-__INSTALL_DIR__/config/service.env
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
Environment=HA_DECONZ_BRIDGE_LOG_FILE=%h/ha-deconz-bridge/ha-deconz-bridge.log Environment=HA_DECONZ_BRIDGE_LOG_FILE=__INSTALL_DIR__/ha-deconz-bridge.log
ExecStart=%h/ha-deconz-bridge/.venv/bin/ha-deconz-bridge-service %h/ha-deconz-bridge/config/lights_config.py ExecStart=__INSTALL_DIR__/.venv/bin/ha-deconz-bridge-service __INSTALL_DIR__/config/lights_config.py
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
StandardOutput=journal StandardOutput=journal
-57
View File
@@ -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
View File
@@ -14,7 +14,7 @@ A runtime config module should export:
## Example ## Example
```python ```python
from configs.example_controllers import define_lights from controllers import define_lights
LIGHTS = define_lights({ LIGHTS = define_lights({
"living_room": { "living_room": {
@@ -92,29 +92,30 @@ Numba is an optional dependency. Install it with the `speed` extra before select
## Config File Layout ## 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. - `config.example/controllers.py`: physical controller/channel helper functions and constants.
- `configs/example_lights.py`: individual virtual lights and which controllers they abstract. - `config.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. - `config.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. - `config.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/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: For a private local setup, copy the public example into the ignored local directory:
```bash ```bash
mkdir -p configs/local mkdir -p config
cp configs/example.py configs/local/lights_config.py cp config.example/*.py config/
cp configs/example_*.py configs/local/ 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 ```bash
ha-deconz-bridge-service configs/local/lights_config.py ha-deconz-bridge-service config/lights_config.py
ha-deconz-bridge-debug-web configs/local/lights_config.py "Example Mixed North" 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. 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 from ha_deconz_bridge.groups import GroupMemberSpec, GroupSpec
GROUPS = { GROUPS = {
"Example Plant": GroupSpec( "Plant Lights": GroupSpec(
name="Example Plant", name="Plant Lights",
members=( members=(
"Example Fixed White A", "Plant Light Left",
"Example Fixed White B", "Plant Light Right",
GroupMemberSpec("Example Mixed North", fixed_color=182), GroupMemberSpec("Living Room Ceiling", fixed_color=182),
), ),
balance="relative_member", balance="relative_member",
), ),
@@ -156,7 +157,7 @@ REMOTES = {
"example_sensor_1": RemoteSpec( "example_sensor_1": RemoteSpec(
sensor_id="example_sensor_1", sensor_id="example_sensor_1",
remote_id="example_living", remote_id="example_living",
name="Example living remote", name="Living room remote",
model="RWL021", model="RWL021",
double_press_window=0.45, # optional override double_press_window=0.45, # optional override
), ),
@@ -165,13 +166,13 @@ REMOTES = {
AUTOMATION_CONFIG = { AUTOMATION_CONFIG = {
"example_living": { "example_living": {
"top": { "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": { "bottom": {
"single": (ActionSpec("turn_off", target="Example Living"),), "single": (ActionSpec("turn_off", target="Living Room"),),
}, },
"up": { "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. 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. - `Hallway`: three RGB+CCT virtual lights.
- `Example Kitchen`: one RGB+CCT virtual light. - `Kitchen`: one RGB+CCT virtual light.
- `Example Bathroom`: one multi-controller ceiling light and one mirror light. - `Bathroom`: one multi-controller ceiling light and one mirror light.
- `Example Bedroom`: two RGB+CCT virtual lights. - `Bedroom`: two RGB+CCT virtual lights.
- `Example Living`: fixed-white, mixed RGB/CCT, and on/off examples. - `Living Room`: fixed-white, mixed RGB/CCT, and on/off examples.
- `Example Plant`: fixed-white examples plus one constrained mixed light, using relative member brightness balancing. - `Plant Lights`: fixed-white lights plus one constrained mixed light, using relative member brightness balancing.
+2 -2
View File
@@ -29,7 +29,7 @@ Both variants:
Run: Run:
```bash ```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. 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: For real hardware, pass your ignored local config instead:
```bash ```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: Inside the UI:
+53 -22
View File
@@ -4,17 +4,15 @@ This project is designed to run as a user-owned Python app on a small Linux host
## Public Repo Policy ## Public Repo Policy
The public repository intentionally does not track a real deployment script or real home config. 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/`:
Keep these in ignored local files:
- real SSH hostnames, domains, and usernames - real SSH hostnames, domains, and usernames
- real remote install paths - real remote install paths
- real room, light, group, and remote names - real room, light, group, and remote names
- real deCONZ IDs and API keys - 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 ## Target Layout
@@ -22,20 +20,58 @@ A typical target machine can use this layout:
- `<install-dir>/app`: synced application code - `<install-dir>/app`: synced application code
- `<install-dir>/.venv`: virtualenv used by the service - `<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 - `<install-dir>/config/service.env`: runtime environment variables
- `~/.config/systemd/user/ha-deconz-bridge.service`: user service unit - `~/.config/systemd/user/ha-deconz-bridge.service`: user service unit
- `~/.config/systemd/user/ha-deconz-bridge-debug.service`: optional web debugger service - `~/.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 ## Service Units
The generic user service templates are tracked in [`deploy/`](../deploy/). They expect: The generic user service templates are tracked in [`deploy/`](../deploy/). They expect:
- `HA_DECONZ_BRIDGE_CONFIG` to point at the runtime config file - the deploy script to replace `__INSTALL_DIR__` with the target install path
- `HA_DECONZ_BRIDGE_SERVICE_ENV` or an equivalent environment file to provide secrets and host-specific settings - `<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 - 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 ## Manual Deployment Outline
@@ -46,11 +82,11 @@ The environment file should not be committed. Use [`deploy/service.env.example`]
```bash ```bash
python3 -m venv <install-dir>/.venv python3 -m venv <install-dir>/.venv
<install-dir>/.venv/bin/pip install --upgrade pip <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`. 4. Copy a real ignored config directory to `<install-dir>/config/`.
5. Copy and edit `deploy/service.env.example` to `<install-dir>/config/service.env`. 5. Copy and edit `config.example/service.env.example` to `<install-dir>/config/service.env`.
6. Install the systemd user units from `deploy/`. 6. Install the systemd user units from `deploy/`.
7. Reload and start the service: 7. Reload and start the service:
@@ -64,18 +100,13 @@ systemctl --user enable --now ha-deconz-bridge.service
Start from the public example: Start from the public example:
```bash ```bash
mkdir -p configs/local mkdir -p config
cp configs/example.py configs/local/lights_config.py cp config.example/*.py config/
cp configs/example_*.py configs/local/ 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: 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.
```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 ## Required Runtime Values
+1
View File
@@ -6,6 +6,7 @@ import sys
import pytest import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) 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.color import Color
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec
+3 -3
View File
@@ -95,7 +95,7 @@ def test_double_long_state_is_per_physical_remote() -> None:
def test_hold_count_gesture_patterns() -> None: def test_hold_count_gesture_patterns() -> None:
action = ActionSpec("turn_off", target="Example Living") action = ActionSpec("turn_off", target="Living Room")
engine = AutomationEngine( engine = AutomationEngine(
remotes={"4": RemoteSpec("4", "room", "Room remote")}, remotes={"4": RemoteSpec("4", "room", "Room remote")},
rules=( rules=(
@@ -124,7 +124,7 @@ def test_hold_count_gesture_patterns() -> None:
def test_automation_rules_bind_to_internal_remote_id() -> 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( engine = AutomationEngine(
remotes={ remotes={
"4": RemoteSpec("4", "room", "First"), "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: 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( rules = automation_rules(
{ {
+2 -2
View File
@@ -72,8 +72,8 @@ def test_fixed_brightness_normalizes_relative_and_ha_values() -> None:
def test_example_groups_reference_existing_lights() -> None: def test_example_groups_reference_existing_lights() -> None:
from configs.example_groups import GROUPS from groups import GROUPS
from configs.example_lights import LIGHTS from lights import LIGHTS
missing = { missing = {
group_name: [ group_name: [
+19 -19
View File
@@ -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: 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 "Living Room Ceiling" in module.LIGHTS
assert "Example Living" in module.GROUPS assert "Living Room" in module.GROUPS
def test_warmup_requests_cover_supported_modes(rgb_light_spec, ct_light_spec) -> None: 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: def test_example_room_scene_reports_requested_brightness_and_ct(settings, monkeypatch) -> None:
from configs.example_actions import scene from actions import scene
from configs.example_groups import GROUPS from groups import GROUPS
from configs.example_lights import LIGHTS from lights import LIGHTS
resources = { resources = {
controller_id: _resource_for_controller(controller) 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), (4000, 255, 250),
(5500, 255, 182), (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.brightness == brightness
assert state.color.color_temp == mireds assert state.color.color_temp == mireds
@@ -716,9 +716,9 @@ def test_example_group_readback_uses_aggregate_member_output(
settings, settings,
monkeypatch, monkeypatch,
) -> None: ) -> None:
from configs.example_actions import scene from actions import scene
from configs.example_groups import GROUPS from groups import GROUPS
from configs.example_lights import LIGHTS from lights import LIGHTS
resources = { resources = {
controller_id: _resource_for_controller(controller) 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()) monkeypatch.setattr("ha_deconz_bridge.app.HomeAssistantBridge", lambda _settings: RecordingBridge())
app = HaDeconzBridgeApp(settings, LIGHTS, groups=GROUPS) app = HaDeconzBridgeApp(settings, LIGHTS, groups=GROUPS)
app._run_action(scene("Example Living", brightness=51, kelvin=2200)) app._run_action(scene("Living Room", brightness=51, kelvin=2200))
app.group_lights["Example Living"].clear_accepted() app.group_lights["Living Room"].clear_accepted()
state = app.group_lights["Example Living"].handle_member_update() state = app.group_lights["Living Room"].handle_member_update()
assert 45 <= state.brightness <= 60 assert 45 <= state.brightness <= 60
assert app.lights["Example Plug A"].state.brightness == 255 assert app.lights["Floor Lamp Plug"].state.brightness == 255
assert app.lights["Example Plug B"].state.brightness == 255 assert app.lights["Desk Lamp Plug"].state.brightness == 255
def test_example_native_ct_member_reports_requested_ct(settings) -> None: 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.brightness == 153
assert state.color.color_temp == 333 assert state.color.color_temp == 333
+34 -34
View File
@@ -1,6 +1,6 @@
from __future__ import annotations 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.color import Color
from ha_deconz_bridge.debugger.web_debugger import _combine_specs, _group_debug_spec from ha_deconz_bridge.debugger.web_debugger import _combine_specs, _group_debug_spec
from ha_deconz_bridge.lights import ChannelSpec, ControllerModeSpec, ControllerSpec, VirtualLightSpec 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: 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) model = compile_light_model(spec)
target = Color.from_rgb((255, 255, 255), brightness=1.0) 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: 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) model = compile_light_model(spec)
target = Color.from_rgb((255, 255, 255), brightness=1.0) 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: 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) assert controller.native_ct_mireds == (153, 500)
def test_example_standard_ct_controllers_use_configured_ct_command_range() -> None: 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(): for controller in LIGHTS[light_name].controllers.values():
if "color_temp" in controller.modes: if "color_temp" in controller.modes:
assert controller.native_ct_mireds == (153, 500) assert controller.native_ct_mireds == (153, 500)
def test_ct_request_uses_native_ct_mode_on_rgb_cct_controller() -> None: 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( result = solve_light(
compile_light_model(spec), compile_light_model(spec),
Color.from_color_temp(455, brightness=1.0), 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: 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) model = compile_light_model(spec)
for mireds in [220, 250, 286, 320, 360]: 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: 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) model = compile_light_model(spec)
target = Color.from_color_temp(286, brightness=1.0, temp_range=spec.exposed_mireds) 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: 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) model = compile_light_model(spec)
target = Color.from_color_temp(286, brightness=1.0, temp_range=spec.exposed_mireds) 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: def test_wc_katto_balances_equivalent_channels() -> None:
spec = LIGHTS["Example Bathroom Ceiling"] spec = LIGHTS["Bathroom Ceiling Light"]
model = compile_light_model(spec) model = compile_light_model(spec)
target = Color.from_color_temp(300, brightness=1.0, temp_range=spec.exposed_mireds) 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( spec = _combine_specs(
LIGHTS, LIGHTS,
[ [
"Example Fixed White A", "Plant Light Left",
"Example Fixed White B", "Plant Light Right",
"Example Mixed North", "Living Room Ceiling",
"Example Plug A", "Floor Lamp Plug",
"Example Plug B", "Desk Lamp Plug",
"Example Hall Floor", "Hallway Floor Light",
"Example Hall Corner", "Hallway Corner Light",
"Example Hall Cabinets", "Hallway Cabinet Lights",
], ],
) )
model = compile_light_model(spec) model = compile_light_model(spec)
@@ -632,14 +632,14 @@ def test_example_all_lights_deduplicates_reference_combinations() -> None:
spec = _combine_specs( spec = _combine_specs(
LIGHTS, LIGHTS,
[ [
"Example Fixed White A", "Plant Light Left",
"Example Fixed White B", "Plant Light Right",
"Example Mixed North", "Living Room Ceiling",
"Example Plug A", "Floor Lamp Plug",
"Example Plug B", "Desk Lamp Plug",
"Example Hall Floor", "Hallway Floor Light",
"Example Hall Corner", "Hallway Corner Light",
"Example Hall Cabinets", "Hallway Cabinet Lights",
], ],
) )
model = compile_light_model(spec) 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( spec = _combine_specs(
LIGHTS, LIGHTS,
[ [
"Example Fixed White A", "Plant Light Left",
"Example Fixed White B", "Plant Light Right",
"Example Mixed North", "Living Room Ceiling",
"Example Plug A", "Floor Lamp Plug",
"Example Plug B", "Desk Lamp Plug",
"Example Hall Floor", "Hallway Floor Light",
"Example Hall Corner", "Hallway Corner Light",
"Example Hall Cabinets", "Hallway Cabinet Lights",
], ],
) )
model = compile_light_model(spec) model = compile_light_model(spec)