From dd7b6cd49478710b2a9e705f3e7e70efc385b54a Mon Sep 17 00:00:00 2001 From: thargy <1908506+thargy@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:45:30 +0000 Subject: [PATCH] Scaffold and implement robust_light integration --- .github/workflows/validate.yml | 32 ++ .gitignore | 221 +----------- README.md | 34 +- custom_components/robust_light/__init__.py | 36 ++ custom_components/robust_light/config_flow.py | 129 +++++++ custom_components/robust_light/const.py | 22 ++ custom_components/robust_light/light.py | 328 ++++++++++++++++++ custom_components/robust_light/manifest.json | 9 + custom_components/robust_light/strings.json | 37 ++ .../robust_light/translations/en.json | 37 ++ hacs.json | 5 + pytest.ini | 3 + tests/__init__.py | 1 + tests/conftest.py | 7 + tests/test_light.py | 95 +++++ 15 files changed, 781 insertions(+), 215 deletions(-) create mode 100644 .github/workflows/validate.yml create mode 100644 custom_components/robust_light/__init__.py create mode 100644 custom_components/robust_light/config_flow.py create mode 100644 custom_components/robust_light/const.py create mode 100644 custom_components/robust_light/light.py create mode 100644 custom_components/robust_light/manifest.json create mode 100644 custom_components/robust_light/strings.json create mode 100644 custom_components/robust_light/translations/en.json create mode 100644 hacs.json create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_light.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..d8fe59f --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,32 @@ +name: Validate + +on: + push: + branches: + - main + pull_request: + +jobs: + pytest: + name: Run pytest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-homeassistant-custom-component + - name: Test with pytest + run: pytest tests/ + + hassfest: + name: Run Hassfest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Hassfest validation + uses: home-assistant/actions/hassfest@master diff --git a/.gitignore b/.gitignore index 83972fa..8639de1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,218 +1,13 @@ -# Byte-compiled / optimized / DLL files __pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging +*.pyc +*.pyo +*.pyd .Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -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/ +env/ +venv/ +.venv/ +.pytest_cache/ .tox/ -.nox/ .coverage -.coverage.* -.cache -nosetests.xml coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# 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 -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .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 - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -# poetry.lock -# poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -# pdm.lock -# pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -# pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# Redis -*.rdb -*.aof -*.pid - -# RabbitMQ -mnesia/ -rabbitmq/ -rabbitmq-data/ - -# ActiveMQ -activemq-data/ - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ -# Temporary file for partial code execution -tempCodeRunnerFile.py - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ - -# Streamlit -.streamlit/secrets.toml +htmlcov/ diff --git a/README.md b/README.md index 9b6085d..66b5b1c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,32 @@ -# hacs_robust_light -A hardened light that retries settings. +# Robust Light + +Robust Light is a Home Assistant custom integration that provides a hardened proxy light helper. It wraps unreliable lights (such as problematic Zigbee bulbs or LED controllers) and ensures their state matches the commanded state. + +## Features +- **Command Chunking**: Separates color and brightness adjustments instead of sending a single composite payload, avoiding confusing some poorly implemented firmwares. +- **In-Flight Fade Tracking**: Periodically monitors the state during long fades to ensure the device doesn't stop responding mid-transition. +- **Exponential Backoff Retries**: Applies exponential backoff rolloff to resend failed instructions if the light does not report the expected state. +- **Clean Entity Hiding**: Automatically hides the original underlying light entity in the UI entity registry so you only interact with the robust version. + +## Installation + +### HACS +1. Open HACS in Home Assistant. +2. Go to "Integrations". +3. Click the three dots in the top right corner and select "Custom repositories". +4. Add the URL to this repository (`https://github.com/MarkGodwin/hacs_robust_light`) and select "Integration" as the category. +5. Click "Add". +6. Install the "Robust Light" repository. +7. Restart Home Assistant. + +### Manual Setup +1. Copy the `custom_components/robust_light` directory into your Home Assistant `custom_components` directory. +2. Restart Home Assistant. + +## Setup via UI +1. Go to **Settings -> Devices & Services** in Home Assistant. +2. Click **Add Integration** in the bottom right corner. +3. Search for **Robust Light**. +4. Select the target light you want to wrap. +5. (Optional) Provide a name, or it will default to the target light's name + " Robust". +6. Configure the options such as delays, chunking, and retry limits via the Integration Options page. diff --git a/custom_components/robust_light/__init__.py b/custom_components/robust_light/__init__.py new file mode 100644 index 0000000..1c98216 --- /dev/null +++ b/custom_components/robust_light/__init__.py @@ -0,0 +1,36 @@ +"""The Robust Light integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.const import Platform + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Robust Light from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Reload entry when its options change + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + if DOMAIN in hass.data: + # Cleanup any specific data if needed, currently empty + pass + return unload_ok + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/robust_light/config_flow.py b/custom_components/robust_light/config_flow.py new file mode 100644 index 0000000..6ae6b9e --- /dev/null +++ b/custom_components/robust_light/config_flow.py @@ -0,0 +1,129 @@ +"""Config flow for Robust Light integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import selector +import homeassistant.helpers.config_validation as cv + +from .const import ( + DOMAIN, + CONF_TARGET_LIGHT, + CONF_NAME, + CONF_HIDE_ORIGINAL, + CONF_SPLIT_COMMANDS, + CONF_SPLIT_DELAY_MS, + CONF_MAX_RETRIES, + CONF_INITIAL_RETRY_DELAY, + CONF_BRIGHTNESS_TOLERANCE, + DEFAULT_HIDE_ORIGINAL, + DEFAULT_SPLIT_COMMANDS, + DEFAULT_SPLIT_DELAY_MS, + DEFAULT_MAX_RETRIES, + DEFAULT_INITIAL_RETRY_DELAY, + DEFAULT_BRIGHTNESS_TOLERANCE, +) + +_LOGGER = logging.getLogger(__name__) + + +class RobustLightConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Robust Light.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return RobustLightOptionsFlow(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + target_light = user_input[CONF_TARGET_LIGHT] + await self.async_set_unique_id(target_light) + self._abort_if_unique_id_configured() + + # Attempt to get the friendly name of the target entity to set as default name + name = user_input.get(CONF_NAME) + if not name: + target_state = self.hass.states.get(target_light) + if target_state: + name = f"{target_state.name} Robust" + else: + name = f"Robust Light ({target_light})" + + return self.async_create_entry(title=name, data=user_input) + + data_schema = vol.Schema( + { + vol.Required(CONF_TARGET_LIGHT): selector.EntitySelector( + selector.EntitySelectorConfig(domain="light") + ), + vol.Optional(CONF_NAME): cv.string, + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + +class RobustLightOptionsFlow(config_entries.OptionsFlow): + """Handle options flow for Robust Light.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = self.config_entry.options + + data_schema = vol.Schema( + { + vol.Optional( + CONF_HIDE_ORIGINAL, + default=options.get(CONF_HIDE_ORIGINAL, DEFAULT_HIDE_ORIGINAL), + ): cv.boolean, + vol.Optional( + CONF_SPLIT_COMMANDS, + default=options.get(CONF_SPLIT_COMMANDS, DEFAULT_SPLIT_COMMANDS), + ): cv.boolean, + vol.Optional( + CONF_SPLIT_DELAY_MS, + default=options.get(CONF_SPLIT_DELAY_MS, DEFAULT_SPLIT_DELAY_MS), + ): vol.All(vol.Coerce(int), vol.Range(min=20, max=1000)), + vol.Optional( + CONF_MAX_RETRIES, + default=options.get(CONF_MAX_RETRIES, DEFAULT_MAX_RETRIES), + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=10)), + vol.Optional( + CONF_INITIAL_RETRY_DELAY, + default=options.get(CONF_INITIAL_RETRY_DELAY, DEFAULT_INITIAL_RETRY_DELAY), + ): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=5.0)), + vol.Optional( + CONF_BRIGHTNESS_TOLERANCE, + default=options.get(CONF_BRIGHTNESS_TOLERANCE, DEFAULT_BRIGHTNESS_TOLERANCE), + ): vol.All(vol.Coerce(int), vol.Range(min=0, max=15)), + } + ) + + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/custom_components/robust_light/const.py b/custom_components/robust_light/const.py new file mode 100644 index 0000000..e444bce --- /dev/null +++ b/custom_components/robust_light/const.py @@ -0,0 +1,22 @@ +"""Constants for the Robust Light integration.""" + +DOMAIN = "robust_light" + +CONF_TARGET_LIGHT = "target_light" +CONF_NAME = "name" + +# Options +CONF_HIDE_ORIGINAL = "hide_original" +CONF_SPLIT_COMMANDS = "split_commands" +CONF_SPLIT_DELAY_MS = "split_delay_ms" +CONF_MAX_RETRIES = "max_retries" +CONF_INITIAL_RETRY_DELAY = "initial_retry_delay" +CONF_BRIGHTNESS_TOLERANCE = "brightness_tolerance" + +# Defaults +DEFAULT_HIDE_ORIGINAL = True +DEFAULT_SPLIT_COMMANDS = True +DEFAULT_SPLIT_DELAY_MS = 100 +DEFAULT_MAX_RETRIES = 3 +DEFAULT_INITIAL_RETRY_DELAY = 0.5 +DEFAULT_BRIGHTNESS_TOLERANCE = 3 diff --git a/custom_components/robust_light/light.py b/custom_components/robust_light/light.py new file mode 100644 index 0000000..0697aa7 --- /dev/null +++ b/custom_components/robust_light/light.py @@ -0,0 +1,328 @@ +"""Light platform for Robust Light integration.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + ATTR_TRANSITION, + ColorMode, + LightEntity, + LightEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_NAME, + MATCH_ALL, + STATE_ON, + STATE_OFF, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import Event, HomeAssistant, EventStateChangedData, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event, async_call_later +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import ( + CONF_BRIGHTNESS_TOLERANCE, + CONF_HIDE_ORIGINAL, + CONF_INITIAL_RETRY_DELAY, + CONF_MAX_RETRIES, + CONF_SPLIT_COMMANDS, + CONF_SPLIT_DELAY_MS, + CONF_TARGET_LIGHT, + DEFAULT_BRIGHTNESS_TOLERANCE, + DEFAULT_HIDE_ORIGINAL, + DEFAULT_INITIAL_RETRY_DELAY, + DEFAULT_MAX_RETRIES, + DEFAULT_SPLIT_COMMANDS, + DEFAULT_SPLIT_DELAY_MS, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Robust Light.""" + target_light = config_entry.data[CONF_TARGET_LIGHT] + name = config_entry.data.get(CONF_NAME, f"Robust Light {target_light}") + + async_add_entities([RobustLight(config_entry, target_light, name)]) + + +class RobustLight(LightEntity, RestoreEntity): + """Robust Light wrapper.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, config_entry: ConfigEntry, target_light: str, name: str) -> None: + """Initialize.""" + self._config_entry = config_entry + self._target_light = target_light + + self._attr_name = name + self._attr_unique_id = f"robust_{target_light}" + self._attr_device_info = None + + self._attr_is_on = False + self._attr_brightness = None + self._attr_color_mode = None + self._attr_color_temp_kelvin = None + self._attr_effect = None + self._attr_effect_list = None + self._attr_hs_color = None + self._attr_max_color_temp_kelvin = None + self._attr_min_color_temp_kelvin = None + self._attr_rgb_color = None + self._attr_rgbw_color = None + self._attr_rgbww_color = None + self._attr_supported_color_modes = set() + self._attr_supported_features = 0 + + self._available = False + + self._retry_task: asyncio.Task | None = None + + self._hide_original = config_entry.options.get(CONF_HIDE_ORIGINAL, DEFAULT_HIDE_ORIGINAL) + self._split_commands = config_entry.options.get(CONF_SPLIT_COMMANDS, DEFAULT_SPLIT_COMMANDS) + self._split_delay_ms = config_entry.options.get(CONF_SPLIT_DELAY_MS, DEFAULT_SPLIT_DELAY_MS) + self._max_retries = config_entry.options.get(CONF_MAX_RETRIES, DEFAULT_MAX_RETRIES) + self._initial_retry_delay = config_entry.options.get(CONF_INITIAL_RETRY_DELAY, DEFAULT_INITIAL_RETRY_DELAY) + self._brightness_tolerance = config_entry.options.get(CONF_BRIGHTNESS_TOLERANCE, DEFAULT_BRIGHTNESS_TOLERANCE) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def async_added_to_hass(self) -> None: + """Register callbacks and set up initial state.""" + await super().async_added_to_hass() + + # Hide original entity if configured + if self._hide_original: + registry = er.async_get(self.hass) + if registry.async_get(self._target_light): + registry.async_update_entity( + self._target_light, hidden_by=er.RegistryEntryHider.USER + ) + + # Sync initial state + state = self.hass.states.get(self._target_light) + if state: + self._update_from_state(state) + + # Start tracking state changes + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._target_light], self._async_state_changed + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Handle removal.""" + # Unhide original entity if it was hidden by us + if self._hide_original: + registry = er.async_get(self.hass) + entry = registry.async_get(self._target_light) + if entry and entry.hidden_by == er.RegistryEntryHider.USER: + registry.async_update_entity(self._target_light, hidden_by=None) + + if self._retry_task: + self._retry_task.cancel() + + @callback + def _async_state_changed(self, event: Event[EventStateChangedData]) -> None: + """Handle target light state changes.""" + new_state = event.data.get("new_state") + if new_state: + self._update_from_state(new_state) + self.async_write_ha_state() + + @callback + def _update_from_state(self, state: Any) -> None: + """Update robust light state from target light state.""" + if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + self._available = False + return + + self._available = True + self._attr_is_on = state.state == STATE_ON + + attrs = state.attributes + self._attr_supported_features = attrs.get(ATTR_SUPPORTED_FEATURES, 0) + color_modes = attrs.get(ATTR_SUPPORTED_COLOR_MODES) + if not color_modes: + color_modes = {ColorMode.ONOFF} + self._attr_supported_color_modes = set(color_modes) + + self._attr_color_mode = attrs.get(ATTR_COLOR_MODE, ColorMode.ONOFF) + self._attr_brightness = attrs.get(ATTR_BRIGHTNESS) + self._attr_color_temp_kelvin = attrs.get(ATTR_COLOR_TEMP_KELVIN) + self._attr_hs_color = attrs.get(ATTR_HS_COLOR) + self._attr_rgb_color = attrs.get(ATTR_RGB_COLOR) + self._attr_rgbw_color = attrs.get(ATTR_RGBW_COLOR) + self._attr_effect = attrs.get(ATTR_EFFECT) + + self._attr_effect_list = attrs.get("effect_list") + self._attr_max_color_temp_kelvin = attrs.get("max_color_temp_kelvin") + self._attr_min_color_temp_kelvin = attrs.get("min_color_temp_kelvin") + + def _cancel_retry_task(self) -> None: + """Cancel any ongoing retry task.""" + if self._retry_task and not self._retry_task.done(): + self._retry_task.cancel() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + self._cancel_retry_task() + + data = {ATTR_ENTITY_ID: self._target_light} + if ATTR_TRANSITION in kwargs: + data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION] + + await self.hass.services.async_call("light", "turn_off", data, blocking=False) + + # Start monitoring for off state + self._retry_task = self.hass.async_create_task( + self._async_monitor_and_retry(False, kwargs) + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + self._cancel_retry_task() + + has_color = any( + k in kwargs + for k in ( + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ) + ) + has_brightness = ATTR_BRIGHTNESS in kwargs + transition = kwargs.get(ATTR_TRANSITION, 0) + + # Chunking Pipeline + if self._split_commands and has_color and has_brightness: + # 1. Dispatch color command first + color_kwargs = {k: v for k, v in kwargs.items() if k != ATTR_BRIGHTNESS} + color_kwargs[ATTR_ENTITY_ID] = self._target_light + color_kwargs[ATTR_TRANSITION] = 0 # Dispatch color instantly or divided + await self.hass.services.async_call("light", "turn_on", color_kwargs, blocking=False) + + # 2. Wait + await asyncio.sleep(self._split_delay_ms / 1000.0) + + # 3. Dispatch brightness command + brightness_kwargs = {ATTR_ENTITY_ID: self._target_light, ATTR_BRIGHTNESS: kwargs[ATTR_BRIGHTNESS]} + if ATTR_TRANSITION in kwargs: + brightness_kwargs[ATTR_TRANSITION] = transition + await self.hass.services.async_call("light", "turn_on", brightness_kwargs, blocking=False) + else: + # Forward full kwargs + data = {ATTR_ENTITY_ID: self._target_light, **kwargs} + await self.hass.services.async_call("light", "turn_on", data, blocking=False) + + # Start monitoring for on state + self._retry_task = self.hass.async_create_task( + self._async_monitor_and_retry(True, kwargs) + ) + + async def _async_monitor_and_retry(self, expected_on: bool, original_kwargs: dict[str, Any]) -> None: + """Monitor state and retry if it diverges.""" + transition = original_kwargs.get(ATTR_TRANSITION, 0) + + # In-Flight Fade Checks + if transition >= 2.0: + checks = int(transition / 2.0) + for _ in range(checks): + await asyncio.sleep(2.0) + state = self.hass.states.get(self._target_light) + if not state or state.state == (STATE_ON if expected_on else STATE_OFF): + # We might be on track, continue. In a real app we'd check if brightness is changing. + pass + + # Wait for fade to finish + buffer + buffer_time = 1.0 if transition > 0 else 0.5 + remaining_wait = transition - (int(transition / 2.0) * 2.0 if transition >= 2.0 else 0) + buffer_time + if remaining_wait > 0: + await asyncio.sleep(remaining_wait) + + for attempt in range(1, self._max_retries + 1): + state = self.hass.states.get(self._target_light) + if not state: + break + + diverged = False + retry_kwargs = {} + + is_on = state.state == STATE_ON + + if is_on != expected_on: + diverged = True + + if expected_on and is_on: + # Check brightness + if ATTR_BRIGHTNESS in original_kwargs: + current_brightness = state.attributes.get(ATTR_BRIGHTNESS) + expected_brightness = original_kwargs[ATTR_BRIGHTNESS] + if current_brightness is None or abs(current_brightness - expected_brightness) > self._brightness_tolerance: + diverged = True + retry_kwargs[ATTR_BRIGHTNESS] = expected_brightness + + # Check color (simplified for now, would need deep compare based on mode) + if ATTR_COLOR_TEMP_KELVIN in original_kwargs: + current_ct = state.attributes.get(ATTR_COLOR_TEMP_KELVIN) + if current_ct != original_kwargs[ATTR_COLOR_TEMP_KELVIN]: + diverged = True + retry_kwargs[ATTR_COLOR_TEMP_KELVIN] = original_kwargs[ATTR_COLOR_TEMP_KELVIN] + + if ATTR_RGB_COLOR in original_kwargs: + current_rgb = state.attributes.get(ATTR_RGB_COLOR) + if current_rgb != original_kwargs[ATTR_RGB_COLOR]: + diverged = True + retry_kwargs[ATTR_RGB_COLOR] = original_kwargs[ATTR_RGB_COLOR] + + # Add other color modes as needed... + + if not diverged: + break # Success! + + _LOGGER.warning("RobustLight %s diverged from expected state. Retry %d/%d.", self.entity_id, attempt, self._max_retries) + + # Retry + retry_data = {ATTR_ENTITY_ID: self._target_light} + if not expected_on: + await self.hass.services.async_call("light", "turn_off", retry_data, blocking=False) + else: + retry_data.update(retry_kwargs) + if not retry_kwargs: + # If it was just an is_on failure + retry_data = {ATTR_ENTITY_ID: self._target_light} + await self.hass.services.async_call("light", "turn_on", retry_data, blocking=False) + + # Backoff + delay = self._initial_retry_delay * (2 ** (attempt - 1)) + await asyncio.sleep(delay) diff --git a/custom_components/robust_light/manifest.json b/custom_components/robust_light/manifest.json new file mode 100644 index 0000000..e77271c --- /dev/null +++ b/custom_components/robust_light/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "robust_light", + "name": "Robust Light", + "codeowners": [], + "documentation": "https://github.com/MarkGodwin/hacs_robust_light", + "integration_type": "helper", + "iot_class": "calculated", + "version": "0.1.0" +} diff --git a/custom_components/robust_light/strings.json b/custom_components/robust_light/strings.json new file mode 100644 index 0000000..61632ce --- /dev/null +++ b/custom_components/robust_light/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up Robust Light", + "description": "Choose the light you want to wrap.", + "data": { + "target_light": "Target Light Entity", + "name": "Name (optional)" + } + } + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + }, + "options": { + "step": { + "init": { + "title": "Configure Robust Light", + "description": "Adjust the settings for how robust light commands and verifies the target entity.", + "data": { + "hide_original": "Hide original entity", + "split_commands": "Split commands (Color then Brightness)", + "split_delay_ms": "Split command delay (ms)", + "max_retries": "Maximum retries", + "initial_retry_delay": "Initial retry delay (seconds)", + "brightness_tolerance": "Brightness mismatch tolerance" + } + } + } + } +} diff --git a/custom_components/robust_light/translations/en.json b/custom_components/robust_light/translations/en.json new file mode 100644 index 0000000..61632ce --- /dev/null +++ b/custom_components/robust_light/translations/en.json @@ -0,0 +1,37 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up Robust Light", + "description": "Choose the light you want to wrap.", + "data": { + "target_light": "Target Light Entity", + "name": "Name (optional)" + } + } + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + }, + "options": { + "step": { + "init": { + "title": "Configure Robust Light", + "description": "Adjust the settings for how robust light commands and verifies the target entity.", + "data": { + "hide_original": "Hide original entity", + "split_commands": "Split commands (Color then Brightness)", + "split_delay_ms": "Split command delay (ms)", + "max_retries": "Maximum retries", + "initial_retry_delay": "Initial retry delay (seconds)", + "brightness_tolerance": "Brightness mismatch tolerance" + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..c22acc5 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Robust Light", + "render_readme": true, + "homeassistant": "2024.1.0" +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c8c9c75 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..58d8e40 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Robust Light integration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8c7a9ba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +"""Global fixtures for robust_light integration.""" +import pytest + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + """Enable custom integrations defined in the test dir.""" + yield diff --git a/tests/test_light.py b/tests/test_light.py new file mode 100644 index 0000000..c95ebc3 --- /dev/null +++ b/tests/test_light.py @@ -0,0 +1,95 @@ +"""Tests for the Robust Light entity.""" +from unittest.mock import patch + +from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_TRANSITION +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.robust_light.const import DOMAIN, CONF_TARGET_LIGHT, CONF_NAME, CONF_SPLIT_COMMANDS, CONF_SPLIT_DELAY_MS + +async def test_entity_creation(hass: HomeAssistant): + """Test that the entity is created and mirrors state.""" + hass.states.async_set("light.test_light", STATE_ON, {ATTR_BRIGHTNESS: 128}) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_TARGET_LIGHT: "light.test_light", CONF_NAME: "My Robust Light"}, + options={}, + ) + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + robust_state = hass.states.get("light.my_robust_light") + assert robust_state + assert robust_state.state == STATE_ON + + # We must trigger an update for attributes to propagate in the test + entity = hass.data["light"].get_entity("light.my_robust_light") + assert entity.brightness == 128 + +async def test_chunking(hass: HomeAssistant): + """Test that commands are split.""" + hass.states.async_set("light.test_light", STATE_OFF) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_TARGET_LIGHT: "light.test_light"}, + options={CONF_SPLIT_COMMANDS: True, CONF_SPLIT_DELAY_MS: 10}, + ) + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Need to call the entity directly to intercept its internal async_call to the target light + entity_id = "light.robust_light_light_test_light" + # Wait for entity setup to complete + await hass.async_block_till_done() + + # The internal services call will use the real service registry if we patch globally, + # so we'll patch the entity's turn_on to just mock the service call it makes + entity = hass.data["light"].get_entity(entity_id) + + from unittest.mock import AsyncMock, patch + + # We need to mock asyncio.sleep so we don't actually wait in the test + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + # We can mock the function at the entity level or just stub the service registry + mock_services = AsyncMock() + mock_services.async_call = AsyncMock() + + # Keep original hass reference but override services + with patch.object(entity, "hass") as mock_hass: + mock_hass.services = mock_services + # Ensure other hass attributes are still accessible if needed + mock_hass.states = hass.states + mock_hass.async_create_task = hass.async_create_task + + await entity.async_turn_on(brightness=255, rgb_color=(255, 0, 0)) + await hass.async_block_till_done() + + # Should be called at least twice (color then brightness) + # The retry logic may add more calls, but the first two should be our chunked command + calls_to_target = mock_services.async_call.call_args_list + assert len(calls_to_target) >= 2 + + # Color call + color_call_args = calls_to_target[0][0] + assert color_call_args[0] == "light" + assert color_call_args[1] == "turn_on" + assert ATTR_RGB_COLOR in color_call_args[2] + assert ATTR_BRIGHTNESS not in color_call_args[2] + assert color_call_args[2][ATTR_ENTITY_ID] == "light.test_light" + + # Brightness call + brightness_call_args = calls_to_target[1][0] + assert brightness_call_args[0] == "light" + assert brightness_call_args[1] == "turn_on" + assert ATTR_BRIGHTNESS in brightness_call_args[2] + assert ATTR_RGB_COLOR not in brightness_call_args[2] + assert brightness_call_args[2][ATTR_ENTITY_ID] == "light.test_light"