From 0f4b17341c293b390eb5a29b88f0ded4954dedaf Mon Sep 17 00:00:00 2001 From: MichaelB2018 Date: Tue, 17 Mar 2026 11:01:57 -0400 Subject: [PATCH] Platinum quality upgrade with sensor platform, tests, and brand images --- .gitignore | 139 +--- LICENSE | 2 + README.md | 215 ++++- brand/icon.png | Bin 0 -> 8607 bytes brand/icon@2x.png | Bin 0 -> 23270 bytes brand/logo.png | Bin 0 -> 7515 bytes conftest.py | 5 + custom_components/legrand_rflc/__init__.py | 142 ++-- custom_components/legrand_rflc/brand/icon.png | Bin 0 -> 8607 bytes .../legrand_rflc/brand/icon@2x.png | Bin 0 -> 23270 bytes custom_components/legrand_rflc/brand/logo.png | Bin 0 -> 7515 bytes custom_components/legrand_rflc/config_flow.py | 179 +++-- custom_components/legrand_rflc/const.py | 17 + custom_components/legrand_rflc/diagnostics.py | 95 +++ custom_components/legrand_rflc/hub.py | 748 ++++++++++++++++++ custom_components/legrand_rflc/icon.png | Bin 0 -> 8607 bytes custom_components/legrand_rflc/icon@2x.png | Bin 0 -> 23270 bytes custom_components/legrand_rflc/icons.json | 12 + custom_components/legrand_rflc/light.py | 233 ++++-- custom_components/legrand_rflc/logo.png | Bin 0 -> 7515 bytes custom_components/legrand_rflc/manifest.json | 9 +- custom_components/legrand_rflc/py.typed | 0 .../legrand_rflc/quality_scale.yaml | 144 ++++ custom_components/legrand_rflc/sensor.py | 303 +++++++ custom_components/legrand_rflc/strings.json | 104 ++- .../legrand_rflc/translations/en.json | 72 +- hacs.json | 8 +- icon.png | Bin 0 -> 8607 bytes icon@2x.png | Bin 0 -> 23270 bytes logo.png | Bin 0 -> 7515 bytes tests/__init__.py | 1 + tests/conftest.py | 269 +++++++ tests/test_config_flow.py | 286 +++++++ tests/test_diagnostics.py | 66 ++ tests/test_init.py | 99 +++ tests/test_light.py | 230 ++++++ 36 files changed, 3037 insertions(+), 341 deletions(-) create mode 100644 brand/icon.png create mode 100644 brand/icon@2x.png create mode 100644 brand/logo.png create mode 100644 conftest.py create mode 100644 custom_components/legrand_rflc/brand/icon.png create mode 100644 custom_components/legrand_rflc/brand/icon@2x.png create mode 100644 custom_components/legrand_rflc/brand/logo.png create mode 100644 custom_components/legrand_rflc/diagnostics.py create mode 100644 custom_components/legrand_rflc/hub.py create mode 100644 custom_components/legrand_rflc/icon.png create mode 100644 custom_components/legrand_rflc/icon@2x.png create mode 100644 custom_components/legrand_rflc/icons.json create mode 100644 custom_components/legrand_rflc/logo.png create mode 100644 custom_components/legrand_rflc/py.typed create mode 100644 custom_components/legrand_rflc/quality_scale.yaml create mode 100644 custom_components/legrand_rflc/sensor.py create mode 100644 icon.png create mode 100644 icon@2x.png create mode 100644 logo.png create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config_flow.py create mode 100644 tests/test_diagnostics.py create mode 100644 tests/test_init.py create mode 100644 tests/test_light.py diff --git a/.gitignore b/.gitignore index b6e4761..f2d93b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,129 +1,34 @@ -# Byte-compiled / optimized / DLL files +# Python __pycache__/ *.py[cod] *$py.class - -# C extensions *.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ *.egg-info/ -.installed.cfg *.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py +dist/ +build/ -# Environments -.env -.venv -env/ +# Virtual environments +.venv/ venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject +env/ -# mkdocs documentation -/site +# IDE +.vscode/ +.idea/ +*.swp +*.swo -# mypy +# Testing +.pytest_cache/ +.coverage +htmlcov/ .mypy_cache/ -.dmypy.json -dmypy.json -# Pyre type checker -.pyre/ +# OS +.DS_Store +Thumbs.db + +# Do not publish +AGENTS.md +docs/ diff --git a/LICENSE b/LICENSE index 9cf1062..df3c197 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,7 @@ MIT License +Copyright (c) Ross Tyler, Alexander Khudek + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights diff --git a/README.md b/README.md index ce278ed..c473f56 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,205 @@ -# Legrand RF Lighting for Home Assistant +# Legrand RF Lighting Control -Home Assistant component for Legrand LC7001. This is work by -[rtyle](https://github.com/rtyle/home-assistant.core/tree/legrand_rflc/homeassistant/components/legrand_rflc) that I packaged for easier installation and tweaked -to add devices corresponding to entitites. +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://hacs.xyz) -To use it, first add it to your `custom_components` directory, -then restart home assistant. Alternatevly you can add the repository -to HACS as a custom repository. +A [Home Assistant](https://www.home-assistant.io/) custom integration for the **Legrand LC7001 Whole House Lighting Controller**. -When you add the integration, it will as you for the host and password. -Sadly, the mDNS host `LCM1.local` doesn't work due to mDNS not working -inside home assistant docker containers. You should assign a fixed ip -to the LC7001, then use the ip as the hostname. The password is the -local password for the LC7001. +Control your Legrand RF light switches and dimmers directly from Home Assistant — entirely over your **local network**, with **no cloud dependency**. -This integration does not use the cloud to control the LC7001, it -communicates locally. +--- + +## What You Need + +- A **Legrand LC7001** hub ([product page](https://www.legrand.us/wiring-devices/electrical-accessories/miscellaneous/adorne-hub/p/lc7001)), connected to your local network +- **Home Assistant** 2024.1 or newer +- The LC7001's **IP address** (see [Finding Your Hub's IP Address](#finding-your-hubs-ip-address) below) +- The hub's **local password**, if you've set one (most people haven't) + +--- + +## What You Get + +Once installed, every light zone configured on your LC7001 appears as a Home Assistant light entity: + +- **Switches** — on/off control +- **Dimmers** — on/off, brightness slider, and transition/fade support +- **Real-time updates** — when someone uses a physical switch, Home Assistant updates instantly +- **Diagnostic sensors** — zone count, time zone, DST status, location, and add-a-light mode on the hub device + +--- + +## Installation + +### Option 1: HACS (Recommended) + +[HACS](https://hacs.xyz/) is the Home Assistant Community Store — a one-click way to install and update custom integrations. + +1. Make sure HACS is installed. If not, follow the [HACS installation guide](https://hacs.xyz/docs/use/). +2. In Home Assistant, go to **HACS** → **Integrations**. +3. Click the **⋮** menu (top right) → **Custom repositories**. +4. Paste this URL: `https://github.com/MichaelB2018/legrand_rflc` +5. Set the category to **Integration** and click **Add**. +6. Search for **Legrand RF Lighting Control** and click **Install**. +7. **Restart Home Assistant** (Settings → System → Restart). + +After the restart, continue to [Configuration](#configuration) below. + +### Option 2: Manual Installation + +Use this if you don't have HACS or prefer to manage files yourself. + +1. **Download** the latest release from [GitHub](https://github.com/MichaelB2018/legrand_rflc/releases), or clone/download the repository. + +2. **Locate your Home Assistant config directory.** This is the folder that contains your `configuration.yaml` file. Common locations: + - **Home Assistant OS / Supervised**: `/config/` + - **Docker**: The volume you mounted as `/config` + - **Core (venv)**: `~/.homeassistant/` + +3. **Create the folder** `custom_components/legrand_rflc/` inside your config directory, if it doesn't already exist: + ``` + config/ + └── custom_components/ + └── legrand_rflc/ + ``` + +4. **Copy all integration files** into that folder. When done, it should look like this: + ``` + config/ + └── custom_components/ + └── legrand_rflc/ + ├── __init__.py + ├── config_flow.py + ├── const.py + ├── diagnostics.py + ├── hub.py + ├── icons.json + ├── light.py + ├── sensor.py + ├── manifest.json + ├── strings.json + ├── quality_scale.yaml + ├── py.typed + ├── icon.png + ├── icon@2x.png + ├── logo.png + └── translations/ + └── en.json + ``` + + > **Tip:** You don't need to copy `tests/`, `conftest.py`, `README.md`, `LICENSE`, `hacs.json`, or `.gitignore` — those are development files. + +5. **Restart Home Assistant** (Settings → System → Restart). + +After the restart, continue to [Configuration](#configuration) below. + +--- + +## Configuration + +### Step 1: Find the Integration + +1. Go to **Settings** → **Devices & Services**. +2. Click **+ Add Integration** (bottom right). +3. Search for **Legrand RF Lighting Control**. + +> **Tip:** If it doesn't appear, make sure you restarted Home Assistant after installation. + +### Step 2: Enter Your Hub Details + +You'll see a form with two fields: + +| Field | What to Enter | +|-------|---------------| +| **Host** | The IP address of your LC7001 hub (e.g., `192.168.1.50`). See below for how to find it. | +| **Password** | The local password, if you've set one on the hub. **Leave blank if you haven't set a password** (this is the default). | + +Click **Submit**. The integration will test the connection. If everything works, your lights will appear within a few seconds. + +### Step 3: Check Your Devices + +Go to **Settings** → **Devices & Services** → **Legrand RF Lighting Control**. You should see: + +- **1 hub device** ("Whole House Lighting Controller") with diagnostic sensors +- **1 device per light zone** — each with a light entity you can control + +All your lights are now available in dashboards, automations, and scripts. + +--- + +## Finding Your Hub's IP Address + +The LC7001 connects to your network via Ethernet. Here are some ways to find its IP: + +1. **Check your router's admin page** — look for a device called "Legrand LC7001" or similar in the DHCP client list. +2. **Use the Legrand RFLC app** — the app displays the hub's network info. +3. **Try the default hostname** — `LCM1.local` works on some networks, but **not reliably in Docker** (mDNS doesn't work in most container setups). A static IP is strongly recommended. + +> **Recommendation:** Assign a **static IP** (or a DHCP reservation) to your LC7001 in your router settings. This prevents the IP from changing and breaking the integration. + +--- + +## Updating the Integration + +### With HACS + +HACS will notify you when a new version is available. Click **Update** and restart Home Assistant. + +### Manual + +Download the new version, replace all files in `custom_components/legrand_rflc/`, and restart Home Assistant. + +--- + +## Changing Settings After Setup + +### Change the Hub IP Address + +If your hub's IP address changes: + +1. Go to **Settings** → **Devices & Services** → **Legrand RF Lighting Control**. +2. Click **⋮** → **Reconfigure**. +3. Enter the new IP and click **Submit**. + +### Change the Password + +If you change the password on your LC7001: + +- Home Assistant will detect the authentication failure and prompt you to **re-authenticate**. +- Enter the new password when prompted. + +--- + +## Removing the Integration + +1. Go to **Settings** → **Devices & Services**. +2. Click **Legrand RF Lighting Control**. +3. Click **⋮** → **Delete**. +4. (Optional) Delete the `custom_components/legrand_rflc/` folder if you installed manually. + +--- + +## Troubleshooting + +| Problem | What to Do | +|---------|------------| +| **Integration doesn't appear after install** | Make sure you restarted Home Assistant. Check that the files are in `custom_components/legrand_rflc/` (not a subfolder like `custom_components/legrand_rflc/legrand_rflc/`). | +| **"Invalid host" error** | The hub is unreachable. Check that the IP is correct, the hub is powered on, and both devices are on the same network. | +| **"Invalid authentication" error** | The password is wrong. Make sure it matches the password set on the LC7001. If you haven't set one, leave the password field blank. | +| **`LCM1.local` doesn't work** | mDNS (`.local` names) often fails inside Docker containers. Use the hub's IP address instead. | +| **Lights show as "Unavailable"** | The hub is disconnected or unauthenticated. Check the hub's power and network connection. The integration will reconnect automatically when the hub comes back. | +| **New zones not showing up** | Adding or removing zones on the hub triggers an automatic reload. If they don't appear, go to the integration and click **⋮** → **Reload**. | +| **DHCP auto-discovery not working** | On Linux, the HA process needs `cap_net_raw` capability or must run as root. This doesn't affect manual setup. | + +--- + +## Credits + +This integration builds on the work of several contributors: + +- **[@rtyle](https://github.com/rtyle)** — Original author of the [Home Assistant component](https://github.com/rtyle/home-assistant.core/tree/legrand_rflc) and the [`lc7001`](https://github.com/rtyle/lc7001) Python library (vendored as `hub.py`, MIT license) +- **[@akhudek](https://github.com/akhudek)** — Original HACS packaging and device registry support +- **[@MichaelB2018](https://github.com/MichaelB2018)** — Platinum quality upgrade, sensor platform, ongoing maintenance + +## License + +[MIT](LICENSE) diff --git a/brand/icon.png b/brand/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..82abb9702dfbf7eee3d9ac4a280e63c0a4f53ecb GIT binary patch literal 8607 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 literal 0 HcmV?d00001 diff --git a/brand/logo.png b/brand/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5d315e30eae3b6b8bcb839bf36f267d9615f3b65 GIT binary patch literal 7515 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%0A`dp$>BR)g68V z5mgP#^tLyYk4^agMI4X@i3O7UUGLa&t@l7%xm^bMi}U<#9o?r|iiD@M8Q@@1BG|w_ z*uyROj=Fo`9pH;gNm1#NoZ@9UB{kbiO6rPm^~;xK6&2ML6@^-|;{Ml!d;T8Yp6LI3 zg2$|w8ZhBB=Lt6cp21;mfp@sV!ouXe{ZK*fZUJ}X{R6#nRx}~NATDDAbNyF(uF?Mw D-zlWr literal 0 HcmV?d00001 diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..e12b1b2 --- /dev/null +++ b/conftest.py @@ -0,0 +1,5 @@ +"""Root conftest for pytest-homeassistant-custom-component.""" + +from __future__ import annotations + +pytest_plugins = "pytest_homeassistant_custom_component" diff --git a/custom_components/legrand_rflc/__init__.py b/custom_components/legrand_rflc/__init__.py index a4941b4..c051483 100644 --- a/custom_components/legrand_rflc/__init__.py +++ b/custom_components/legrand_rflc/__init__.py @@ -3,91 +3,129 @@ https://www.legrand.us/solutions/smart-lighting/radio-frequency-lighting-controls """ +from __future__ import annotations + import asyncio +import logging from collections.abc import Mapping from typing import Final -import lc7001.aio - -from homeassistant import config_entries -from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import CONF_AUTHENTICATION, CONF_HOST, CONF_PORT +from homeassistant.const import CONF_AUTHENTICATION, CONF_HOST, CONF_MAC, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from .const import DOMAIN +from .const import DOMAIN, LegrandRFLCConfigEntry, LegrandRFLCData +from .hub import Authenticator, Connector, Hub -PLATFORMS: Final = ["light"] +_LOGGER: Final = logging.getLogger(__name__) - -async def async_setup(hass: HomeAssistant, config: dict) -> bool: - """Set up Legrand RFLC integration.""" - hass.data[DOMAIN] = {} - return True +PLATFORMS: Final[list[str]] = ["light", "sensor"] async def async_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry + hass: HomeAssistant, entry: LegrandRFLCConfigEntry ) -> bool: """Set up Legrand LC7001 from a config entry.""" - entry_id = entry.entry_id data = entry.data - host = data[CONF_HOST] - kwargs = {} + host: str = data[CONF_HOST] + kwargs: dict[str, object] = {} if CONF_AUTHENTICATION in data: kwargs["key"] = bytes.fromhex(data[CONF_AUTHENTICATION]) if CONF_PORT in data: # for testing only (server emulation on localhost) kwargs["port"] = data[CONF_PORT] - hass.data[DOMAIN][entry_id] = hub = lc7001.aio.Hub(host, **kwargs) - # Register a device representing the hub. + # Test connection before setting up the persistent hub. + test = Connector(host, loop_timeout=-1, **kwargs) + try: + await test.loop() + except OSError as err: + raise ConfigEntryNotReady( + f"Cannot connect to LC7001 hub at {host}" + ) from err + except Authenticator.Error as err: + entry.async_start_reauth(hass) + raise ConfigEntryNotReady( + f"Authentication failed for LC7001 hub at {host}" + ) from err + + hub = Hub(host, **kwargs) + entry.runtime_data = LegrandRFLCData(hub=hub) + + # Register the hub device. + mac_raw: str = data.get(CONF_MAC, "") + mac_formatted = dr.format_mac(mac_raw) if mac_raw else "" device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, host)}, - identifiers={(DOMAIN, host)}, - manufacturer="Legrand", - name="Whole House Lighting Controller", - model="LC7001", - ) - - async def setup_platforms() -> None: - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - async def _reauth() -> None: - await hass.config_entries.async_unload(entry_id) - await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": SOURCE_REAUTH, - "entry": entry, - "unique_id": entry.unique_id, + hub_device_kwargs: dict[str, object] = { + "config_entry_id": entry.entry_id, + "identifiers": {(DOMAIN, host)}, + "manufacturer": "Legrand", + "name": "Whole House Lighting Controller", + "model": "LC7001", + } + if mac_formatted: + hub_device_kwargs["connections"] = { + (dr.CONNECTION_NETWORK_MAC, mac_formatted) + } + hub_device = device_registry.async_get_or_create(**hub_device_kwargs) + + # Clean up stale connection entries (e.g. IP was incorrectly stored as MAC + # in older versions). Keep only the valid formatted MAC. + stale = { + (t, v) + for t, v in hub_device.connections + if t == dr.CONNECTION_NETWORK_MAC and v != mac_formatted + } + if stale: + device_registry.async_update_device( + hub_device.id, + new_connections={ + c for c in hub_device.connections if c not in stale }, - data=data, ) - async def reauth() -> None: - hass.async_create_task(_reauth()) + platforms_setup = False + + async def on_authenticated(*_args: object) -> None: + nonlocal platforms_setup + if not platforms_setup: + platforms_setup = True + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def reload(message: Mapping) -> None: - hass.async_create_task(hass.config_entries.async_reload(entry_id)) + async def on_unauthenticated(*_args: object) -> None: + entry.async_start_reauth(hass) - hub.once(hub.EVENT_AUTHENTICATED, setup_platforms) - hub.once(hub.EVENT_UNAUTHENTICATED, reauth) - hub.once(hub.EVENT_ZONE_ADDED, reload) - hub.once(hub.EVENT_ZONE_DELETED, reload) + async def on_zone_changed(message: Mapping) -> None: + hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) + + # Use persistent hub.on() — NOT hub.once(). + # hub.once() caused a critical bug: after the first disconnect/reconnect + # cycle, the handlers were gone and HA could never recover. + hub.on(hub.EVENT_AUTHENTICATED, on_authenticated) + hub.on(hub.EVENT_UNAUTHENTICATED, on_unauthenticated) + hub.on(hub.EVENT_ZONE_ADDED, on_zone_changed) + hub.on(hub.EVENT_ZONE_DELETED, on_zone_changed) asyncio.create_task(hub.loop()) # not hass.async_create_task return True +async def async_remove_config_entry_device( + hass: HomeAssistant, + entry: LegrandRFLCConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Allow removal of a device if it is not the hub itself.""" + # Protect the hub device; allow removal of orphaned zone devices. + hub = entry.runtime_data.hub + return (DOMAIN, hub.host()) not in device_entry.identifiers + + async def async_unload_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry + hass: HomeAssistant, entry: LegrandRFLCConfigEntry ) -> bool: """Unload a config entry.""" - hub = hass.data[DOMAIN][entry.entry_id] + hub = entry.runtime_data.hub await hub.cancel() - await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN].pop(entry.entry_id) - return True + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/legrand_rflc/brand/icon.png b/custom_components/legrand_rflc/brand/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..82abb9702dfbf7eee3d9ac4a280e63c0a4f53ecb GIT binary patch literal 8607 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 literal 0 HcmV?d00001 diff --git a/custom_components/legrand_rflc/brand/logo.png b/custom_components/legrand_rflc/brand/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5d315e30eae3b6b8bcb839bf36f267d9615f3b65 GIT binary patch literal 7515 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%0A`dp$>BR)g68V z5mgP#^tLyYk4^agMI4X@i3O7UUGLa&t@l7%xm^bMi}U<#9o?r|iiD@M8Q@@1BG|w_ z*uyROj=Fo`9pH;gNm1#NoZ@9UB{kbiO6rPm^~;xK6&2ML6@^-|;{Ml!d;T8Yp6LI3 zg2$|w8ZhBB=Lt6cp21;mfp@sV!ouXe{ZK*fZUJ}X{R6#nRx}~NATDDAbNyF(uF?Mw D-zlWr literal 0 HcmV?d00001 diff --git a/custom_components/legrand_rflc/config_flow.py b/custom_components/legrand_rflc/config_flow.py index 169b827..2b25ff0 100644 --- a/custom_components/legrand_rflc/config_flow.py +++ b/custom_components/legrand_rflc/config_flow.py @@ -5,13 +5,13 @@ import asyncio import logging import socket +from collections.abc import Mapping from typing import Any, Final -import lc7001.aio import voluptuous -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.dhcp import IP_ADDRESS +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import ( CONF_AUTHENTICATION, CONF_HOST, @@ -19,9 +19,9 @@ CONF_PASSWORD, CONF_PORT, ) -from homeassistant.helpers.typing import DiscoveryInfoType from .const import DOMAIN +from .hub import Authenticator, Connector, hash_password _LOGGER: Final = logging.getLogger(__name__) @@ -31,83 +31,84 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - HOST: Final = lc7001.aio.Connector.HOST + HOST: Final = Connector.HOST ABORT_NO_DEVICES_FOUND: Final = "no_devices_found" ABORT_REAUTH_SUCCESSFUL: Final = "reauth_successful" + ABORT_RECONFIGURE_SUCCESSFUL: Final = "reconfigure_successful" ERROR_INVALID_HOST: Final = "invalid_host" ERROR_INVALID_AUTH: Final = "invalid_auth" - # Automatic ssdp discovery does not help. + async def _test_connection( + self, + host: str, + key: bytes | None = None, + port: int | None = None, + ) -> str: + """Test connection and return MAC address. + + Raises OSError on connection failure. + Raises Authenticator.Error on authentication failure. + """ + kwargs: dict[str, Any] = {"key": key, "loop_timeout": -1} + if port is not None: + kwargs["port"] = port + connector = Connector(host, **kwargs) + return await connector.loop() - # Automatic zeroconf discovery does not help. - # Even though the device supports mDNS, it does not publish any services to discover. - - # Automatic dhcp discovery can detect conversations (udp port bootpc or bootps) - # with network devices that present their hostname as 'Legrand LC7001' - # with a manifest.json entry of - # "dhcp": [{"hostname": "legrand lc7001"}] - # This will happen each time a Legrand LC7001 controller (re)boots. - # Linux requires the hass process have an effective cap_net_raw capability (or be run as root) - # for this to work. async def async_step_dhcp( - self, discovery_info: DiscoveryInfoType - ) -> data_entry_flow.FlowResult: + self, discovery_info: Any + ) -> ConfigFlowResult: """Handle a flow initiated by dhcp discovery.""" - # example discovery_info - # {'ip': '192.168.0.1', 'hostname': 'legrand lc7001', 'macaddress': '0026ec000000'} try: - resolutions = await asyncio.get_event_loop().getaddrinfo(self.HOST, None) + resolutions = await asyncio.get_running_loop().getaddrinfo( + self.HOST, None + ) except OSError as error: _LOGGER.warning("OS getaddrinfo %s error %s", self.HOST, error) return self.async_abort(reason=self.ABORT_NO_DEVICES_FOUND) - address = discovery_info[IP_ADDRESS] + address: str = discovery_info.ip if hasattr(discovery_info, "ip") else discovery_info.get("ip", "") if any( resolution[4][0] == address for resolution in resolutions if resolution[0] == socket.AF_INET ): await self._async_handle_discovery_without_unique_id() - # wait for user interaction in the next step return await self.async_step_user() - _LOGGER.warning("%s does not resolve to discovered %s", self.HOST, address) + _LOGGER.warning( + "%s does not resolve to discovered %s", self.HOST, address + ) return self.async_abort(reason=self.ABORT_NO_DEVICES_FOUND) async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" - errors = {} + errors: dict[str, str] = {} host = self.HOST if user_input is not None: host = user_input[CONF_HOST] - await self.async_set_unique_id(host) # already_in_progress? - self._abort_if_unique_id_configured() # already_configured? - key = None - kwargs = {"key": key, "loop_timeout": -1} + await self.async_set_unique_id(host) + self._abort_if_unique_id_configured() + key: bytes | None = None if CONF_PASSWORD in user_input: - key = kwargs["key"] = lc7001.aio.hash_password( - user_input[CONF_PASSWORD].encode() - ) - if CONF_PORT in user_input: # for testing server emulation on localhost - kwargs["port"] = user_input[CONF_PORT] - task = self.hass.async_create_task( - lc7001.aio.Connector(host, **kwargs).loop() - ) + key = hash_password(user_input[CONF_PASSWORD].encode()) + port: int | None = user_input.get(CONF_PORT) try: - mac = await task + mac = await self._test_connection(host, key, port) except OSError: errors[CONF_HOST] = self.ERROR_INVALID_HOST - except lc7001.aio.Authenticator.Error: + except Authenticator.Error: errors[CONF_PASSWORD] = self.ERROR_INVALID_AUTH else: - data = {CONF_HOST: host, CONF_MAC: mac} + data: dict[str, Any] = {CONF_HOST: host, CONF_MAC: mac} if key is not None: data[CONF_AUTHENTICATION] = key.hex() + if port is not None: + data[CONF_PORT] = port return self.async_create_entry(title=host, data=data) - # get user_input return self.async_show_form( step_id="user", data_schema=voluptuous.Schema( @@ -120,47 +121,87 @@ async def async_step_user( ) async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth flow entry point.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: - """Handle configuration by reauth.""" - host = self.context["unique_id"] - errors = {CONF_PASSWORD: self.ERROR_INVALID_AUTH} + ) -> ConfigFlowResult: + """Handle reauth confirmation.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + host: str = reauth_entry.data[CONF_HOST] + if user_input is not None: key: bytes | None = None - kwargs = {"key": key, "loop_timeout": -1} - if CONF_AUTHENTICATION in user_input: - key = kwargs["key"] = bytes.fromhex(user_input[CONF_AUTHENTICATION]) - if CONF_PORT in user_input: # for testing server emulation on localhost - kwargs["port"] = user_input[CONF_PORT] if CONF_PASSWORD in user_input: - key = kwargs["key"] = lc7001.aio.hash_password( - user_input[CONF_PASSWORD].encode() + key = hash_password(user_input[CONF_PASSWORD].encode()) + port: int | None = reauth_entry.data.get(CONF_PORT) + try: + mac = await self._test_connection(host, key, port) + except OSError: + errors[CONF_HOST] = self.ERROR_INVALID_HOST + except Authenticator.Error: + errors[CONF_PASSWORD] = self.ERROR_INVALID_AUTH + else: + data: dict[str, Any] = {CONF_HOST: host, CONF_MAC: mac} + if port is not None: + data[CONF_PORT] = port + if key is not None: + data[CONF_AUTHENTICATION] = key.hex() + return self.async_update_reload_and_abort( + reauth_entry, data=data ) - task = self.hass.async_create_task( - lc7001.aio.Connector(host, **kwargs).loop() - ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=voluptuous.Schema( + { + voluptuous.Optional(CONF_PASSWORD): str, + } + ), + errors=errors, + description_placeholders={"host": host}, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + host: str = reconfigure_entry.data[CONF_HOST] + + if user_input is not None: + new_host: str = user_input[CONF_HOST] + key: bytes | None = None + if CONF_PASSWORD in user_input: + key = hash_password(user_input[CONF_PASSWORD].encode()) + elif CONF_AUTHENTICATION in reconfigure_entry.data: + key = bytes.fromhex(reconfigure_entry.data[CONF_AUTHENTICATION]) + port: int | None = user_input.get(CONF_PORT) try: - mac = await task + mac = await self._test_connection(new_host, key, port) except OSError: errors[CONF_HOST] = self.ERROR_INVALID_HOST - except lc7001.aio.Authenticator.Error: - pass + except Authenticator.Error: + errors[CONF_PASSWORD] = self.ERROR_INVALID_AUTH else: - data = {CONF_HOST: host, CONF_MAC: mac} - if CONF_PORT in user_input: # for testing server emulation on localhost - data[CONF_PORT] = user_input[CONF_PORT] + data: dict[str, Any] = {CONF_HOST: new_host, CONF_MAC: mac} + if port is not None: + data[CONF_PORT] = port if key is not None: data[CONF_AUTHENTICATION] = key.hex() - entry = self.context["entry"] - self.hass.config_entries.async_update_entry(entry, data=data) - self.hass.async_create_task( - self.hass.config_entries.async_setup(entry.entry_id) + return self.async_update_reload_and_abort( + reconfigure_entry, + data=data, + unique_id=new_host, ) - return self.async_abort(reason=self.ABORT_REAUTH_SUCCESSFUL) - # get user_input return self.async_show_form( - step_id="reauth", + step_id="reconfigure", data_schema=voluptuous.Schema( { voluptuous.Required(CONF_HOST, default=host): str, diff --git a/custom_components/legrand_rflc/const.py b/custom_components/legrand_rflc/const.py index 3c4fe1d..c56c8ac 100644 --- a/custom_components/legrand_rflc/const.py +++ b/custom_components/legrand_rflc/const.py @@ -1,5 +1,22 @@ """Constants for the Legrand RFLC integration.""" +from __future__ import annotations + +from dataclasses import dataclass from typing import Final +from homeassistant.config_entries import ConfigEntry + +from .hub import Hub + DOMAIN: Final = "legrand_rflc" + + +@dataclass +class LegrandRFLCData: + """Runtime data for the Legrand RFLC integration.""" + + hub: Hub + + +type LegrandRFLCConfigEntry = ConfigEntry[LegrandRFLCData] diff --git a/custom_components/legrand_rflc/diagnostics.py b/custom_components/legrand_rflc/diagnostics.py new file mode 100644 index 0000000..41ba9c8 --- /dev/null +++ b/custom_components/legrand_rflc/diagnostics.py @@ -0,0 +1,95 @@ +"""Diagnostics support for the Legrand RFLC integration.""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Mapping +from typing import Any, Final + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_AUTHENTICATION +from homeassistant.core import HomeAssistant + +from .const import LegrandRFLCConfigEntry + +_LOGGER: Final = logging.getLogger(__name__) + +TO_REDACT: Final = {CONF_AUTHENTICATION} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: LegrandRFLCConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + hub = entry.runtime_data.hub + + diag: dict[str, Any] = { + "config_entry": async_redact_data(dict(entry.data), TO_REDACT), + "hub": { + "host": hub.host(), + "connected": hub.connected, + "authenticated": hub.authenticated, + }, + } + + if not (hub.connected and hub.authenticated): + return diag + + # Query zone list from the hub. + zone_list_future: asyncio.Future[list[Mapping]] = ( + asyncio.get_running_loop().create_future() + ) + + async def handle_zones(message: Mapping) -> None: + try: + hub.StatusError(message).raise_if() + except hub.StatusError: + if not zone_list_future.done(): + zone_list_future.set_result([]) + return + if not zone_list_future.done(): + zone_list_future.set_result(message.get(hub.ZONE_LIST, [])) + + await hub.handle_send(handle_zones, hub.compose_list_zones()) + + try: + zone_items = await asyncio.wait_for(zone_list_future, timeout=5.0) + except asyncio.TimeoutError: + _LOGGER.warning("Timeout querying zone list for diagnostics") + return diag + + # Query each zone's properties. + zones: list[dict[str, Any]] = [] + for item in zone_items: + zid: int = item[hub.ZID] + zone_future: asyncio.Future[dict[str, Any]] = ( + asyncio.get_running_loop().create_future() + ) + + async def handle_zone( + msg: Mapping, _future: asyncio.Future[dict[str, Any]] = zone_future + ) -> None: + try: + hub.StatusError(msg).raise_if() + except hub.StatusError: + if not _future.done(): + _future.set_result({}) + return + if not _future.done(): + _future.set_result(dict(msg.get(hub.PROPERTY_LIST, {}))) + + await hub.handle_send( + handle_zone, hub.compose_report_zone_properties(zid) + ) + + try: + props = await asyncio.wait_for(zone_future, timeout=5.0) + except asyncio.TimeoutError: + props = {} + + zones.append({"zid": zid, "properties": props}) + + diag["zones"] = zones + return diag diff --git a/custom_components/legrand_rflc/hub.py b/custom_components/legrand_rflc/hub.py new file mode 100644 index 0000000..5ce18ca --- /dev/null +++ b/custom_components/legrand_rflc/hub.py @@ -0,0 +1,748 @@ +# Vendored from lc7001 v1.0.5 (https://github.com/rtyle/lc7001) +# Original author: Ross Tyler +# License: MIT (https://github.com/rtyle/lc7001/blob/master/LICENSE) +# Modifications: None (vendored as-is to resolve PyPI install failures) + +"""asyncio (aio) Legrand Adorne LC7001 Hub interface. + +https://www.legrand.us/wiring-devices/electrical-accessories/miscellaneous/adorne-hub/p/lc7001 +https://www.amazon.com/Legrand-Q-LC7001-Lighting-Controller/dp/B06XW1MLVF + +https://www.legrand.us/solutions/smart-lighting/radio-frequency-lighting-controls +https://developer.legrand.com/documentation/rflc-api-for-lc7001/ +""" + +import abc +import asyncio +import collections +import contextlib +import json +import logging +import time +from typing import Any, Final, Mapping, MutableMapping + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +_logger: Final = logging.getLogger(__name__) + + +def hash_password(data: bytes) -> bytes: + """Return a hash of data for turning a password into a key.""" + digest = hashes.Hash(hashes.MD5()) + digest.update(data) + return digest.finalize() + + +class Composer: + """Composer of messages.""" + + # message keys + APP_CONTEXT_ID: Final = "AppContextId" # echoed in reply + _ID: Final = "ID" # echoed in reply + PROPERTY_LIST: Final = "PropertyList" + SCENE_LIST: Final = "SceneList" + SERVICE: Final = "Service" + SID: Final = "SID" # json_integer, 0-99 + ZID: Final = "ZID" # json_integer, 0-99 + ZONE_LIST: Final = "ZoneList" + + # PROPERTY_LIST keys + NAME: Final = "Name" # json_string, 1-20 characters + + # SCENE PROPERTY_LIST keys + DAY_BITS: Final = "DayBits" # json_integer, DAY_BITS values + DELTA: Final = "Delta" # json_integer, minutes before/after TRIGGER_TIME + FREQUENCY: Final = "Frequency" # json_integer, FREQUENCY values + SKIP: Final = "Skip" # json_boolean, True to skip next trigger + TRIGGER_TIME: Final = "TriggerTime" # json_integer, time_t + TRIGGER_TYPE: Final = "TriggerType" # json_integer, TRIGGER_TYPE values + + # SCENE PROPERTY_LIST, DAY_BITS values + SUNDAY: Final = 0 + MONDAY: Final = 1 + TUESDAY: Final = 2 + WEDNESDAY: Final = 3 + THURSDAY: Final = 4 + FRIDAY: Final = 5 + SATURDAY: Final = 6 + + # SCENE PROPERTY_LIST, FREQUENCY values + NONE: Final = 0 + ONCE: Final = 1 + WEEKLY: Final = 2 + + # SCENE PROPERTY_LIST, TRIGGER_TYPE values + REGULAR_TIME: Final = 0 + SUNRISE: Final = 1 + SUNSET: Final = 2 + + # SCENE PROPERTY_LIST, ZONE_LIST array, item keys (always with ZID) + LEVEL: Final = "Lvl" # json_integer, 1-100 (POWER_LEVEL) + RR: Final = "RR" # json_integer, 1-100 (RAMP_RATE) + ST: Final = "St" # json_boolean, True/False (POWER state) + + # ZONE PROPERTY_LIST keys + DEVICE_TYPE: Final = "DeviceType" # json_string, DIMMER/, reported only + POWER: Final = "Power" # json_boolean, True/False + POWER_LEVEL: Final = "PowerLevel" # json_integer, 1-100 + RAMP_RATE: Final = "RampRate" # json_integer, 1-100 + + # ZONE PROPERTY_LIST, DEVICE_TYPE values + DIMMER: Final = "Dimmer" + SWITCH: Final = "Switch" + + # SERVICE values + CREATE_SCENE: Final = "CreateScene" + DELETE_SCENE: Final = "DeleteScene" + DELETE_ZONE: Final = "DeleteZone" + LIST_SCENES: Final = "ListScenes" + LIST_ZONES: Final = "ListZones" + REPORT_SCENE_PROPERTIES: Final = "ReportSceneProperties" + REPORT_SYSTEM_PROPERTIES: Final = "ReportSystemProperties" + REPORT_ZONE_PROPERTIES: Final = "ReportZoneProperties" + RUN_SCENE: Final = "RunScene" + SET_SCENE_PROPERTIES: Final = "SetSceneProperties" + SET_SYSTEM_PROPERTIES: Final = "SetSystemProperties" + SET_ZONE_PROPERTIES: Final = "SetZoneProperties" + TRIGGER_RAMP_COMMAND: Final = "TriggerRampCommand" + TRIGGER_RAMP_ALL_COMMAND: Final = "TriggerRampAllCommand" + + # SET_SYSTEM_PROPERTIES PROPERTY_LIST keys + ADD_A_LIGHT: Final = "AddALight" # json_boolean, True to enable + TIME_ZONE: Final = "TimeZone" # json_integer, seconds offset from GMT + EFFECTIVE_TIME_ZONE: Final = "EffectiveTimeZone" # json_integer, seconds offset from GMT including DST + DAYLIGHT_SAVING_TIME: Final = ( + "DaylightSavingTime" # json_boolean, True for DST + ) + LOCATION_INFO: Final = "LocationInfo" # json_string, LOCATION description + LOCATION: Final = "Location" # json_object, LAT and LONG + CONFIGURED: Final = ( + "Configured" # json_boolean, True to say LCM configured + ) + LAT: Final = "Lat" # json_object, latitude in DEG, MIN, SEC + LONG: Final = "Long" # json_object longitude in DEG, MIN, SEC + DEG: Final = "Deg" # json_integer, degrees + MIN: Final = "Min" # json_integer, minutes + SEC: Final = "Sec" # json_integer, seconds + + # SET_SYSTEM_PROPERTIES PROPERTY_LIST security keys + KEYS: Final = "Keys" + + def wrap(self, _id, message: MutableMapping[str, Any]) -> bytes: + """Wrap a composed message, with _id, in a frame.""" + message[self._ID] = _id + return json.dumps(message).encode() + b"\x00" + + def compose_delete_scene(self, sid: int): + """Compose a DELETE_SCENE message.""" + return {self.SERVICE: self.DELETE_SCENE, self.SID: sid} + + def compose_delete_zone(self, zid: int): + """Compose a DELETE_ZONE message.""" + return {self.SERVICE: self.DELETE_ZONE, self.ZID: zid} + + def compose_list_scenes(self): + """Compose a LIST_SCENES message.""" + return {self.SERVICE: self.LIST_SCENES} + + def compose_list_zones(self): + """Compose a LIST_ZONES message.""" + return {self.SERVICE: self.LIST_ZONES} + + def compose_report_scene_properties(self, sid: int): + """Compose a REPORT_SCENE_PROPERTIES message.""" + return {self.SERVICE: self.REPORT_SCENE_PROPERTIES, self.SID: sid} + + def compose_report_system_properties(self): + """Compose a REPORT_SYSTEM_PROPERTIES message.""" + return {self.SERVICE: self.REPORT_SYSTEM_PROPERTIES} + + def compose_report_zone_properties(self, zid: int): + """Compose a REPORT_ZONE_PROPERTIES message.""" + return {self.SERVICE: self.REPORT_ZONE_PROPERTIES, self.ZID: zid} + + def compose_set_zone_properties( # pylint: disable=too-many-arguments + self, + zid: int, + name: str = None, + power: bool = None, + power_level: int = None, + ramp_rate: int = None, + ): + """Compose a SET_ZONE_PROPERTIES message.""" + property_list: dict[str, Any] = {} + if name is not None: + property_list[self.NAME] = name + if power is not None: + property_list[self.POWER] = power + if power_level is not None: + property_list[self.POWER_LEVEL] = power_level + if ramp_rate is not None: + property_list[self.RAMP_RATE] = ramp_rate + return { + self.SERVICE: self.SET_ZONE_PROPERTIES, + self.ZID: zid, + self.PROPERTY_LIST: property_list, + } + + def compose_set_system_properties( + self, + add_a_light: bool = None, + time_zone: int = None, + effective_time_zone: int = None, + daylight_saving_time: bool = None, + location_info: str = None, + location: Mapping = None, + configured: bool = None, + ): + """Compose a SET_SYSTEM_PROPERTIES message.""" + property_list: dict[str, Any] = {} + if add_a_light is not None: + property_list[self.ADD_A_LIGHT] = add_a_light + if time_zone is not None: + property_list[self.TIME_ZONE] = time_zone + if effective_time_zone is not None: + property_list[self.EFFECTIVE_TIME_ZONE] = effective_time_zone + if daylight_saving_time is not None: + property_list[self.DAYLIGHT_SAVING_TIME] = daylight_saving_time + if location_info is not None: + property_list[self.LOCATION_INFO] = location_info + if location is not None: + property_list[self.LOCATION] = location + if configured is not None: + property_list[self.CONFIGURED] = configured + return { + self.SERVICE: self.SET_SYSTEM_PROPERTIES, + self.PROPERTY_LIST: property_list, + } + + @staticmethod + def _encrypt(key: bytes, data: bytes) -> bytes: + cipher = Cipher(algorithms.AES(key), modes.ECB()) + encryptor = cipher.encryptor() + return encryptor.update(data) + encryptor.finalize() + + def compose_keys(self, old: bytes, new: bytes): + """Compose a message to change key from old to new.""" + return { + self.SERVICE: self.SET_SYSTEM_PROPERTIES, + self.PROPERTY_LIST: { + self.KEYS: "".join( + [self._encrypt(old, key).hex() for key in (old, new)] + ) + }, + } + + +class _Sender(Composer): + def __init__(self): + self._id = 0 # id of last send + self._writer: asyncio.StreamWriter = None + + async def send(self, message: MutableMapping[str, Any]): + """Send a composed message with the next ID.""" + writer = self._writer + if writer is None: + _logger.warning("\t! %s", message) + else: + self._id += 1 + writer.write(self.wrap(self._id, message)) + await writer.drain() + _logger.debug("\t< %s", message) + + +class _Inner: # pylint: disable=too-few-public-methods + """An _Inner instance remembers its outer instance.""" + + def __init__(self, outer): + self._outer = outer + + def outer(self): + """Get private _outer attribute.""" + return self._outer + + +class Receiver(_Sender): + """A Receiver's messages are handled by an abstract receive method.""" + + # default constructor values + READ_TIMEOUT: Final = 20.0 # expect ping every 5 seconds + + class StatusError(ValueError): + """StatusError whose args are derived from a message. + + Returns (error: bool, code: int = 0, text: str = None) + where error is True if STATUS is not STATUS_SUCCESS, + code is ERROR_CODE value (or 0) + and text is ERROR_TEXT value (or None).""" + + # message keys + ERROR_TEXT: Final = "ErrorText" + ERROR_CODE: Final = "ErrorCode" + STATUS: Final = "Status" + + # STATUS values + STATUS_SUCCESS: Final = "Success" + STATUS_ERROR: Final = "Error" + + def __init__(self, message: Mapping): + super().__init__( + message.get(self.STATUS, self.STATUS_ERROR) + != self.STATUS_SUCCESS, + int(message.get(self.ERROR_CODE, "0")), + message.get(self.ERROR_TEXT, None), + ) + + def __bool__(self): + return bool(self.args[0]) + + def raise_if(self): + """raise self if bool(self).""" + if bool(self): + raise self + + def __init__(self, read_timeout: float = READ_TIMEOUT): + super().__init__() + self._reader: asyncio.StreamReader = None + self._read_timeout = read_timeout + self._frames = self._Frames(self) + + @abc.abstractmethod + async def receive(self, message: Mapping): + """Receive a message.""" + + class _Frames(_Inner, collections.abc.AsyncIterator): + def __aiter__(self): + return self + + async def __anext__(self): + # return null terminated frame, without the terminator + return ( + await asyncio.wait_for( + self.outer()._reader.readuntil(b"\x00"), + self.outer()._read_timeout, + ) + )[:-1] + + async def unwrap(self, frame: bytes): + """Unwrap message in frame and receive it.""" + try: + message = json.loads(frame) + except json.JSONDecodeError as error: + _logger.warning("except json.JSONDecodeError: %s %s", error, frame) + else: + await self.receive(message) + + async def session(self): + """Iterate over read frames forever.""" + async for frame in self._frames: + _logger.debug("\t\t> %s", frame.decode()) + await self.unwrap(frame) + + +class _EventEmitter: + """_EventEmitter pattern implementation.""" + + class _Once(_Inner): # pylint: disable=too-few-public-methods + """_Once (_Inner class of _EventEmitter) forwards an emission once.""" + + NOTHING: Final = object() # do not forward an event of NOTHING + + async def _forward(self, *event): + self.outer().off(self._name, self._forward) + if len(event) != 1 or event[0] is not self.NOTHING: + await self._handler(*event) + + def __init__( + self, outer, name: str, handler: collections.abc.Awaitable + ): + super().__init__(outer) + self._name = name + self._handler = handler + self.outer().on(self._name, self._forward) + + def __init__(self): + self._handlers = {} + + def on( + self, name: str, handler: collections.abc.Awaitable + ): # pylint: disable=invalid-name + """Register the handler for events with this name.""" + if name not in self._handlers: + self._handlers[name] = [handler] + else: + self._handlers[name].append(handler) + return self + + def off(self, name: str, handler: collections.abc.Awaitable): + """Unregister the handler for events with this name.""" + if name not in self._handlers: + raise ValueError(name) + handlers = self._handlers[name] + handlers.remove(handler) # may raise ValueError + if len(handlers) == 0: + del self._handlers[name] + return self + + def once(self, name: str, handler: collections.abc.Awaitable): + """Register the handler for one event with this name.""" + self._Once(self, name, handler) + return self + + async def _emit(self, name: str, *event): + """Emit *event to all handlers for name.""" + if name in self._handlers: + # iterate over a copy so that a handler may change the handlers + handlers = self._handlers[name].copy() + for handler in handlers: + await handler(*event) + return self + + +class Emitter(Receiver, _EventEmitter): + """Emitter is a Receiver and an _EventEmitter of received messages.""" + + # events emitted with message + EVENT_BROADCAST: Final = f"{Receiver._ID}:0" + EVENT_DELETE_ZONE: Final = f"{Receiver.SERVICE}:{Receiver.DELETE_ZONE}" + EVENT_LIST_SCENES: Final = f"{Receiver.SERVICE}:{Receiver.LIST_SCENES}" + EVENT_LIST_ZONES: Final = f"{Receiver.SERVICE}:{Receiver.LIST_ZONES}" + EVENT_PING: Final = f"{Receiver.SERVICE}:ping" + EVENT_REPORT_SCENE_PROPERTIES: Final = ( + f"{Receiver.SERVICE}:{Receiver.REPORT_SCENE_PROPERTIES}" + ) + EVENT_REPORT_SYSTEM_PROPERTIES: Final = ( + f"{Receiver.SERVICE}:{Receiver.REPORT_SYSTEM_PROPERTIES}" + ) + EVENT_REPORT_ZONE_PROPERTIES: Final = ( + f"{Receiver.SERVICE}:{Receiver.REPORT_ZONE_PROPERTIES}" + ) + EVENT_RUN_SCENE: Final = f"{Receiver.SERVICE}:{Receiver.RUN_SCENE}" + EVENT_SET_SCENE_PROPERTIES: Final = ( + f"{Receiver.SERVICE}:{Receiver.SET_SCENE_PROPERTIES}" + ) + EVENT_SET_SYSTEM_PROPERTIES: Final = ( + f"{Receiver.SERVICE}:{Receiver.SET_SYSTEM_PROPERTIES}" + ) + EVENT_SET_ZONE_PROPERTIES: Final = ( + f"{Receiver.SERVICE}:{Receiver.SET_ZONE_PROPERTIES}" + ) + EVENT_SCENE_CREATED: Final = f"{Receiver.SERVICE}:SceneCreated" + EVENT_SCENE_DELETED: Final = f"{Receiver.SERVICE}:SceneDeleted" + EVENT_SCENE_PROPERTIES_CHANGED: Final = ( + f"{Receiver.SERVICE}:ScenePropertiesChanged" + ) + EVENT_SYSTEM_PROPERTIES_CHANGED: Final = ( + f"{Receiver.SERVICE}:SystemPropertiesChanged" + ) + EVENT_TRIGGER_RAMP_COMMAND: Final = ( + f"{Receiver.SERVICE}:{Receiver.TRIGGER_RAMP_COMMAND}" + ) + EVENT_TRIGGER_RAMP_ALL_COMMAND: Final = ( + f"{Receiver.SERVICE}:{Receiver.TRIGGER_RAMP_ALL_COMMAND}" + ) + EVENT_ZONE_ADDED: Final = f"{Receiver.SERVICE}:ZoneAdded" + EVENT_ZONE_DELETED: Final = f"{Receiver.SERVICE}:ZoneDeleted" + EVENT_ZONE_PROPERTIES_CHANGED: Final = ( + f"{Receiver.SERVICE}:ZonePropertiesChanged" + ) + + async def receive(self, message: Mapping): + if self._ID in message: + _id = message[self._ID] + if _id != 0: + # emit what we received (NOTHING) until caught up. + # such will be acknowledged but not forwarded by Once. + while self._emit_id < _id: + await self._emit(f"ID:{self._emit_id}", self._Once.NOTHING) + self._emit_id += 1 + await self._emit(f"ID:{_id}", message) + if self.SERVICE in message: + service = message[self.SERVICE] + await self._emit(f"{self.SERVICE}:{service}", message) + if self.ZID in message: + zid = message[self.ZID] + await self._emit(f"{self.SERVICE}:{service}:{zid}", message) + + async def handle_send( + self, handler: collections.abc.Awaitable, message: MutableMapping + ): + """Handle the response from the message we will send.""" + self.once(f"{self._ID}:{self._id + 1}", handler) + await self.send(message) + + def __init__(self, read_timeout: float = Receiver.READ_TIMEOUT): + Receiver.__init__(self, read_timeout) + _EventEmitter.__init__(self) + self._emit_id = 1 # id of next emit + + +class Authenticator(Emitter): # pylint: disable=too-few-public-methods + """An Authenticator session runs for the first/authentication phase only. + + This phase will either end by exception (Authenticator.Error) + or the MAC address of the unit that we successfully authenticated with. + """ + + # authenticated events emitted + EVENT_AUTHENTICATED: Final = "authenticated" + EVENT_UNAUTHENTICATED: Final = "unauthenticated" + + # Security message prefixes + SECURITY_MAC: Final = b'{"MAC":' + SECURITY_HELLO: Final = b"Hello V1 " + SECURITY_HELLO_INVALID: Final = b"[INVALID]" + SECURITY_HELLO_OK: Final = b"[OK]" + SECURITY_SETKEY: Final = b"[SETKEY]" + + # SECURITY_MAC and SECURITY_SETKEY key + MAC: Final = "MAC" + + def __init__( + self, + key: bytes = None, + read_timeout: float = Receiver.READ_TIMEOUT, + ): + super().__init__(read_timeout) + self._key: bytes = key + self._address = None + self._authenticated: bool = False + + class Error(ValueError): + """Authentication error.""" + + class _Result(asyncio.CancelledError): + """Chained from an Error if there was one.""" + + async def _send_challenge_response(self, key: bytes, challenge: bytes): + if key is None: + raise self._Result() from self.Error("authentication required") + message = self._encrypt(key, challenge).hex() + writer = self._writer + writer.write(message.encode()) + await writer.drain() + _logger.debug("\t< %s", message) + + async def _receive_security_setkey(self): + # } terminated json encoding + frame = await asyncio.wait_for( + self._reader.readuntil(b"}"), self._read_timeout + ) + _logger.debug("\t\t> %s", frame.decode()) + try: + message = json.loads(frame) + except json.JSONDecodeError as error: + _logger.warning("except json.JSONDecodeError: %s %s", error, frame) + else: + self._address = message[self.MAC] + + async def handle(message: Mapping): + try: + self.StatusError(message).raise_if() + except self.StatusError as error: + raise self._Result() from error + else: + raise self._Result() + + if self._key is None: + raise self._Result() from self.Error("authentication required") + await self.handle_send( + handle, self.compose_keys(hash_password(b""), self._key) + ) + + async def _receive_security_hello(self): + # space terminated challenge phrase + challenge = ( + await asyncio.wait_for( + self._reader.readuntil(b" "), self._read_timeout + ) + )[:-1] + _logger.debug("\t\t>\t%s", challenge.decode()) + # 12 byte MAC address + self._address = ( + await asyncio.wait_for( + self._reader.readexactly(12), self._read_timeout + ) + ).decode() + _logger.debug("\t\t>\t%s", self._address) + await self._send_challenge_response( + self._key, bytes.fromhex(challenge.decode()) + ) + + def _receive_security_hello_ok(self): + raise self._Result() + + def _receive_security_hello_invalid(self): + raise self._Result() from self.Error("Invalid") + + def _receive_security_mac(self, message): + self._address = message[self.MAC] + raise self._Result() + + async def _unwrap_security_mac(self, frame): + try: + message = json.loads(frame) + self._receive_security_mac(message) + except json.JSONDecodeError: + # may be concatenated (improperly, for JSON) with another(s?). + # rewrite/retry it as an array of frames + frames = b"[" + frame.replace(b"}{", b"},{") + b"]" + try: + messages = json.loads(frames) + except json.JSONDecodeError as error: + _logger.warning( + "except json.JSONDecodeError: %s %s", error, frames + ) + else: + mac, *others = messages + try: + self._receive_security_mac(mac) + finally: + for message in others: + await self.receive(message) + + async def unwrap(self, frame: bytes): + if frame.startswith(self.SECURITY_MAC): + await self._unwrap_security_mac(frame) + elif frame.startswith(self.SECURITY_SETKEY): + await self._receive_security_setkey() + elif frame.startswith(self.SECURITY_HELLO): + await self._receive_security_hello() + elif frame.startswith(self.SECURITY_HELLO_OK): + await self._receive_security_hello_ok() + elif frame.startswith(self.SECURITY_HELLO_INVALID): + self._receive_security_hello_invalid() + else: + await super().unwrap(frame) + + @property + def authenticated(self): + """Return True if session successfully authenticated.""" + return self._authenticated + + async def session(self): + try: + await super().session() + except self._Result as root: + address = self._address + self._address = None + if root.__cause__ is None: + self._authenticated = True + await self._emit(self.EVENT_AUTHENTICATED, address) + return address + self._authenticated = False + await self._emit(self.EVENT_UNAUTHENTICATED) + raise root.__cause__ from None + + +class _ConnectionContext(contextlib.AbstractAsyncContextManager): + def __init__(self, host: str, port: int): + self._host = host + self._port = port + self._writer = None + + async def __aenter__(self): + reader, self._writer = await asyncio.open_connection( + self._host, self._port + ) + return (reader, self._writer) + + async def __aexit__(self, et, ev, tb): + self._writer.close() + try: + await self._writer.wait_closed() + except OSError: + pass + + +class Connector(Authenticator): + """A Connector is an Authenticator that loops over connections/sessions.""" + + # connected events emitted + EVENT_CONNECTED: Final = "connected" + EVENT_DISCONNECTED: Final = "disconnected" + + # default arguments + HOST: Final = "LCM1.local" + PORT: Final = 2112 + LOOP_TIMEOUT: Final = 2 ** 8 + + def __init__( # pylint: disable=too-many-arguments + self, + host: str = HOST, + port: int = PORT, + loop_timeout: float = LOOP_TIMEOUT, + key: bytes = None, + read_timeout: float = Receiver.READ_TIMEOUT, + ): + super().__init__(key, read_timeout) + self._host = host + self._port = port + self._loop_timeout = loop_timeout + self._task = None + + def host(self): + return self._host + + async def cancel(self): + """Cancel the task running loop().""" + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + + @property + def connected(self): + """Return true if currently connected.""" + return self._writer is not None + + async def loop(self): + """Return the result of the first successful connection/session. + + If loop_timeout < 0, raise OSError if (and why) connection could not be made; + otherwise, reconnect after exponential backoff (up to loop_timeout seconds) + before trying again.""" + self._task = asyncio.current_task() + count = 0 + try: + while True: + try: + async with _ConnectionContext( + self._host, self._port + ) as connection: + # connection success, will be closed with context exit + count = 0 + self._reader, self._writer = connection + await self._emit(self.EVENT_CONNECTED) + try: + return await self.session() + except EOFError: + _logger.warning("EOFError") + except OSError as error: + _logger.warning("OSError %s", error) + except asyncio.TimeoutError: + _logger.warning("asyncio.TimeoutError") + finally: + self._reader, self._writer = None, None + await self._emit(self.EVENT_DISCONNECTED) + except OSError as error: + # connection error + if self._loop_timeout < 0: + raise + _logger.warning("OSError %s", error) + # reconnect after exponential backoff + await asyncio.sleep(min(2 ** count, self._loop_timeout)) + count += 1 + finally: + self._task = None + + +class Hub(Connector): + """Each Hub session acts as an Authenticator then Emitter.""" + + async def session(self): + await Authenticator.session(self) + await Emitter.session(self) diff --git a/custom_components/legrand_rflc/icon.png b/custom_components/legrand_rflc/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..82abb9702dfbf7eee3d9ac4a280e63c0a4f53ecb GIT binary patch literal 8607 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 literal 0 HcmV?d00001 diff --git a/custom_components/legrand_rflc/icons.json b/custom_components/legrand_rflc/icons.json new file mode 100644 index 0000000..510ef15 --- /dev/null +++ b/custom_components/legrand_rflc/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "light": { + "switch": { + "default": "mdi:light-switch" + }, + "dimmer": { + "default": "mdi:lightbulb" + } + } + } +} diff --git a/custom_components/legrand_rflc/light.py b/custom_components/legrand_rflc/light.py index f250452..39567f8 100644 --- a/custom_components/legrand_rflc/light.py +++ b/custom_components/legrand_rflc/light.py @@ -1,64 +1,145 @@ """The Legrand RFLC integration light platform.""" +from __future__ import annotations + +import asyncio +import contextlib +import logging from collections.abc import Mapping -from typing import Final +from typing import Any, Final -from homeassistant import config_entries from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_TRANSITION, - LightEntityFeature, - LightEntity, ColorMode, + LightEntity, + LightEntityFeature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo -from .const import DOMAIN +from .const import DOMAIN, LegrandRFLCConfigEntry +from .hub import Hub + +_LOGGER: Final = logging.getLogger(__name__) + +PARALLEL_UPDATES: Final = 1 class _Switch(LightEntity): _attr_should_poll = False _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_has_entity_name = True + _attr_name = None + _attr_translation_key = "switch" - def __init__(self, hub, zid: int, properties: Mapping): + def __init__(self, hub: Hub, zid: int, properties: Mapping) -> None: self._hub = hub - self._attr_name = properties[hub.NAME] - self._attr_is_on = properties[hub.POWER] - self._device_type = properties[hub.DEVICE_TYPE] self._zid = zid - hub.on( - f"{hub.EVENT_ZONE_PROPERTIES_CHANGED}:{zid}", - self._zone_properties_changed, - ) - hub.on(hub.EVENT_CONNECTED, self._available) - hub.on(hub.EVENT_DISCONNECTED, self._available) - hub.on(hub.EVENT_AUTHENTICATED, self._available) - hub.on(hub.EVENT_UNAUTHENTICATED, self._available) - - @property - def unique_id(self) -> str: - return f"{self._hub.host()}:{self._zid}" + self._zone_name: str = properties[hub.NAME] + self._attr_is_on: bool = properties[hub.POWER] + self._device_type: str = properties[hub.DEVICE_TYPE] + self._attr_unique_id = f"{hub.host()}:{zid}" + self._was_available: bool | None = None @property def available(self) -> bool: + """Return True if the hub is connected and authenticated.""" return self._hub.connected and self._hub.authenticated @property def device_info(self) -> DeviceInfo: + """Return device info for this zone.""" return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Legrand", - model="LC7001", - name=f"{self._attr_name} {self._device_type}", - via_device=(DOMAIN,self._hub.host()), + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer="Legrand", + model="LC7001", + name=self._zone_name, + via_device=(DOMAIN, self._hub.host()), + ) + + async def async_added_to_hass(self) -> None: + """Subscribe to hub events when entity is added.""" + hub = self._hub + zid = self._zid + hub.on( + f"{hub.EVENT_ZONE_PROPERTIES_CHANGED}:{zid}", + self._zone_properties_changed, + ) + hub.on(hub.EVENT_CONNECTED, self._on_availability_changed) + hub.on(hub.EVENT_DISCONNECTED, self._on_availability_changed) + hub.on(hub.EVENT_AUTHENTICATED, self._on_authenticated) + hub.on(hub.EVENT_UNAUTHENTICATED, self._on_availability_changed) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from hub events when entity is removed.""" + hub = self._hub + zid = self._zid + with contextlib.suppress(ValueError): + hub.off( + f"{hub.EVENT_ZONE_PROPERTIES_CHANGED}:{zid}", + self._zone_properties_changed, ) + for event in ( + hub.EVENT_CONNECTED, + hub.EVENT_DISCONNECTED, + hub.EVENT_UNAUTHENTICATED, + ): + with contextlib.suppress(ValueError): + hub.off(event, self._on_availability_changed) + with contextlib.suppress(ValueError): + hub.off(hub.EVENT_AUTHENTICATED, self._on_authenticated) + + def _log_availability(self) -> None: + """Log availability transitions (once per direction).""" + now_available = self.available + if self._was_available is not None and self._was_available != now_available: + if now_available: + _LOGGER.info("%s is available", self.entity_id) + else: + _LOGGER.warning("%s is unavailable", self.entity_id) + self._was_available = now_available - async def _available(self) -> None: + async def _on_availability_changed(self, *_args: object) -> None: + """Handle hub connection/auth state changes.""" + self._log_availability() self.async_write_ha_state() - def _zone_properties_changed_switch(self, message: Mapping) -> None: + async def _on_authenticated(self, *_args: object) -> None: + """Handle (re)authentication — refresh zone state from hub.""" + self._log_availability() + + # Re-query zone properties to catch changes during disconnection. + result: asyncio.Future[None] = asyncio.get_running_loop().create_future() + + async def handle_report(message: Mapping) -> None: + try: + self._hub.StatusError(message).raise_if() + except self._hub.StatusError: + if not result.done(): + result.set_result(None) + return + self._apply_properties(message) + self.async_write_ha_state() + if not result.done(): + result.set_result(None) + + await self._hub.handle_send( + handle_report, + self._hub.compose_report_zone_properties(self._zid), + ) + try: + await asyncio.wait_for(result, timeout=5.0) + except asyncio.TimeoutError: + _LOGGER.warning( + "%s: timeout refreshing state after reconnection", + self.entity_id, + ) + + def _apply_properties(self, message: Mapping) -> None: + """Apply zone properties from a hub message.""" hub = self._hub if hub.PROPERTY_LIST in message: properties = message[hub.PROPERTY_LIST] @@ -66,23 +147,43 @@ def _zone_properties_changed_switch(self, message: Mapping) -> None: self._attr_is_on = properties[hub.POWER] async def _zone_properties_changed(self, message: Mapping) -> None: - self._zone_properties_changed_switch(message) + """Handle zone property change events from the hub.""" + self._apply_properties(message) self.async_write_ha_state() async def _async_switch(self, power: bool) -> None: + """Send on/off command and await hub response.""" hub = self._hub + result: asyncio.Future[None] = asyncio.get_running_loop().create_future() - async def handle(message: Mapping): - hub.StatusError(message).raise_if() + async def handle(message: Mapping) -> None: + error = hub.StatusError(message) + if error: + if not result.done(): + result.set_exception( + HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_failed", + translation_placeholders={ + "entity": self.entity_id or "", + "error": str(error.args[2] or "hub error"), + }, + ) + ) + elif not result.done(): + result.set_result(None) await hub.handle_send( handle, hub.compose_set_zone_properties(self._zid, power=power) ) + await result - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" await self._async_switch(True) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" await self._async_switch(False) @@ -90,6 +191,7 @@ class _Dimmer(_Switch): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_supported_features = LightEntityFeature.TRANSITION + _attr_translation_key = "dimmer" @staticmethod def _normalize(value: int, ceiling: int) -> float: @@ -101,7 +203,7 @@ def _quantize(value: float, ceiling: int) -> int: """Quantize [0.0, 1.0] to [0, ceiling].""" return int(value * ceiling) - # dimming ceilings for homeassistant and us (LC7001) + # Brightness ceilings for Home Assistant and LC7001. HA: Final[int] = 255 US: Final[int] = 100 @@ -110,37 +212,50 @@ def _to_ha(value: int) -> int: return _Dimmer._quantize(_Dimmer._normalize(value, _Dimmer.US), _Dimmer.HA) @staticmethod - def _from_ha(value) -> int: + def _from_ha(value: int) -> int: return _Dimmer._quantize(_Dimmer._normalize(value, _Dimmer.HA), _Dimmer.US) - def __init__(self, hub, zid: int, properties: Mapping): + def __init__(self, hub: Hub, zid: int, properties: Mapping) -> None: super().__init__(hub, zid, properties) - self._attr_brightness = self._to_ha(properties[hub.POWER_LEVEL]) + self._attr_brightness: int = self._to_ha(properties[hub.POWER_LEVEL]) - async def _zone_properties_changed(self, message: Mapping) -> None: - self._zone_properties_changed_switch(message) + def _apply_properties(self, message: Mapping) -> None: + """Apply zone properties including brightness.""" + super()._apply_properties(message) hub = self._hub if hub.PROPERTY_LIST in message: properties = message[hub.PROPERTY_LIST] if hub.POWER_LEVEL in properties: self._attr_brightness = self._to_ha(properties[hub.POWER_LEVEL]) - self.async_write_ha_state() - async def _async_dimmer(self, power: bool, **kwargs) -> None: + async def _async_dimmer(self, power: bool, **kwargs: Any) -> None: + """Send dimmer command and await hub response.""" hub = self._hub + result: asyncio.Future[None] = asyncio.get_running_loop().create_future() async def handle(message: Mapping) -> None: - hub.StatusError(message).raise_if() - - properties: dict = {"power": power} + error = hub.StatusError(message) + if error: + if not result.done(): + result.set_exception( + HomeAssistantError( + translation_domain=DOMAIN, + translation_key="dimmer_failed", + translation_placeholders={ + "entity": self.entity_id or "", + "error": str(error.args[2] or "hub error"), + }, + ) + ) + elif not result.done(): + result.set_result(None) + + properties: dict[str, object] = {"power": power} if ATTR_BRIGHTNESS in kwargs: brightness = self._from_ha(kwargs[ATTR_BRIGHTNESS]) properties["power_level"] = brightness else: - if power: - brightness = self._from_ha(self.brightness) - else: - brightness = 0 + brightness = self._from_ha(self.brightness) if power else 0 if ATTR_TRANSITION in kwargs: change = abs(brightness - self._from_ha(self.brightness)) properties["ramp_rate"] = min( @@ -150,30 +265,30 @@ async def handle(message: Mapping) -> None: handle, hub.compose_set_zone_properties(self._zid, **properties), ) + await result - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the dimmer.""" await self._async_dimmer(True, **kwargs) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the dimmer.""" await self._async_dimmer(False, **kwargs) async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, - async_add_entities, + entry: LegrandRFLCConfigEntry, + async_add_entities: Any, ) -> None: - """Set up the Legrand RFLC integration light platform. - - Adds a Dimmer or Switch for each like zone on this entry's hub. - """ - hub = hass.data[DOMAIN][entry.entry_id] + """Set up the Legrand RFLC integration light platform.""" + hub = entry.runtime_data.hub async def zones(message: Mapping) -> None: async def zone(message: Mapping) -> None: - zid = message[hub.ZID] + zid: int = message[hub.ZID] properties = message[hub.PROPERTY_LIST] - device_type = properties[hub.DEVICE_TYPE] + device_type: str = properties[hub.DEVICE_TYPE] if device_type == hub.DIMMER: async_add_entities([_Dimmer(hub, zid, properties)], False) elif device_type == hub.SWITCH: diff --git a/custom_components/legrand_rflc/logo.png b/custom_components/legrand_rflc/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5d315e30eae3b6b8bcb839bf36f267d9615f3b65 GIT binary patch literal 7515 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%0A`dp$>BR)g68V z5mgP#^tLyYk4^agMI4X@i3O7UUGLa&t@l7%xm^bMi}U<#9o?r|iiD@M8Q@@1BG|w_ z*uyROj=Fo`9pH;gNm1#NoZ@9UB{kbiO6rPm^~;xK6&2ML6@^-|;{Ml!d;T8Yp6LI3 zg2$|w8ZhBB=Lt6cp21;mfp@sV!ouXe{ZK*fZUJ}X{R6#nRx}~NATDDAbNyF(uF?Mw D-zlWr literal 0 HcmV?d00001 diff --git a/custom_components/legrand_rflc/manifest.json b/custom_components/legrand_rflc/manifest.json index 7d033a7..e24c7a6 100644 --- a/custom_components/legrand_rflc/manifest.json +++ b/custom_components/legrand_rflc/manifest.json @@ -1,12 +1,13 @@ { "domain": "legrand_rflc", "name": "Legrand RF Lighting Control", - "version": "0.2.1", + "version": "0.3.0", "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/legrand_rflc", - "requirements": ["lc7001==1.0.3"], + "documentation": "https://github.com/MichaelB2018/legrand_rflc", + "requirements": ["cryptography>=42.0.0"], "dependencies": [], - "codeowners": ["@rtyle", "@akhudek"], + "codeowners": ["@rtyle", "@MichaelB2018"], "iot_class": "local_push", + "integration_type": "device", "dhcp": [{ "hostname": "legrand lc7001" }] } diff --git a/custom_components/legrand_rflc/py.typed b/custom_components/legrand_rflc/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/legrand_rflc/quality_scale.yaml b/custom_components/legrand_rflc/quality_scale.yaml new file mode 100644 index 0000000..c00e06f --- /dev/null +++ b/custom_components/legrand_rflc/quality_scale.yaml @@ -0,0 +1,144 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration has no custom actions/services. + appropriate-polling: + status: done + comment: local_push IoT class; no polling. + brands: + status: done + comment: Brand images shipped in brand/ directory. + common-modules: + status: done + config-flow: + status: done + config-flow-test-coverage: + status: done + dependency-transparency: + status: done + comment: Hub library vendored as hub.py (MIT license, from lc7001 v1.0.5). + docs-actions: + status: exempt + comment: Integration has no custom actions/services. + docs-high-level-description: + status: done + docs-installation-instructions: + status: done + docs-removal-instructions: + status: done + entity-event-setup: + status: done + comment: Subscriptions in async_added_to_hass, cleanup in async_will_remove_from_hass. + entity-unique-id: + status: done + comment: Format is host:zid. + has-entity-name: + status: done + runtime-data: + status: done + comment: Uses ConfigEntry.runtime_data with LegrandRFLCData dataclass. + test-before-configure: + status: done + comment: Config flow tests connection before creating entry. + test-before-setup: + status: done + comment: async_setup_entry tests connection; raises ConfigEntryNotReady on failure. + unique-config-entry: + status: done + comment: Unique ID is the hub host; duplicates are aborted. + + # Silver + action-exceptions: + status: done + comment: StatusError wrapped as HomeAssistantError. + config-entry-unloading: + status: done + comment: Hub cancelled, platforms unloaded. + docs-configuration-parameters: + status: done + docs-installation-parameters: + status: done + comment: README documents host and password fields. + docs-troubleshooting: + status: done + entity-unavailable: + status: done + comment: Tracks hub connected and authenticated state. + integration-owner: + status: done + log-when-unavailable: + status: done + comment: Logs transitions at warning (unavailable) and info (available). + parallel-updates: + status: done + comment: PARALLEL_UPDATES = 1 in light.py. + reauthentication-flow: + status: done + test-coverage: + status: done + + # Gold + devices: + status: done + comment: Hub device and per-zone child devices. + diagnostics: + status: done + comment: Reports hub status, connection info, and zone properties with auth redaction. + discovery: + status: done + comment: DHCP discovery supported. + discovery-update-info: + status: exempt + comment: Hub uses mDNS hostname; IP changes handled by DNS resolution. + docs-data-update: + status: done + docs-examples: + status: done + docs-known-limitations: + status: done + docs-supported-devices: + status: done + docs-supported-functions: + status: done + docs-use-cases: + status: done + dynamic-devices: + status: done + comment: Config entry reloads on zone add/delete. + entity-category: + status: done + comment: Light entities are primary controls (no category). Sensor entities use EntityCategory.DIAGNOSTIC. + entity-device-class: + status: exempt + comment: Light entities do not use device_class. + entity-disabled-by-default: + status: done + comment: Diagnostic sensors are enabled by default as they are low-volume. Light entities are primary controls. + entity-translations: + status: done + exception-translations: + status: done + comment: HomeAssistantError uses translation_domain and translation_key. + icon-translations: + status: done + comment: Icons defined in icons.json via translation_key. + reconfiguration-flow: + status: done + repair-issues: + status: exempt + comment: No repair-worthy issues to raise programmatically. + stale-devices: + status: done + comment: Config entry reload on zone deletion removes stale devices. + + # Platinum + async-dependency: + status: done + comment: Vendored hub.py uses asyncio natively. + inject-websession: + status: exempt + comment: Library uses raw TCP sockets, not HTTP. + strict-typing: + status: done + comment: Full type annotations with py.typed marker. diff --git a/custom_components/legrand_rflc/sensor.py b/custom_components/legrand_rflc/sensor.py new file mode 100644 index 0000000..c673d42 --- /dev/null +++ b/custom_components/legrand_rflc/sensor.py @@ -0,0 +1,303 @@ +"""The Legrand RFLC integration sensor platform.""" + +from __future__ import annotations + +import contextlib +import logging +from collections.abc import Mapping +from typing import Any, Final + +from homeassistant.components.sensor import SensorEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN, LegrandRFLCConfigEntry +from .hub import Hub + +_LOGGER: Final = logging.getLogger(__name__) + + +class _HubSensor(SensorEntity): + """Base sensor entity for the LC7001 hub device.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, hub: Hub, key: str, name: str) -> None: + self._hub = hub + self._attr_unique_id = f"{hub.host()}:sensor:{key}" + self._attr_translation_key = key + self._attr_name = name + + @property + def available(self) -> bool: + return self._hub.connected and self._hub.authenticated + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo(identifiers={(DOMAIN, self._hub.host())}) + + +class _ZoneCountSensor(_HubSensor): + """Number of zones configured on the hub.""" + + _attr_icon = "mdi:counter" + + def __init__(self, hub: Hub) -> None: + super().__init__(hub, "zone_count", "Zone count") + + async def async_added_to_hass(self) -> None: + hub = self._hub + # Subscribe to the Service-level event so we catch responses + # to ListZones requests (sent by async_setup_entry and on reconnect). + hub.on(hub.EVENT_LIST_ZONES, self._on_list_zones) + hub.on(hub.EVENT_ZONE_ADDED, self._on_zone_changed) + hub.on(hub.EVENT_ZONE_DELETED, self._on_zone_changed) + hub.on(hub.EVENT_DISCONNECTED, self._on_availability_changed) + + async def async_will_remove_from_hass(self) -> None: + hub = self._hub + + with contextlib.suppress(ValueError): + hub.off(hub.EVENT_LIST_ZONES, self._on_list_zones) + with contextlib.suppress(ValueError): + hub.off(hub.EVENT_ZONE_ADDED, self._on_zone_changed) + with contextlib.suppress(ValueError): + hub.off(hub.EVENT_ZONE_DELETED, self._on_zone_changed) + with contextlib.suppress(ValueError): + hub.off(hub.EVENT_DISCONNECTED, self._on_availability_changed) + + async def _on_availability_changed(self, *_args: object) -> None: + self.async_write_ha_state() + + async def _on_zone_changed(self, *_args: object) -> None: + # Zone added/deleted — request a fresh list. Response triggers + # EVENT_LIST_ZONES which our _on_list_zones handles. + await self._hub.send(self._hub.compose_list_zones()) + + async def _on_list_zones(self, message: Mapping) -> None: + try: + self._hub.StatusError(message).raise_if() + except self._hub.StatusError: + return + self._attr_native_value = len( + message.get(self._hub.ZONE_LIST, []) + ) + self.async_write_ha_state() + + +class _SystemPropertySensor(_HubSensor): + """Sensor that reads a property from ReportSystemProperties.""" + + def __init__(self, hub: Hub, key: str, name: str, prop_key: str) -> None: + super().__init__(hub, key, name) + self._prop_key = prop_key + + async def async_added_to_hass(self) -> None: + hub = self._hub + # Subscribe to BOTH the response event and the broadcast event. + # EVENT_REPORT_SYSTEM_PROPERTIES fires for responses to our request. + # EVENT_SYSTEM_PROPERTIES_CHANGED fires for hub-initiated broadcasts. + hub.on(hub.EVENT_REPORT_SYSTEM_PROPERTIES, self._on_properties) + hub.on(hub.EVENT_SYSTEM_PROPERTIES_CHANGED, self._on_properties) + hub.on(hub.EVENT_DISCONNECTED, self._on_availability_changed) + + async def async_will_remove_from_hass(self) -> None: + hub = self._hub + + with contextlib.suppress(ValueError): + hub.off(hub.EVENT_REPORT_SYSTEM_PROPERTIES, self._on_properties) + with contextlib.suppress(ValueError): + hub.off(hub.EVENT_SYSTEM_PROPERTIES_CHANGED, self._on_properties) + with contextlib.suppress(ValueError): + hub.off(hub.EVENT_DISCONNECTED, self._on_availability_changed) + + async def _on_availability_changed(self, *_args: object) -> None: + self.async_write_ha_state() + + async def _on_properties(self, message: Mapping) -> None: + try: + self._hub.StatusError(message).raise_if() + except self._hub.StatusError: + return + props = message.get(self._hub.PROPERTY_LIST, {}) + if self._prop_key in props: + self._attr_native_value = self._format(props[self._prop_key]) + self.async_write_ha_state() + + def _format(self, value: Any) -> Any: + return value + + +class _TimeZoneSensor(_SystemPropertySensor): + """Hub timezone offset sensor.""" + + _attr_icon = "mdi:map-clock" + + def __init__(self, hub: Hub) -> None: + super().__init__(hub, "time_zone", "Time zone", hub.EFFECTIVE_TIME_ZONE) + + def _format(self, value: Any) -> str: + """Format seconds offset as ±HH:MM.""" + total = int(value) + sign = "+" if total >= 0 else "-" + total = abs(total) + hours = total // 3600 + minutes = (total % 3600) // 60 + return f"UTC{sign}{hours:02d}:{minutes:02d}" + + +class _DSTSensor(_SystemPropertySensor): + """Hub daylight saving time sensor.""" + + _attr_icon = "mdi:weather-sunny-alert" + + def __init__(self, hub: Hub) -> None: + super().__init__( + hub, "daylight_saving_time", "Daylight saving time", + hub.DAYLIGHT_SAVING_TIME, + ) + + def _format(self, value: Any) -> str: + return "On" if value else "Off" + + +class _LocationSensor(_SystemPropertySensor): + """Hub location info sensor.""" + + _attr_icon = "mdi:map-marker" + + def __init__(self, hub: Hub) -> None: + super().__init__(hub, "location", "Location", hub.LOCATION_INFO) + + +class _AddALightSensor(_SystemPropertySensor): + """Hub add-a-light mode sensor.""" + + _attr_icon = "mdi:lightbulb-plus" + + def __init__(self, hub: Hub) -> None: + super().__init__(hub, "add_a_light", "Add a light mode", hub.ADD_A_LIGHT) + + def _format(self, value: Any) -> str: + return "Active" if value else "Inactive" + + +class _BroadcastSensor(_HubSensor): + """Sensor that reads a value from a periodic hub broadcast.""" + + def __init__( + self, hub: Hub, key: str, name: str, event: str, prop_key: str, + ) -> None: + super().__init__(hub, key, name) + self._event = event + self._prop_key = prop_key + + async def async_added_to_hass(self) -> None: + hub = self._hub + hub.on(self._event, self._on_broadcast) + hub.on(hub.EVENT_DISCONNECTED, self._on_availability_changed) + + async def async_will_remove_from_hass(self) -> None: + hub = self._hub + + with contextlib.suppress(ValueError): + hub.off(self._event, self._on_broadcast) + with contextlib.suppress(ValueError): + hub.off(hub.EVENT_DISCONNECTED, self._on_availability_changed) + + async def _on_availability_changed(self, *_args: object) -> None: + self.async_write_ha_state() + + async def _on_broadcast(self, message: Mapping) -> None: + if self._prop_key in message: + self._attr_native_value = self._format(message[self._prop_key]) + self.async_write_ha_state() + + def _format(self, value: Any) -> Any: + return value + + +class _FirmwareVersionSensor(_BroadcastSensor): + """Hub firmware version from BroadcastDiagnostics.""" + + _attr_icon = "mdi:chip" + + def __init__(self, hub: Hub) -> None: + super().__init__( + hub, "firmware_version", "Firmware version", + f"{hub.SERVICE}:BroadcastDiagnostics", "FirmwareVersion", + ) + + +class _FreeMemorySensor(_BroadcastSensor): + """Hub free memory from BroadcastMemory.""" + + _attr_icon = "mdi:memory" + _attr_native_unit_of_measurement = "B" + + def __init__(self, hub: Hub) -> None: + super().__init__( + hub, "free_memory", "Free memory", + f"{hub.SERVICE}:BroadcastMemory", "FreeMemory:", + ) + + +class _ConnectedClientsSensor(_BroadcastSensor): + """Number of JSON/TCP clients connected to the hub.""" + + _attr_icon = "mdi:lan-connect" + + def __init__(self, hub: Hub) -> None: + super().__init__( + hub, "connected_clients", "Connected clients", + f"{hub.SERVICE}:BroadcastMemory", "JsonConnections:", + ) + + +class _AuthExemptSensor(_BroadcastSensor): + """Whether the hub is in password-exempt mode.""" + + _attr_icon = "mdi:shield-lock-open-outline" + + def __init__(self, hub: Hub) -> None: + super().__init__( + hub, "auth_exempt", "Password exempt", + f"{hub.SERVICE}:BroadcastDiagnostics", "AuthExempt", + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LegrandRFLCConfigEntry, + async_add_entities: Any, +) -> None: + """Set up the Legrand RFLC sensor platform.""" + hub = entry.runtime_data.hub + + entities: list[SensorEntity] = [ + _ZoneCountSensor(hub), + _TimeZoneSensor(hub), + _DSTSensor(hub), + _LocationSensor(hub), + _AddALightSensor(hub), + _FirmwareVersionSensor(hub), + _FreeMemorySensor(hub), + _ConnectedClientsSensor(hub), + _AuthExemptSensor(hub), + ] + async_add_entities(entities, False) + + async def _request_initial_data(*_args: object) -> None: + """Send data requests on (re)authentication.""" + await hub.send(hub.compose_report_system_properties()) + await hub.send(hub.compose_list_zones()) + + hub.on(hub.EVENT_AUTHENTICATED, _request_initial_data) + + # Send initial requests now — entities are subscribed, and the hub loop + # will deliver the responses via Service-level events. + await hub.send(hub.compose_report_system_properties()) + await hub.send(hub.compose_list_zones()) diff --git a/custom_components/legrand_rflc/strings.json b/custom_components/legrand_rflc/strings.json index 81af11f..3f270ca 100644 --- a/custom_components/legrand_rflc/strings.json +++ b/custom_components/legrand_rflc/strings.json @@ -3,26 +3,92 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "step": { - "reauth": { - "data": { - "host": "[%key:common::config_flow::data::host%]", - "password": "[%key:common::config_flow::data::password%]" - } - }, - "user": { - "data": { - "host": "[%key:common::config_flow::data::host%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_host": "[%key:common::config_flow::error::invalid_host%]" - } + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "Hostname or IP address of your LC7001 hub (default: LCM1.local).", + "password": "Leave empty if the hub has no password configured." + } + }, + "reauth_confirm": { + "title": "Re-authenticate {host}", + "description": "The hub at {host} requires re-authentication.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Enter the new password for the hub." + } + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "Hostname or IP address of your LC7001 hub.", + "password": "Leave empty to keep the current password, or enter a new one." + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + } + }, + "entity": { + "light": { + "switch": { + "name": "Light" + }, + "dimmer": { + "name": "Light" + } + }, + "sensor": { + "zone_count": { + "name": "Zone count" + }, + "time_zone": { + "name": "Time zone" + }, + "daylight_saving_time": { + "name": "Daylight saving time" + }, + "location": { + "name": "Location" + }, + "add_a_light": { + "name": "Add a light mode" + }, + "firmware_version": { + "name": "Firmware version" + }, + "free_memory": { + "name": "Free memory" + }, + "connected_clients": { + "name": "Connected clients" + }, + "auth_exempt": { + "name": "Password exempt" + } + } + }, + "exceptions": { + "switch_failed": { + "message": "Failed to switch {entity}: {error}" + }, + "dimmer_failed": { + "message": "Failed to control {entity}: {error}" + } } } diff --git a/custom_components/legrand_rflc/translations/en.json b/custom_components/legrand_rflc/translations/en.json index e860afd..48dcd61 100644 --- a/custom_components/legrand_rflc/translations/en.json +++ b/custom_components/legrand_rflc/translations/en.json @@ -4,26 +4,92 @@ "already_configured": "Device is already configured", "already_in_progress": "Configuration flow is already in progress", "no_devices_found": "No devices found on the network", - "reauth_successful": "Re-authentication was successful" + "reauth_successful": "Re-authentication was successful", + "reconfigure_successful": "Reconfiguration was successful" }, "error": { "invalid_auth": "Invalid authentication", "invalid_host": "Invalid hostname or IP address" }, "step": { - "reauth": { + "user": { "data": { "host": "Host", "password": "Password" + }, + "data_description": { + "host": "Hostname or IP address of your LC7001 hub (default: LCM1.local).", + "password": "Leave empty if the hub has no password configured." } }, - "user": { + "reauth_confirm": { + "title": "Re-authenticate {host}", + "description": "The hub at {host} requires re-authentication.", + "data": { + "password": "Password" + }, + "data_description": { + "password": "Enter the new password for the hub." + } + }, + "reconfigure": { "data": { "host": "Host", "password": "Password" + }, + "data_description": { + "host": "Hostname or IP address of your LC7001 hub.", + "password": "Leave empty to keep the current password, or enter a new one." } } } }, + "entity": { + "light": { + "switch": { + "name": "Light" + }, + "dimmer": { + "name": "Light" + } + }, + "sensor": { + "zone_count": { + "name": "Zone count" + }, + "time_zone": { + "name": "Time zone" + }, + "daylight_saving_time": { + "name": "Daylight saving time" + }, + "location": { + "name": "Location" + }, + "add_a_light": { + "name": "Add a light mode" + }, + "firmware_version": { + "name": "Firmware version" + }, + "free_memory": { + "name": "Free memory" + }, + "connected_clients": { + "name": "Connected clients" + }, + "auth_exempt": { + "name": "Password exempt" + } + } + }, + "exceptions": { + "switch_failed": { + "message": "Failed to switch {entity}: {error}" + }, + "dimmer_failed": { + "message": "Failed to control {entity}: {error}" + } + }, "title": "Legrand RF Lighting Control" } \ No newline at end of file diff --git a/hacs.json b/hacs.json index 156b338..ba28c9d 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { - "name": "Legrand RF Lighting ", - "render_readme": true, - "domains": ["dimmer","switch"] -} \ No newline at end of file + "name": "Legrand RF Lighting Control", + "render_readme": true, + "iot_class": "local_push" +} diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..82abb9702dfbf7eee3d9ac4a280e63c0a4f53ecb GIT binary patch literal 8607 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 literal 0 HcmV?d00001 diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5d315e30eae3b6b8bcb839bf36f267d9615f3b65 GIT binary patch literal 7515 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%0A`dp$>BR)g68V z5mgP#^tLyYk4^agMI4X@i3O7UUGLa&t@l7%xm^bMi}U<#9o?r|iiD@M8Q@@1BG|w_ z*uyROj=Fo`9pH;gNm1#NoZ@9UB{kbiO6rPm^~;xK6&2ML6@^-|;{Ml!d;T8Yp6LI3 zg2$|w8ZhBB=Lt6cp21;mfp@sV!ouXe{ZK*fZUJ}X{R6#nRx}~NATDDAbNyF(uF?Mw D-zlWr literal 0 HcmV?d00001 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..76df337 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Legrand RFLC integration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f1806a0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,269 @@ +"""Shared test fixtures for the Legrand RFLC integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Final +from unittest.mock import AsyncMock, patch + +import pytest +from homeassistant.const import CONF_AUTHENTICATION, CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.legrand_rflc.const import DOMAIN, LegrandRFLCData +from custom_components.legrand_rflc.hub import ( + Authenticator, + Composer, + Connector, + Receiver, +) + +MOCK_HOST: Final = "192.168.1.100" +MOCK_MAC: Final = "001122334455" +MOCK_KEY_HEX: Final = "0123456789abcdef0123456789abcdef" + + +class MockHub: + """Mock Hub with real event emitting for testing.""" + + # Mirror real event constants. + EVENT_CONNECTED: Final = Connector.EVENT_CONNECTED + EVENT_DISCONNECTED: Final = Connector.EVENT_DISCONNECTED + EVENT_AUTHENTICATED: Final = Authenticator.EVENT_AUTHENTICATED + EVENT_UNAUTHENTICATED: Final = Authenticator.EVENT_UNAUTHENTICATED + EVENT_ZONE_ADDED: Final = f"{Receiver.SERVICE}:ZoneAdded" + EVENT_ZONE_DELETED: Final = f"{Receiver.SERVICE}:ZoneDeleted" + EVENT_ZONE_PROPERTIES_CHANGED: Final = ( + f"{Receiver.SERVICE}:ZonePropertiesChanged" + ) + + # Mirror composer key constants. + SERVICE: Final = Composer.SERVICE + ZID: Final = Composer.ZID + ZONE_LIST: Final = Composer.ZONE_LIST + PROPERTY_LIST: Final = Composer.PROPERTY_LIST + NAME: Final = Composer.NAME + POWER: Final = Composer.POWER + POWER_LEVEL: Final = Composer.POWER_LEVEL + RAMP_RATE: Final = Composer.RAMP_RATE + DEVICE_TYPE: Final = Composer.DEVICE_TYPE + DIMMER: Final = Composer.DIMMER + SWITCH: Final = Composer.SWITCH + + StatusError = Receiver.StatusError + + def __init__( + self, + host: str = MOCK_HOST, + zones: list[dict[str, Any]] | None = None, + ) -> None: + self._host = host + self._connected = True + self._authenticated = True + self._zones: list[dict[str, Any]] = zones or [] + self._handlers: dict[str, list[Any]] = {} + self._set_zone_response: dict[str, Any] | None = None + + def host(self) -> str: + return self._host + + @property + def connected(self) -> bool: + return self._connected + + @connected.setter + def connected(self, value: bool) -> None: + self._connected = value + + @property + def authenticated(self) -> bool: + return self._authenticated + + @authenticated.setter + def authenticated(self, value: bool) -> None: + self._authenticated = value + + # --- Event emitter --- + + def on(self, name: str, handler: Any) -> MockHub: + self._handlers.setdefault(name, []).append(handler) + return self + + def off(self, name: str, handler: Any) -> MockHub: + if name not in self._handlers: + raise ValueError(name) + self._handlers[name].remove(handler) + if not self._handlers[name]: + del self._handlers[name] + return self + + def once(self, name: str, handler: Any) -> MockHub: + async def wrapper(*args: Any) -> None: + self.off(name, wrapper) + await handler(*args) + + self.on(name, wrapper) + return self + + async def emit(self, name: str, *event: Any) -> None: + if name in self._handlers: + for handler in list(self._handlers[name]): + await handler(*event) + + # --- Hub lifecycle --- + + async def cancel(self) -> None: + pass + + async def loop(self) -> str: + self._connected = True + self._authenticated = True + await self.emit(self.EVENT_CONNECTED) + await self.emit(self.EVENT_AUTHENTICATED, self._host) + return self._host + + # --- Compose helpers --- + + def compose_list_zones(self) -> dict[str, Any]: + return {self.SERVICE: "ListZones"} + + def compose_report_zone_properties(self, zid: int) -> dict[str, Any]: + return {self.SERVICE: "ReportZoneProperties", self.ZID: zid} + + def compose_set_zone_properties( + self, zid: int, **kwargs: Any + ) -> dict[str, Any]: + return {self.SERVICE: "SetZoneProperties", self.ZID: zid, **kwargs} + + # --- Simulated request/response --- + + async def handle_send( + self, handler: Any, message: dict[str, Any] + ) -> None: + service = message.get(self.SERVICE, "") + + if service == "ListZones": + await handler( + { + "Status": "Success", + self.ZONE_LIST: [ + {self.ZID: z["zid"]} for z in self._zones + ], + } + ) + elif service == "ReportZoneProperties": + zid = message[self.ZID] + for z in self._zones: + if z["zid"] == zid: + await handler( + { + "Status": "Success", + self.ZID: zid, + self.PROPERTY_LIST: z["properties"], + } + ) + return + await handler( + { + "Status": "Error", + "ErrorCode": "1", + "ErrorText": "Zone not found", + } + ) + elif service == "SetZoneProperties": + if self._set_zone_response is not None: + await handler(self._set_zone_response) + else: + await handler({"Status": "Success"}) + else: + await handler({"Status": "Success"}) + + async def send(self, message: Any) -> None: + pass + + +def make_zone( + zid: int, + name: str = "Test Zone", + device_type: str = "Dimmer", + power: bool = True, + power_level: int = 50, +) -> dict[str, Any]: + """Create a zone dict for MockHub.""" + props: dict[str, Any] = { + Composer.NAME: name, + Composer.DEVICE_TYPE: device_type, + Composer.POWER: power, + } + if device_type == "Dimmer": + props[Composer.POWER_LEVEL] = power_level + return {"zid": zid, "properties": props} + + +# ---- Fixtures ---- + +DEFAULT_ZONES: Final = [ + make_zone(0, "Living Room", "Dimmer", True, 75), + make_zone(1, "Kitchen", "Switch", False), +] + + +@pytest.fixture +def mock_hub() -> MockHub: + """Return a MockHub with two default zones.""" + return MockHub(zones=list(DEFAULT_ZONES)) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a bare config entry (no auth).""" + return MockConfigEntry( + domain=DOMAIN, + title=MOCK_HOST, + data={CONF_HOST: MOCK_HOST, CONF_MAC: MOCK_MAC}, + unique_id=MOCK_HOST, + ) + + +@pytest.fixture +def mock_config_entry_with_auth() -> MockConfigEntry: + """Return a config entry with authentication.""" + return MockConfigEntry( + domain=DOMAIN, + title=MOCK_HOST, + data={ + CONF_HOST: MOCK_HOST, + CONF_MAC: MOCK_MAC, + CONF_AUTHENTICATION: MOCK_KEY_HEX, + }, + unique_id=MOCK_HOST, + ) + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub: MockHub, +) -> MockHub: + """Set up the integration with mocked Connector and Hub. + + Returns the MockHub instance so tests can emit events on it. + """ + mock_config_entry.add_to_hass(hass) + + with ( + patch( + "custom_components.legrand_rflc.Connector", + ) as mock_conn_cls, + patch( + "custom_components.legrand_rflc.Hub", + return_value=mock_hub, + ), + ): + mock_conn_cls.return_value.loop = AsyncMock(return_value=MOCK_MAC) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_hub diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..7c2a5ab --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,286 @@ +"""Tests for the Legrand RFLC config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from homeassistant import config_entries +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.legrand_rflc.const import DOMAIN +from custom_components.legrand_rflc.hub import Authenticator + +from .conftest import MOCK_HOST, MOCK_KEY_HEX, MOCK_MAC + +PATCH = "custom_components.legrand_rflc.config_flow" + + +# ---- User step ---- + + +async def test_user_step_shows_form(hass: HomeAssistant) -> None: + """Test that the user step shows a form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_user_step_success(hass: HomeAssistant) -> None: + """Test successful user flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch(f"{PATCH}.Connector") as mock_conn: + mock_conn.return_value.loop = AsyncMock(return_value=MOCK_MAC) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: MOCK_HOST}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_HOST + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_MAC] == MOCK_MAC + assert CONF_AUTHENTICATION not in result["data"] + + +async def test_user_step_with_password(hass: HomeAssistant) -> None: + """Test user flow with password stores auth key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + patch(f"{PATCH}.Connector") as mock_conn, + patch(f"{PATCH}.hash_password", return_value=b"\x01" * 16), + ): + mock_conn.return_value.loop = AsyncMock(return_value=MOCK_MAC) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: MOCK_HOST, CONF_PASSWORD: "secret"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert CONF_AUTHENTICATION in result["data"] + assert result["data"][CONF_AUTHENTICATION] == ("01" * 16) + + +async def test_user_step_invalid_host(hass: HomeAssistant) -> None: + """Test user flow with unreachable host shows error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch(f"{PATCH}.Connector") as mock_conn: + mock_conn.return_value.loop = AsyncMock( + side_effect=OSError("Connection refused") + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: MOCK_HOST}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_HOST: "invalid_host"} + + +async def test_user_step_invalid_auth(hass: HomeAssistant) -> None: + """Test user flow with bad password shows error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + patch(f"{PATCH}.Connector") as mock_conn, + patch(f"{PATCH}.hash_password", return_value=b"\x01" * 16), + ): + mock_conn.return_value.loop = AsyncMock( + side_effect=Authenticator.Error("Invalid") + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: MOCK_HOST, CONF_PASSWORD: "wrong"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} + + +async def test_user_step_already_configured(hass: HomeAssistant) -> None: + """Test user flow aborts if entry already exists.""" + existing = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: MOCK_HOST, CONF_MAC: MOCK_MAC}, + unique_id=MOCK_HOST, + ) + existing.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch(f"{PATCH}.Connector") as mock_conn: + mock_conn.return_value.loop = AsyncMock(return_value=MOCK_MAC) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: MOCK_HOST}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +# ---- Reauth step ---- + + +async def test_reauth_flow_success(hass: HomeAssistant) -> None: + """Test successful reauth updates the entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: MOCK_HOST, CONF_MAC: MOCK_MAC}, + unique_id=MOCK_HOST, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": MOCK_HOST, + }, + data=entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with ( + patch(f"{PATCH}.Connector") as mock_conn, + patch(f"{PATCH}.hash_password", return_value=b"\x02" * 16), + ): + mock_conn.return_value.loop = AsyncMock(return_value=MOCK_MAC) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "newpass"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_flow_invalid_auth(hass: HomeAssistant) -> None: + """Test reauth with bad password shows error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: MOCK_HOST, CONF_MAC: MOCK_MAC}, + unique_id=MOCK_HOST, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": MOCK_HOST, + }, + data=entry.data, + ) + + with ( + patch(f"{PATCH}.Connector") as mock_conn, + patch(f"{PATCH}.hash_password", return_value=b"\x02" * 16), + ): + mock_conn.return_value.loop = AsyncMock( + side_effect=Authenticator.Error("Invalid") + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "wrong"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} + + +# ---- Reconfigure step ---- + + +async def test_reconfigure_flow_success(hass: HomeAssistant) -> None: + """Test successful reconfigure updates the entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: MOCK_HOST, + CONF_MAC: MOCK_MAC, + CONF_AUTHENTICATION: MOCK_KEY_HEX, + }, + unique_id=MOCK_HOST, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + new_host = "192.168.1.200" + with patch(f"{PATCH}.Connector") as mock_conn: + mock_conn.return_value.loop = AsyncMock(return_value="aabbccddeeff") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: new_host}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == new_host + # Previous auth key should be preserved since no new password given. + assert entry.data[CONF_AUTHENTICATION] == MOCK_KEY_HEX + + +async def test_reconfigure_flow_invalid_host(hass: HomeAssistant) -> None: + """Test reconfigure with bad host shows error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: MOCK_HOST, CONF_MAC: MOCK_MAC}, + unique_id=MOCK_HOST, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + ) + + with patch(f"{PATCH}.Connector") as mock_conn: + mock_conn.return_value.loop = AsyncMock( + side_effect=OSError("Unreachable") + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "bad-host"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_HOST: "invalid_host"} diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..d735bf1 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,66 @@ +"""Tests for the Legrand RFLC diagnostics platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from homeassistant.const import CONF_AUTHENTICATION, CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.legrand_rflc.const import DOMAIN, LegrandRFLCData +from custom_components.legrand_rflc.diagnostics import ( + async_get_config_entry_diagnostics, +) + +from .conftest import MOCK_HOST, MOCK_KEY_HEX, MOCK_MAC, MockHub, make_zone + + +async def test_diagnostics_connected(hass: HomeAssistant) -> None: + """Test diagnostics when hub is connected.""" + hub = MockHub( + zones=[make_zone(0, "Living Room", "Dimmer", True, 75)], + ) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: MOCK_HOST, + CONF_MAC: MOCK_MAC, + CONF_AUTHENTICATION: MOCK_KEY_HEX, + }, + unique_id=MOCK_HOST, + ) + entry.add_to_hass(hass) + entry.runtime_data = LegrandRFLCData(hub=hub) + + diag = await async_get_config_entry_diagnostics(hass, entry) + + assert diag["hub"]["connected"] is True + assert diag["hub"]["authenticated"] is True + assert diag["hub"]["host"] == MOCK_HOST + # Auth key should be redacted. + assert diag["config_entry"][CONF_AUTHENTICATION] == "**REDACTED**" + assert "zones" in diag + assert len(diag["zones"]) == 1 + assert diag["zones"][0]["zid"] == 0 + + +async def test_diagnostics_disconnected(hass: HomeAssistant) -> None: + """Test diagnostics when hub is disconnected.""" + hub = MockHub() + hub.connected = False + hub.authenticated = False + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: MOCK_HOST, CONF_MAC: MOCK_MAC}, + unique_id=MOCK_HOST, + ) + entry.add_to_hass(hass) + entry.runtime_data = LegrandRFLCData(hub=hub) + + diag = await async_get_config_entry_diagnostics(hass, entry) + + assert diag["hub"]["connected"] is False + assert "zones" not in diag diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..e3177d2 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,99 @@ +"""Tests for the Legrand RFLC integration setup.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.legrand_rflc.const import DOMAIN, LegrandRFLCData +from custom_components.legrand_rflc.hub import Authenticator + +from .conftest import MOCK_HOST, MOCK_MAC, MockHub + +INIT_PATCH = "custom_components.legrand_rflc" + + +async def test_setup_entry_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub: MockHub, +) -> None: + """Test successful setup creates runtime data and registers device.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch(f"{INIT_PATCH}.Connector") as mock_conn_cls, + patch(f"{INIT_PATCH}.Hub", return_value=mock_hub), + ): + mock_conn_cls.return_value.loop = AsyncMock(return_value=MOCK_MAC) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is mock_config_entry.state # loaded + assert isinstance(mock_config_entry.runtime_data, LegrandRFLCData) + assert mock_config_entry.runtime_data.hub is mock_hub + + +async def test_setup_entry_connection_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup raises ConfigEntryNotReady on connection failure.""" + mock_config_entry.add_to_hass(hass) + + with patch(f"{INIT_PATCH}.Connector") as mock_conn_cls: + mock_conn_cls.return_value.loop = AsyncMock( + side_effect=OSError("Connection refused") + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + mock_config_entry.state + is mock_config_entry.state.__class__.SETUP_RETRY + ) + + +async def test_setup_entry_auth_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup raises ConfigEntryNotReady on auth failure.""" + mock_config_entry.add_to_hass(hass) + + with patch(f"{INIT_PATCH}.Connector") as mock_conn_cls: + mock_conn_cls.return_value.loop = AsyncMock( + side_effect=Authenticator.Error("Invalid") + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + mock_config_entry.state + is mock_config_entry.state.__class__.SETUP_RETRY + ) + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub: MockHub, +) -> None: + """Test unloading cancels the hub and unloads platforms.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch(f"{INIT_PATCH}.Connector") as mock_conn_cls, + patch(f"{INIT_PATCH}.Hub", return_value=mock_hub), + ): + mock_conn_cls.return_value.loop = AsyncMock(return_value=MOCK_MAC) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert result is True diff --git a/tests/test_light.py b/tests/test_light.py new file mode 100644 index 0000000..9cd6693 --- /dev/null +++ b/tests/test_light.py @@ -0,0 +1,230 @@ +"""Tests for the Legrand RFLC light platform.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_TRANSITION, + ColorMode, + LightEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.legrand_rflc.const import DOMAIN, LegrandRFLCData +from custom_components.legrand_rflc.hub import Composer + +from .conftest import MOCK_HOST, MOCK_MAC, MockHub, make_zone + +INIT_PATCH = "custom_components.legrand_rflc" + + +@pytest.fixture +async def setup_lights( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub: MockHub, +) -> MockHub: + """Set up the integration and return the mock hub.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch(f"{INIT_PATCH}.Connector") as mock_conn_cls, + patch(f"{INIT_PATCH}.Hub", return_value=mock_hub), + ): + mock_conn_cls.return_value.loop = AsyncMock(return_value=MOCK_MAC) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_hub + + +# ---- Entity creation ---- + + +async def test_dimmer_entity_created( + hass: HomeAssistant, setup_lights: MockHub +) -> None: + """Test a dimmer zone creates a light entity with brightness.""" + state = hass.states.get("light.living_room") + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) is not None + + +async def test_switch_entity_created( + hass: HomeAssistant, setup_lights: MockHub +) -> None: + """Test a switch zone creates a light entity without brightness.""" + state = hass.states.get("light.kitchen") + assert state is not None + assert state.state == STATE_OFF + + +# ---- State updates via events ---- + + +async def test_zone_properties_changed( + hass: HomeAssistant, setup_lights: MockHub +) -> None: + """Test entity state updates on zone property change events.""" + hub = setup_lights + + # Simulate the hub pushing a property change. + await hub.emit( + f"{hub.EVENT_ZONE_PROPERTIES_CHANGED}:1", + { + hub.SERVICE: "ZonePropertiesChanged", + hub.ZID: 1, + hub.PROPERTY_LIST: {hub.POWER: True}, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.kitchen") + assert state is not None + assert state.state == STATE_ON + + +async def test_dimmer_brightness_update( + hass: HomeAssistant, setup_lights: MockHub +) -> None: + """Test dimmer brightness updates on zone property change.""" + hub = setup_lights + + await hub.emit( + f"{hub.EVENT_ZONE_PROPERTIES_CHANGED}:0", + { + hub.SERVICE: "ZonePropertiesChanged", + hub.ZID: 0, + hub.PROPERTY_LIST: {hub.POWER_LEVEL: 50, hub.POWER: True}, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.living_room") + assert state is not None + # 50 / 100 * 255 = 127 + assert state.attributes[ATTR_BRIGHTNESS] == 127 + + +# ---- Commands ---- + + +async def test_switch_turn_on( + hass: HomeAssistant, setup_lights: MockHub +) -> None: + """Test turning on a switch.""" + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.kitchen"}, + blocking=True, + ) + # No exception means success; the mock hub returned Success. + + +async def test_switch_turn_off( + hass: HomeAssistant, setup_lights: MockHub +) -> None: + """Test turning off a switch.""" + await hass.services.async_call( + "light", + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.kitchen"}, + blocking=True, + ) + + +async def test_dimmer_turn_on_with_brightness( + hass: HomeAssistant, setup_lights: MockHub +) -> None: + """Test turning on a dimmer with brightness.""" + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.living_room", ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + +async def test_command_error_raises_ha_error( + hass: HomeAssistant, setup_lights: MockHub +) -> None: + """Test that a hub error response raises HomeAssistantError.""" + hub = setup_lights + hub._set_zone_response = { + "Status": "Error", + "ErrorCode": "99", + "ErrorText": "Zone busy", + } + + with pytest.raises(HomeAssistantError, match="hub error"): + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.kitchen"}, + blocking=True, + ) + + +# ---- Availability ---- + + +async def test_availability_tracks_connection( + hass: HomeAssistant, setup_lights: MockHub +) -> None: + """Test entity becomes unavailable when hub disconnects.""" + hub = setup_lights + + hub.connected = False + await hub.emit(hub.EVENT_DISCONNECTED) + await hass.async_block_till_done() + + state = hass.states.get("light.living_room") + assert state is not None + assert state.state == "unavailable" + + hub.connected = True + hub.authenticated = True + await hub.emit(hub.EVENT_AUTHENTICATED, MOCK_HOST) + await hass.async_block_till_done() + + state = hass.states.get("light.living_room") + assert state is not None + assert state.state != "unavailable" + + +# ---- Brightness normalization ---- + + +async def test_brightness_normalization() -> None: + """Test brightness conversion between HA (0-255) and LC7001 (0-100).""" + from custom_components.legrand_rflc.light import _Dimmer + + # LC7001 0 → HA 0 + assert _Dimmer._to_ha(0) == 0 + # LC7001 100 → HA 255 + assert _Dimmer._to_ha(100) == 255 + # LC7001 50 → HA 127 + assert _Dimmer._to_ha(50) == 127 + + # HA 0 → LC7001 0 + assert _Dimmer._from_ha(0) == 0 + # HA 255 → LC7001 100 + assert _Dimmer._from_ha(255) == 100 + # HA 127 → LC7001 49 (truncation) + assert _Dimmer._from_ha(127) == 49