diff --git a/.gitignore b/.gitignore index 2519094..477b2c2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ __pycache__/ *.pyc *.egg-info/ old attempt/ +config/ configs/local/ -scripts/deploy.py +scripts/*.local.py *.env *.env.local *.local diff --git a/README.md b/README.md index 9d2d590..4375e28 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/configs/example_actions.py b/config.example/actions.py similarity index 63% rename from configs/example_actions.py rename to config.example/actions.py index c0d8343..4eac68e 100644 --- a/configs/example_actions.py +++ b/config.example/actions.py @@ -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) diff --git a/configs/example_controllers.py b/config.example/controllers.py similarity index 100% rename from configs/example_controllers.py rename to config.example/controllers.py diff --git a/config.example/deploy.env.example b/config.example/deploy.env.example new file mode 100644 index 0000000..2dc7441 --- /dev/null +++ b/config.example/deploy.env.example @@ -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 diff --git a/config.example/groups.py b/config.example/groups.py new file mode 100644 index 0000000..e04cf4c --- /dev/null +++ b/config.example/groups.py @@ -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", + ), +} diff --git a/configs/example_lights.py b/config.example/lights.py similarity index 86% rename from configs/example_lights.py rename to config.example/lights.py index 8e020c4..7a0b524 100644 --- a/configs/example_lights.py +++ b/config.example/lights.py @@ -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}, diff --git a/config.example/lights_config.py b/config.example/lights_config.py new file mode 100644 index 0000000..c4f4011 --- /dev/null +++ b/config.example/lights_config.py @@ -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() diff --git a/configs/example_remotes.py b/config.example/remotes.py similarity index 82% rename from configs/example_remotes.py rename to config.example/remotes.py index ab5b1b3..1c36968 100644 --- a/configs/example_remotes.py +++ b/config.example/remotes.py @@ -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", ), } diff --git a/deploy/service.env.example b/config.example/service.env.example similarity index 93% rename from deploy/service.env.example rename to config.example/service.env.example index 9cc6696..47927fd 100644 --- a/deploy/service.env.example +++ b/config.example/service.env.example @@ -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 diff --git a/configs/example.py b/configs/example.py deleted file mode 100644 index d43524d..0000000 --- a/configs/example.py +++ /dev/null @@ -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() diff --git a/configs/example_groups.py b/configs/example_groups.py deleted file mode 100644 index de96de3..0000000 --- a/configs/example_groups.py +++ /dev/null @@ -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", - ), -} diff --git a/deploy/deploy.py b/deploy/deploy.py new file mode 100644 index 0000000..2ad347e --- /dev/null +++ b/deploy/deploy.py @@ -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()) diff --git a/deploy/ha-deconz-bridge-debug.service b/deploy/ha-deconz-bridge-debug.service index 28c7adc..ccb4af3 100644 --- a/deploy/ha-deconz-bridge-debug.service +++ b/deploy/ha-deconz-bridge-debug.service @@ -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] diff --git a/deploy/ha-deconz-bridge.service b/deploy/ha-deconz-bridge.service index 5e366c8..5566339 100644 --- a/deploy/ha-deconz-bridge.service +++ b/deploy/ha-deconz-bridge.service @@ -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 diff --git a/deploy/lights_config.example.py b/deploy/lights_config.example.py deleted file mode 100644 index 719c08a..0000000 --- a/deploy/lights_config.example.py +++ /dev/null @@ -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)), - }, - ) - }, - ) - }, - ), -} diff --git a/docs/configuration.md b/docs/configuration.md index b3ebb31..98f5db0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/docs/debugger.md b/docs/debugger.md index 5ee502c..9fd52ce 100644 --- a/docs/debugger.md +++ b/docs/debugger.md @@ -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: diff --git a/docs/deployment.md b/docs/deployment.md index e60e55e..9440067 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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: - `/app`: synced application code - `/.venv`: virtualenv used by the service -- `/config/lights_config.py`: runtime light definitions +- `/config/*.py`: runtime light/group/remote definitions - `/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 +- `/config/lights_config.py` and its imported sibling config files to exist +- `/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 /.venv /.venv/bin/pip install --upgrade pip -/.venv/bin/pip install -e '/app[speed]' +/.venv/bin/pip install "/app[speed]" ``` -4. Copy a real ignored config to `/config/lights_config.py`. -5. Copy and edit `deploy/service.env.example` to `/config/service.env`. +4. Copy a real ignored config directory to `/config/`. +5. Copy and edit `config.example/service.env.example` to `/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 diff --git a/tests/conftest.py b/tests/conftest.py index 3b5d538..815dff8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_automation.py b/tests/test_automation.py index c7fca43..7db287d 100644 --- a/tests/test_automation.py +++ b/tests/test_automation.py @@ -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( { diff --git a/tests/test_groups.py b/tests/test_groups.py index 360fab3..193b50e 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -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: [ diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 425f32b..a0ffea0 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -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 diff --git a/tests/test_solver.py b/tests/test_solver.py index c4bc6c4..02f5188 100644 --- a/tests/test_solver.py +++ b/tests/test_solver.py @@ -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)