From 146a860397d75a068566cc2ecc98b02441e17983 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 11 Aug 2026 23:39:58 -0700 Subject: [PATCH] fix(integration): use authoritative output power state Route master off through non-destructive pause and retain selected preset provenance with an explicit modified state. Replace compatibility wrappers and mutating capability probes with the supported Python SDK contract. Make WebSocket state primary with ordered resync barriers and real availability. Normalize metrics, disable reconciliation polling by default, secure effect uploads against path swaps, and refresh devices dynamically. Co-Authored-By: Nova HASS Audit (Codex) --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- README.md | 13 +- custom_components/hypercolor/__init__.py | 100 +++--- custom_components/hypercolor/api.py | 34 +- custom_components/hypercolor/binary_sensor.py | 61 +++- custom_components/hypercolor/button.py | 40 ++- custom_components/hypercolor/client.py | 40 --- custom_components/hypercolor/config_flow.py | 57 ++- custom_components/hypercolor/const.py | 4 +- custom_components/hypercolor/coordinator.py | 314 ++++++++++++---- custom_components/hypercolor/entity.py | 69 +++- custom_components/hypercolor/light.py | 91 ++--- custom_components/hypercolor/repairs.py | 5 +- custom_components/hypercolor/runtime_data.py | 42 ++- custom_components/hypercolor/select.py | 61 +++- custom_components/hypercolor/sensor.py | 16 +- custom_components/hypercolor/services.py | 57 ++- custom_components/hypercolor/strings.json | 4 +- custom_components/hypercolor/switch.py | 17 +- .../hypercolor/translations/en.json | 3 +- pyproject.toml | 2 +- tests/test_api.py | 65 +++- tests/test_client.py | 40 --- tests/test_config_flow.py | 49 +++ tests/test_coordinator.py | 338 +++++++++++++++++- tests/test_entity.py | 40 +++ tests/test_hass_e2e.py | 123 ++++++- tests/test_runtime_data.py | 22 ++ tests/test_select.py | 16 + tests/test_sensor.py | 30 ++ tests/test_services.py | 66 +++- uv.lock | 2 +- 33 files changed, 1426 insertions(+), 399 deletions(-) delete mode 100644 custom_components/hypercolor/client.py delete mode 100644 tests/test_client.py create mode 100644 tests/test_config_flow.py create mode 100644 tests/test_entity.py create mode 100644 tests/test_runtime_data.py create mode 100644 tests/test_sensor.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 638842e..418dc39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Check out Hypercolor client env: - HYPERCOLOR_REF: ${{ vars.HYPERCOLOR_REF || '25067d621c9756388fe13e850fcd2a9f90389870' }} + HYPERCOLOR_REF: ${{ vars.HYPERCOLOR_REF || '978096e614695777d3cd13ea0b184877e46afd84' }} HYPERCOLOR_TOKEN: ${{ secrets.HYPERCOLOR_TOKEN }} run: | set -euo pipefail diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36d04a2..9b24d61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: - name: Check out Hypercolor client env: - HYPERCOLOR_REF: ${{ vars.HYPERCOLOR_REF || '25067d621c9756388fe13e850fcd2a9f90389870' }} + HYPERCOLOR_REF: ${{ vars.HYPERCOLOR_REF || '978096e614695777d3cd13ea0b184877e46afd84' }} HYPERCOLOR_TOKEN: ${{ secrets.HYPERCOLOR_TOKEN }} run: | set -euo pipefail diff --git a/README.md b/README.md index 39cecd8..5a71e8c 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,6 @@ the device tree reads naturally. | `light.hypercolor` | light | master power, brightness, effect picker | | `binary_sensor.hypercolor_connected` | binary_sensor | live connectivity to the daemon | | `sensor.hypercolor_active_effect` | sensor | display name of the running effect | -| `sensor.hypercolor_fps` | sensor | render loop FPS | | `select.hypercolor_scene` | select | activate a scene | | `select.hypercolor_profile` | select | apply a profile | | `select.hypercolor_layout` | select | switch spatial layouts | @@ -142,7 +141,8 @@ Toggle these in the integration's options panel: - ๐ŸŒŠ **Audio entities** (`channels.audio`) โ€” adds `binary_sensor.hypercolor_audio_beat`, `binary_sensor.hypercolor_audio_reactive_active`, `sensor.hypercolor_audio_energy`, `select.hypercolor_audio_device`, and `switch.hypercolor_audio_reactive`. -- ๐Ÿงช **Metrics entity** (`channels.metrics`) โ€” adds `sensor.hypercolor_render_time`. +- ๐Ÿงช **Metrics entities** (`channels.metrics`): adds `sensor.hypercolor_fps` and + `sensor.hypercolor_render_time`. - ๐Ÿฆ‹ **Per-device entities** (`per_device_entities`) โ€” opt specific device ids in to get their own light, identify button, and enabled switch. @@ -261,10 +261,11 @@ that becomes the integration's unique id. That means the same daemon keeps the s entry across IP changes, container restarts, and network re-shuffles. The integration also runs a background WebSocket session against the daemon. Events -trigger immediate coordinator refreshes; metrics, device metrics, and audio spectrum are -opt-in channels that ride the same socket. If the WebSocket drops, the integration -backs off exponentially and retries forever, so HA's connectivity sensor reflects reality -without needing per-tick polling. +patch authoritative state immediately and refresh only the affected coordinator; metrics +and audio spectrum are opt-in channels that ride the same socket. If the WebSocket drops, +the integration backs off exponentially and retries forever, so HA's connectivity sensor +reflects reality without needing per-tick polling. Periodic reconciliation is disabled by +default and remains available as an explicit fallback. ## ๐Ÿงช Development diff --git a/custom_components/hypercolor/__init__.py b/custom_components/hypercolor/__init__.py index abd608b..8f33be7 100644 --- a/custom_components/hypercolor/__init__.py +++ b/custom_components/hypercolor/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -14,8 +14,14 @@ ) from homeassistant.helpers.httpx_client import get_async_client -from .api import CannotConnectError, InvalidAuthError, async_validate_daemon -from .client import create_hypercolor_client +from hypercolor import HypercolorClient + +from .api import ( + CannotConnectError, + InvalidAuthError, + UnsupportedDaemonError, + async_validate_daemon, +) from .const import ( CONF_API_KEY, CONF_RECONCILE_INTERVAL_S, @@ -32,7 +38,7 @@ reconcile_loop, websocket_loop, ) -from .entity import child_device_identifier, device_slug, read_field +from .entity import child_device_identifier, read_field from .runtime_data import HypercolorRuntimeData from .services import async_setup_services @@ -59,8 +65,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HypercolorConfigEntry) - raise ConfigEntryNotReady from exc except InvalidAuthError as exc: raise ConfigEntryAuthFailed from exc + except UnsupportedDaemonError as exc: + raise ConfigEntryError( + "The Hypercolor daemon does not support persistent output pause" + ) from exc - client = create_hypercolor_client( + client = HypercolorClient( host=entry.data[CONF_HOST], port=entry.data[CONF_PORT], api_key=entry.data.get(CONF_API_KEY), @@ -126,17 +136,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HypercolorConfigEntry) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) _register_child_devices(hass, entry, devices.data) - _cleanup_opted_out_entities(hass, entry, devices.data) + _cleanup_opted_out_entities(hass, entry) _cleanup_stale_zone_entities(hass, entry, state.data) reconcile_interval_s = int( entry.options.get(CONF_RECONCILE_INTERVAL_S, OPTIONS_DEFAULTS[CONF_RECONCILE_INTERVAL_S]) ) - runtime_data.reconcile_task = entry.async_create_background_task( - hass, - reconcile_loop([state, catalog, devices], reconcile_interval_s), - name="hypercolor.reconcile", - ) + if reconcile_interval_s > 0: + runtime_data.reconcile_task = entry.async_create_background_task( + hass, + reconcile_loop([state, catalog, devices], reconcile_interval_s), + name="hypercolor.reconcile", + ) runtime_data.ws_task = entry.async_create_background_task( hass, websocket_loop(runtime_data, {**OPTIONS_DEFAULTS, **entry.options}), @@ -154,13 +165,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: HypercolorConfigEntry) runtime = entry.runtime_data - tasks = [task for task in (runtime.ws_task, runtime.reconcile_task) if task is not None] + tasks = [ + task + for task in (runtime.ws_task, runtime.reconcile_task, runtime.unavailable_task) + if task is not None + ] for task in tasks: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) - if hasattr(runtime.client, "aclose"): - await runtime.client.aclose() + await runtime.client.aclose() return unload_ok @@ -170,11 +184,15 @@ async def _async_update_listener(hass: HomeAssistant, entry: HypercolorConfigEnt async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - if entry.version == 1 and entry.minor_version < 1: + if entry.version == 1 and entry.minor_version < 2: + options = {**OPTIONS_DEFAULTS, **entry.options} + if options[CONF_RECONCILE_INTERVAL_S] == 60: + options[CONF_RECONCILE_INTERVAL_S] = 0 + options.pop("channels.device_metrics", None) hass.config_entries.async_update_entry( entry, - minor_version=1, - options={**OPTIONS_DEFAULTS, **entry.options}, + minor_version=2, + options=options, ) return True @@ -204,6 +222,15 @@ def _register_child_devices( ) -> None: device_registry = dr.async_get(hass) runtime = entry.runtime_data + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, runtime.server.instance_id)}, + name=runtime.server.instance_name, + manufacturer="Hypercolor", + model="Daemon", + sw_version=runtime.server.version, + configuration_url=(f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}"), + ) for device in devices or []: device_id = str(read_field(device, "id")) if not device_id: @@ -222,28 +249,25 @@ def _register_child_devices( def _cleanup_opted_out_entities( hass: HomeAssistant, entry: HypercolorConfigEntry, - devices: list[Any], ) -> None: entity_registry = er.async_get(hass) runtime = entry.runtime_data opted_in = set(entry.options.get("per_device_entities", [])) - for device in devices or []: - device_id = str(read_field(device, "id")) - if not device_id or device_id in opted_in: + prefix = f"{runtime.server.instance_id}:device:" + suffixes = (":light", ":identify", ":enabled") + for registry_entry in er.async_entries_for_config_entry(entity_registry, entry.entry_id): + if not registry_entry.unique_id.startswith(prefix): + continue + suffix = next( + (candidate for candidate in suffixes if registry_entry.unique_id.endswith(candidate)), + None, + ) + if suffix is None: + continue + device_id = registry_entry.unique_id[len(prefix) : -len(suffix)] + if device_id in opted_in: continue - slug = device_slug(device_id) - for unique_id in ( - f"{runtime.server.instance_id}:device:{device_id}:light", - f"{runtime.server.instance_id}:device:{device_id}:identify", - f"{runtime.server.instance_id}:device:{device_id}:enabled", - ): - if entity_id := entity_registry.async_get_entity_id( - _domain_for_unique_id(unique_id), - DOMAIN, - unique_id, - ): - entity_registry.async_remove(entity_id) - runtime.per_device_entity_ids.discard(slug) + entity_registry.async_remove(registry_entry.entity_id) def _cleanup_stale_zone_entities( @@ -270,11 +294,3 @@ def _cleanup_stale_zone_entities( zone_id = registry_entry.unique_id.removeprefix(prefix) if zone_id not in current_zone_ids: entity_registry.async_remove(registry_entry.entity_id) - - -def _domain_for_unique_id(unique_id: str) -> str: - if unique_id.endswith(":light"): - return "light" - if unique_id.endswith(":identify"): - return "button" - return "switch" diff --git a/custom_components/hypercolor/api.py b/custom_components/hypercolor/api.py index 16635cd..bcfd5b5 100644 --- a/custom_components/hypercolor/api.py +++ b/custom_components/hypercolor/api.py @@ -16,6 +16,10 @@ class InvalidAuthError(Exception): pass +class UnsupportedDaemonError(Exception): + pass + + @dataclass(frozen=True, slots=True) class ServerInfo: instance_id: str @@ -48,27 +52,37 @@ async def async_validate_daemon( except (KeyError, TypeError, ValueError) as exc: raise CannotConnectError from exc + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + try: + output_probe = await httpx_client.get( + f"{root_url}/api/v1/output/power", + headers=headers, + ) + except httpx.HTTPError as exc: + raise CannotConnectError from exc + if output_probe.status_code in {httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN}: + raise InvalidAuthError + if output_probe.status_code == httpx.codes.NOT_FOUND: + raise UnsupportedDaemonError + if output_probe.status_code >= httpx.codes.BAD_REQUEST: + raise CannotConnectError + if server_info.auth_required: - headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} try: - auth_probe = await httpx_client.get(f"{root_url}/api/v1/effects", headers=headers) - control_probe = await httpx_client.patch( - f"{root_url}/api/v1/effects/current/controls", + control_probe = await httpx_client.post( + f"{root_url}/api/v1/diagnose", headers=headers, - json={"controls": {}}, + json={"checks": []}, ) except httpx.HTTPError as exc: raise CannotConnectError from exc - if auth_probe.status_code == httpx.codes.UNAUTHORIZED or control_probe.status_code in { + if control_probe.status_code in { httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN, }: raise InvalidAuthError - if ( - auth_probe.status_code >= httpx.codes.BAD_REQUEST - or control_probe.status_code >= httpx.codes.INTERNAL_SERVER_ERROR - ): + if control_probe.status_code >= httpx.codes.BAD_REQUEST: raise CannotConnectError return server_info diff --git a/custom_components/hypercolor/binary_sensor.py b/custom_components/hypercolor/binary_sensor.py index f0f6a08..d819593 100644 --- a/custom_components/hypercolor/binary_sensor.py +++ b/custom_components/hypercolor/binary_sensor.py @@ -14,7 +14,14 @@ CONF_CHANNELS_AUDIO, DEFAULT_AUDIO_BEAT_HOLD_MS, ) -from .entity import catalog_items, hub_device_info, item_id, item_name, read_field +from .entity import ( + MultiCoordinatorEntity, + catalog_items, + hub_device_info, + item_id, + item_name, + read_field, +) from .runtime_data import HypercolorRuntimeData @@ -44,6 +51,41 @@ def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: self._entry = entry self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:connected" + self._remove_timer: CALLBACK_TYPE | None = None + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + self.async_on_remove(self._cancel_timer) + self.async_on_remove( + self._entry.runtime_data.connection_state.async_add_listener( + self._handle_connection_update + ) + ) + self._handle_connection_update() + + @callback + def _handle_connection_update(self) -> None: + self._cancel_timer() + if not self._entry.runtime_data.connection_state.connected: + grace_s = int(self._entry.options.get("disconnect_grace_s", 5)) + if grace_s > 0: + self._remove_timer = async_call_later( + self.hass, + grace_s, + self._disconnect_grace_expired, + ) + self.async_write_ha_state() + + @callback + def _disconnect_grace_expired(self, *_: object) -> None: + self._remove_timer = None + self.async_write_ha_state() + + @callback + def _cancel_timer(self) -> None: + if self._remove_timer is not None: + self._remove_timer() + self._remove_timer = None @property def is_on(self) -> bool: @@ -70,6 +112,10 @@ def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:audio_beat" + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + self.async_on_remove(self._cancel_timer) + @property def is_on(self) -> bool: spectrum = (self.coordinator.data or {}).get("spectrum") or {} @@ -81,8 +127,7 @@ def is_on(self) -> bool: @callback def _handle_coordinator_update(self) -> None: if self.is_on: - if self._remove_timer is not None: - self._remove_timer() + self._cancel_timer() hold_ms = int( self._entry.options.get( CONF_AUDIO_BEAT_HOLD_MS, @@ -101,14 +146,20 @@ def _beat_expired(self, *_: object) -> None: self._remove_timer = None self.async_write_ha_state() + @callback + def _cancel_timer(self) -> None: + if self._remove_timer is not None: + self._remove_timer() + self._remove_timer = None + -class HypercolorAudioReactiveBinarySensor(CoordinatorEntity, BinarySensorEntity): +class HypercolorAudioReactiveBinarySensor(MultiCoordinatorEntity, BinarySensorEntity): _attr_has_entity_name = True _attr_name = "Audio reactive active" def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: runtime = entry.runtime_data - super().__init__(runtime.coordinators["state"]) + super().__init__(runtime.coordinators["state"], runtime.coordinators["catalog"]) self._catalog = runtime.coordinators["catalog"] self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:audio_reactive_active" diff --git a/custom_components/hypercolor/button.py b/custom_components/hypercolor/button.py index 868e787..7d053f7 100644 --- a/custom_components/hypercolor/button.py +++ b/custom_components/hypercolor/button.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import secrets from collections.abc import Awaitable, Callable from typing import Any @@ -10,9 +11,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .client import async_stop_effect -from .const import CONF_PER_DEVICE_ENTITIES, OPTIONS_DEFAULTS -from .entity import catalog_items, child_device_info, hub_device_info, item_id, read_field +from hypercolor import HypercolorNotFoundError + +from .entity import ( + add_configured_device_entities, + catalog_items, + child_device_info, + hub_device_info, + item_id, + read_field, +) from .runtime_data import HypercolorRuntimeData @@ -35,25 +43,14 @@ async def async_setup_entry( entry, name="Stop effect", unique_suffix="stop_effect", - action=lambda: async_stop_effect(entry.runtime_data.client), + action=lambda: _stop_effect(entry.runtime_data.client), ), ] - enabled_devices = set( - entry.options.get( - CONF_PER_DEVICE_ENTITIES, - OPTIONS_DEFAULTS[CONF_PER_DEVICE_ENTITIES], - ) - ) - devices = entry.runtime_data.coordinators["devices"].data or [] - entities.extend( - HypercolorIdentifyDeviceButton(entry, device) - for device in devices - if str(read_field(device, "id")) in enabled_devices - ) async_add_entities(entities) + add_configured_device_entities(entry, async_add_entities, HypercolorIdentifyDeviceButton) -class HypercolorActionButton(ButtonEntity): +class HypercolorActionButton(CoordinatorEntity, ButtonEntity): _attr_has_entity_name = True def __init__( @@ -65,6 +62,7 @@ def __init__( action: Callable[[], Awaitable[Any]], ) -> None: runtime = entry.runtime_data + super().__init__(runtime.coordinators["state"]) self._entry = entry self._action = action self._attr_name = name @@ -112,12 +110,13 @@ async def async_press(self) -> None: await self._state.async_request_refresh() -class HypercolorIdentifyDeviceButton(ButtonEntity): +class HypercolorIdentifyDeviceButton(CoordinatorEntity, ButtonEntity): _attr_has_entity_name = True _attr_name = "Identify" def __init__(self, entry: ConfigEntry[HypercolorRuntimeData], device: Any) -> None: runtime = entry.runtime_data + super().__init__(runtime.coordinators["devices"]) self._entry = entry self._device_id = str(read_field(device, "id")) self._attr_device_info = child_device_info(runtime, device) @@ -125,3 +124,8 @@ def __init__(self, entry: ConfigEntry[HypercolorRuntimeData], device: Any) -> No async def async_press(self) -> None: await self._entry.runtime_data.client.identify_device(self._device_id) + + +async def _stop_effect(client: Any) -> None: + with contextlib.suppress(HypercolorNotFoundError): + await client.stop_effect() diff --git a/custom_components/hypercolor/client.py b/custom_components/hypercolor/client.py deleted file mode 100644 index d809fa6..0000000 --- a/custom_components/hypercolor/client.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable -from inspect import signature -from typing import Any, Protocol - -import httpx - -from hypercolor import HypercolorClient, HypercolorNotFoundError - -_NO_ACTIVE_EFFECT = "No effect is currently active" - - -class _EffectStopper(Protocol): - def stop_effect(self) -> Awaitable[Any]: ... - - -def create_hypercolor_client( - *, - host: str, - port: int, - api_key: str | None, - httpx_client: httpx.AsyncClient, -) -> HypercolorClient: - kwargs: dict[str, Any] = { - "host": host, - "port": port, - "api_key": api_key, - } - if "httpx_client" in signature(HypercolorClient).parameters: - kwargs["httpx_client"] = httpx_client - return HypercolorClient(**kwargs) - - -async def async_stop_effect(client: _EffectStopper) -> None: - try: - await client.stop_effect() - except HypercolorNotFoundError as exc: - if str(exc) != _NO_ACTIVE_EFFECT: - raise diff --git a/custom_components/hypercolor/config_flow.py b/custom_components/hypercolor/config_flow.py index 9417851..d521143 100644 --- a/custom_components/hypercolor/config_flow.py +++ b/custom_components/hypercolor/config_flow.py @@ -1,21 +1,27 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .api import CannotConnectError, InvalidAuthError, ServerInfo, async_validate_daemon +from .api import ( + CannotConnectError, + InvalidAuthError, + ServerInfo, + UnsupportedDaemonError, + async_validate_daemon, +) from .const import ( CONF_API_KEY, CONF_AUDIO_BEAT_HOLD_MS, CONF_CHANNELS_AUDIO, - CONF_CHANNELS_DEVICE_METRICS, CONF_CHANNELS_METRICS, CONF_DISCONNECT_GRACE_S, CONF_LIVE_CONTROLS_ENABLED, @@ -27,11 +33,12 @@ DOMAIN, OPTIONS_DEFAULTS, ) +from .entity import read_field class HypercolorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - MINOR_VERSION = 1 + MINOR_VERSION = 2 _discovery: dict[str, Any] | None = None @@ -157,6 +164,9 @@ async def _async_validate_input( except InvalidAuthError: errors["base"] = "invalid_auth" return None + except UnsupportedDaemonError: + errors["base"] = "unsupported_daemon" + return None await self.async_set_unique_id(server.instance_id) self._abort_if_unique_id_configured() @@ -185,6 +195,9 @@ async def _async_validate_reauth( except InvalidAuthError: errors["base"] = "invalid_auth" return None + except UnsupportedDaemonError: + errors["base"] = "unsupported_daemon" + return None return self.async_update_reload_and_abort( entry, @@ -204,6 +217,13 @@ async def async_step_init( return self.async_create_entry(title="", data={**OPTIONS_DEFAULTS, **user_input}) options = {**OPTIONS_DEFAULTS, **self._config_entry.options} + selected_devices = options[CONF_PER_DEVICE_ENTITIES] + device_options = _device_options( + self._config_entry, + [str(device_id) for device_id in selected_devices] + if isinstance(selected_devices, list) + else [], + ) return self.async_show_form( step_id="init", data_schema=vol.Schema( @@ -213,7 +233,7 @@ async def async_step_init( default=options[CONF_RECONCILE_INTERVAL_S], ): selector.NumberSelector( selector.NumberSelectorConfig( - min=10, + min=0, max=600, step=5, mode=selector.NumberSelectorMode.BOX, @@ -227,10 +247,6 @@ async def async_step_init( CONF_CHANNELS_METRICS, default=options[CONF_CHANNELS_METRICS], ): bool, - vol.Required( - CONF_CHANNELS_DEVICE_METRICS, - default=options[CONF_CHANNELS_DEVICE_METRICS], - ): bool, vol.Required( CONF_LIVE_CONTROLS_ENABLED, default=options[CONF_LIVE_CONTROLS_ENABLED], @@ -273,9 +289,9 @@ async def async_step_init( default=options[CONF_PER_DEVICE_ENTITIES], ): selector.SelectSelector( selector.SelectSelectorConfig( - options=cast(list[str], options[CONF_PER_DEVICE_ENTITIES]), + options=device_options, multiple=True, - custom_value=True, + custom_value=False, ) ), } @@ -292,6 +308,25 @@ async def _validate(flow: HypercolorConfigFlow, user_input: dict[str, Any]) -> S ) +def _device_options( + entry: config_entries.ConfigEntry, + selected_ids: list[str], +) -> list[selector.SelectOptionDict]: + runtime = entry.runtime_data if entry.state is ConfigEntryState.LOADED else None + coordinator = read_field(runtime, "coordinators", {}).get("devices") if runtime else None + devices = read_field(coordinator, "data", []) or [] + labels = { + str(read_field(device, "id")): str(read_field(device, "name", read_field(device, "id"))) + for device in devices + } + for device_id in selected_ids: + labels.setdefault(device_id, device_id) + return [ + selector.SelectOptionDict(value=device_id, label=label) + for device_id, label in sorted(labels.items(), key=lambda item: item[1].casefold()) + ] + + def _user_schema(user_input: dict[str, Any] | None) -> vol.Schema: defaults = user_input or {} fields: dict[Any, type] = { diff --git a/custom_components/hypercolor/const.py b/custom_components/hypercolor/const.py index 167eb80..6db4678 100644 --- a/custom_components/hypercolor/const.py +++ b/custom_components/hypercolor/const.py @@ -7,14 +7,13 @@ DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 9420 -DEFAULT_RECONCILE_INTERVAL_S = 60 +DEFAULT_RECONCILE_INTERVAL_S = 0 DEFAULT_DISCONNECT_GRACE_S = 5 DEFAULT_UNAVAILABLE_AFTER_S = 30 DEFAULT_AUDIO_BEAT_HOLD_MS = 100 CONF_API_KEY = "api_key" CONF_CHANNELS_AUDIO = "channels.audio" -CONF_CHANNELS_DEVICE_METRICS = "channels.device_metrics" CONF_CHANNELS_METRICS = "channels.metrics" CONF_DISCONNECT_GRACE_S = "disconnect_grace_s" CONF_LIVE_CONTROLS_ENABLED = "live_controls_enabled" @@ -39,7 +38,6 @@ CONF_RECONCILE_INTERVAL_S: DEFAULT_RECONCILE_INTERVAL_S, CONF_CHANNELS_AUDIO: False, CONF_CHANNELS_METRICS: False, - CONF_CHANNELS_DEVICE_METRICS: False, CONF_PER_DEVICE_ENTITIES: [], CONF_LIVE_CONTROLS_ENABLED: True, CONF_AUDIO_BEAT_HOLD_MS: DEFAULT_AUDIO_BEAT_HOLD_MS, diff --git a/custom_components/hypercolor/coordinator.py b/custom_components/hypercolor/coordinator.py index b21a09a..ce35322 100644 --- a/custom_components/hypercolor/coordinator.py +++ b/custom_components/hypercolor/coordinator.py @@ -11,13 +11,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from websockets.exceptions import InvalidStatus from hypercolor import HypercolorAuthenticationError, HypercolorNotFoundError +from hypercolor.websocket import EventMessage, MetricsMessage, SpectrumData from .const import ( CONF_AUDIO_BEAT_HOLD_MS, CONF_CHANNELS_AUDIO, - CONF_CHANNELS_DEVICE_METRICS, CONF_CHANNELS_METRICS, DOMAIN, OPTIONS_DEFAULTS, @@ -26,7 +27,8 @@ from .repairs import ( async_create_auth_issue, async_create_unavailable_issue, - async_delete_runtime_issues, + async_delete_auth_issue, + async_delete_unavailable_issue, ) from .runtime_data import ConnectionState, HypercolorRuntimeData @@ -36,6 +38,36 @@ # to send its hello frame (websockets only times out the handshake itself). WS_CONNECT_TIMEOUT_S = 15 +_EVENT_REFRESH_TARGETS = { + "asset_changed": ("catalog",), + "audio_source_changed": ("audio", "state"), + "audio_started": ("audio", "state"), + "audio_stopped": ("audio", "state"), + "config_changed": ("state",), + "control_surface_changed": ("devices",), + "effect_started": ("catalog", "state"), + "effect_stopped": ("catalog", "state"), + "effect_registry_updated": ("catalog",), + "input_source_changed": ("state",), + "library_store_changed": ("catalog",), + "profile_deleted": ("catalog",), + "profile_loaded": ("state",), + "profile_saved": ("catalog",), + "scene_library_changed": ("catalog",), + "scene_enabled": ("catalog",), + "scene_settings_changed": ("catalog", "state"), + "session_changed": ("state",), +} +_EVENT_PREFIX_REFRESH_TARGETS = ( + ("effect_", ("state",)), + ("scene_", ("state",)), + ("active_scene_", ("state",)), + ("render_group_", ("state",)), + ("layer_", ("state",)), + ("layout_", ("catalog", "state")), + ("device_", ("devices",)), +) + class HypercolorCoordinator(DataUpdateCoordinator[Any]): def __init__( @@ -65,12 +97,7 @@ async def _async_update_data(self) -> Any: self._connection_state.set_disconnected(exc) async_create_auth_issue(self.hass, self._config_entry.entry_id) raise ConfigEntryAuthFailed from exc - except Exception as exc: - self._connection_state.set_disconnected(exc) - async_create_unavailable_issue(self.hass, self._config_entry.entry_id) - raise - self._connection_state.set_connected() - async_delete_runtime_issues(self.hass, self._config_entry.entry_id) + async_delete_auth_issue(self.hass, self._config_entry.entry_id) return data @@ -87,23 +114,30 @@ async def reconcile_loop( async def load_state(client: Any) -> dict[str, Any]: status = await client.get_status() - active_effect = await _optional(client.get_active_effect) - active_scene = await _optional(client.get_active_scene) - active_layout = await _optional(client.get_active_layout) + active_effect = await client.get_active_effect() + active_scene = await client.get_active_scene() + active_layout = await client.get_active_layout() active_effect_id = read_field(active_effect, "id", read_field(status, "active_effect")) active_effect_name = read_field(active_effect, "name", read_field(status, "active_effect")) + active_effect_definition = None + if active_effect_id and callable(get_effect := getattr(client, "get_effect", None)): + with contextlib.suppress(HypercolorNotFoundError): + active_effect_definition = await get_effect(str(active_effect_id)) active_effect_cover_image_url = _active_effect_cover_image_url(client, active_effect) zones = read_field(active_scene, "groups", []) or [] return { "status": status, "active_effect_detail": active_effect, + "active_effect_definition": active_effect_definition, "active_scene_detail": active_scene, "active_layout_detail": active_layout, "active_effect": active_effect_name, "active_effect_id": active_effect_id, "active_effect_name": active_effect_name, + "active_effect_state": read_field(active_effect, "state", "idle"), "active_effect_cover_image_url": active_effect_cover_image_url, "active_preset": read_field(active_effect, "active_preset_id"), + "active_preset_modified": bool(read_field(active_effect, "active_preset_modified", False)), "active_scene": read_field(active_scene, "id"), "active_scene_name": read_field(active_scene, "name"), "active_layout": read_field(active_layout, "id"), @@ -137,7 +171,8 @@ async def load_metrics(client: Any) -> dict[str, Any]: status = await client.get_status() return { "status": status, - "render_loop": read_field(status, "render_loop", {}), + "fps": {}, + "frame_time": {}, } @@ -152,19 +187,22 @@ async def websocket_loop(runtime: HypercolorRuntimeData, options: dict[str, Any] stream = runtime.client.events() try: hello = await asyncio.wait_for(stream.connect(), timeout=WS_CONNECT_TIMEOUT_S) - runtime.connection_state.set_connected() + _mark_connected(runtime) _seed_hello(runtime, hello) - await _reconcile_after_reconnect(runtime) channels = _websocket_channels(options) if channels: await stream.subscribe(*channels) + await _reconcile_after_reconnect(runtime, options) backoff_s = 1 async for message in stream: - _handle_ws_message(runtime, message, options) + await _process_ws_message(runtime, message, options) except asyncio.CancelledError: raise except Exception as exc: # noqa: BLE001 - runtime.connection_state.set_disconnected(exc) + error = _normalize_websocket_error(exc) + _mark_disconnected(runtime, options, error) + if isinstance(error, HypercolorAuthenticationError): + _start_reauth(runtime) _LOGGER.debug("Hypercolor WebSocket disconnected", exc_info=True) await asyncio.sleep(backoff_s) backoff_s = min(backoff_s * 2, 30) @@ -184,71 +222,94 @@ def _active_effect_cover_image_url(client: Any, active_effect: Any) -> str | Non cover_image_url = read_field(active_effect, "cover_image_url") if not cover_image_url: return None - if active_cover_url := _client_active_effect_cover_image_url(client): - return active_cover_url - return _daemon_url(client, str(cover_image_url)) - - -def _client_active_effect_cover_image_url(client: Any) -> str | None: - loader = getattr(client, "active_effect_cover_image_url", None) - if not callable(loader): - return None - value = loader() - return str(value) if value else None - - -def _daemon_url(client: Any, path: str) -> str | None: - if path.startswith(("http://", "https://")): - return path - root_url = getattr(client, "root_url", None) - if not isinstance(root_url, str) or not root_url: - return None - normalized_path = path if path.startswith("/") else f"/{path}" - return f"{root_url.rstrip('/')}{normalized_path}" + return client.active_effect_cover_image_url() def _seed_hello(runtime: HypercolorRuntimeData, hello: Any) -> None: - state = read_field(hello, "state") - if isinstance(state, dict) and (coordinator := runtime.coordinators.get("state")): - merged = dict(coordinator.data or {}) - merged.update(state) - coordinator.async_set_updated_data(merged) + hello_state = read_field(hello, "state") + if not isinstance(hello_state, dict): + return + + updates: dict[str, Any] = {} + if (brightness := read_field(hello_state, "brightness")) is not None: + updates.update(global_brightness=brightness, brightness=brightness) + if (paused := read_field(hello_state, "paused")) is not None: + updates["active_effect_state"] = "paused" if paused else "running" + if "effect" in hello_state: + effect = read_field(hello_state, "effect") + updates.update( + active_effect=read_field(effect, "name"), + active_effect_id=read_field(effect, "id"), + ) + if "scene" in hello_state: + scene = read_field(hello_state, "scene") + updates.update( + active_scene=read_field(scene, "id"), + active_scene_name=read_field(scene, "name"), + ) + if (device_count := read_field(hello_state, "device_count")) is not None: + updates["device_count"] = device_count + if updates: + _patch_coordinator(runtime, "state", **updates) + if isinstance(fps := read_field(hello_state, "fps"), dict): + _patch_coordinator(runtime, "metrics", fps=fps) -async def _reconcile_after_reconnect(runtime: HypercolorRuntimeData) -> None: +async def _reconcile_after_reconnect( + runtime: HypercolorRuntimeData, + options: dict[str, Any], +) -> None: + names = ["state", "catalog", "devices"] + if options.get(CONF_CHANNELS_METRICS, OPTIONS_DEFAULTS[CONF_CHANNELS_METRICS]): + names.append("metrics") + if options.get(CONF_CHANNELS_AUDIO, OPTIONS_DEFAULTS[CONF_CHANNELS_AUDIO]): + names.append("audio") refreshes = [ runtime.coordinators[name].async_request_refresh() - for name in ("state", "catalog", "devices") + for name in names if name in runtime.coordinators ] if refreshes: - await asyncio.gather(*refreshes, return_exceptions=True) + await asyncio.gather(*refreshes) def _websocket_channels(options: dict[str, Any]) -> list[str]: channels = ["events"] if options.get(CONF_CHANNELS_METRICS, OPTIONS_DEFAULTS[CONF_CHANNELS_METRICS]): channels.append("metrics") - if options.get( - CONF_CHANNELS_DEVICE_METRICS, - OPTIONS_DEFAULTS[CONF_CHANNELS_DEVICE_METRICS], - ): - channels.append("device_metrics") if options.get(CONF_CHANNELS_AUDIO, OPTIONS_DEFAULTS[CONF_CHANNELS_AUDIO]): channels.append("spectrum") return channels +def _normalize_websocket_error(error: Exception) -> Exception: + if isinstance(error, InvalidStatus) and error.response.status_code in {401, 403}: + return HypercolorAuthenticationError( + "Hypercolor WebSocket authentication failed", + status_code=error.response.status_code, + ) + return error + + +def _start_reauth(runtime: HypercolorRuntimeData) -> None: + state = runtime.coordinators.get("state") + if state is None: + return + async_create_auth_issue(state.hass, state.config_entry.entry_id) + state.config_entry.async_start_reauth(state.hass) + + def _handle_ws_message( runtime: HypercolorRuntimeData, message: Any, options: dict[str, Any], ) -> None: - runtime.connection_state.set_connected() - message_name = type(message).__name__ - if message_name == "MetricsMessage": - _set_coordinator_data(runtime, "metrics", read_field(message, "data", {})) - elif message_name == "SpectrumData": + _mark_connected(runtime) + if isinstance(message, MetricsMessage): + _set_coordinator_data( + runtime, "metrics", _normalize_metrics(read_field(message, "data", {})) + ) + elif isinstance(message, SpectrumData): hold_ms = int( options.get( CONF_AUDIO_BEAT_HOLD_MS, @@ -269,22 +330,80 @@ def _handle_ws_message( "beat_until": beat_until, } _set_coordinator_data(runtime, "audio", current) - elif message_name == "EventMessage": + elif isinstance(message, EventMessage): event = str(read_field(message, "event", "")) - if event.startswith( - ( - "effect", - "scene", - "active_scene", - "render_group", - "layer", - "profile", - "layout", - "device", - "brightness", + data = read_field(message, "data", {}) + _handle_event(runtime, event, data) + + +async def _process_ws_message( + runtime: HypercolorRuntimeData, + message: Any, + options: dict[str, Any], +) -> None: + if isinstance(message, EventMessage) and str(read_field(message, "event", "")) == ( + "resync_required" + ): + _mark_connected(runtime) + await _reconcile_after_reconnect(runtime, options) + return + _handle_ws_message(runtime, message, options) + + +def _normalize_metrics(data: Any) -> dict[str, Any]: + normalized = dict(data) if isinstance(data, dict) else {} + normalized["fps"] = read_field(data, "fps", {}) or {} + normalized["frame_time"] = read_field(data, "frame_time", {}) or {} + return normalized + + +def _handle_event(runtime: HypercolorRuntimeData, event: str, data: Any) -> None: + if event == "resync_required": + _request_refresh(runtime, *sorted(runtime.coordinators)) + return + if event == "paused": + _patch_coordinator(runtime, "state", active_effect_state="paused") + return + if event == "resumed": + _patch_coordinator(runtime, "state", active_effect_state="running") + return + if event == "brightness_changed": + brightness = read_field(data, "new_value") + if brightness is not None: + _patch_coordinator( + runtime, + "state", + global_brightness=brightness, + brightness=brightness, ) - ): - _request_refresh(runtime, "state", "catalog", "devices") + return + if event == "fps_changed": + current = dict(read_field(runtime.coordinators.get("metrics"), "data", {}) or {}) + fps = dict(read_field(current, "fps", {}) or {}) + fps.update( + { + "target": read_field(data, "new_target"), + "actual": read_field(data, "measured"), + } + ) + current["fps"] = fps + _set_coordinator_data(runtime, "metrics", current) + return + + targets = set(_EVENT_REFRESH_TARGETS.get(event, ())) + if not targets: + targets.update( + target + for prefix, prefix_targets in _EVENT_PREFIX_REFRESH_TARGETS + if event.startswith(prefix) + for target in prefix_targets + ) + if event == "config_changed" and str(read_field(data, "key", "")).startswith("audio."): + targets.add("audio") + if event == "library_store_changed" and read_field(data, "collection") == "presets": + targets.add("state") + if targets: + _request_refresh(runtime, *sorted(targets)) def _set_coordinator_data( @@ -296,7 +415,64 @@ def _set_coordinator_data( coordinator.async_set_updated_data(data) +def _patch_coordinator( + runtime: HypercolorRuntimeData, + coordinator_name: str, + **updates: Any, +) -> None: + coordinator = runtime.coordinators.get(coordinator_name) + if coordinator is None: + return + current = dict(coordinator.data or {}) + current.update(updates) + coordinator.async_set_updated_data(current) + + def _request_refresh(runtime: HypercolorRuntimeData, *coordinator_names: str) -> None: for coordinator_name in coordinator_names: if coordinator := runtime.coordinators.get(coordinator_name): coordinator.hass.async_create_task(coordinator.async_request_refresh()) + + +def _mark_connected(runtime: HypercolorRuntimeData) -> None: + if not runtime.connection_state.set_connected(): + return + if runtime.unavailable_task is not None: + runtime.unavailable_task.cancel() + runtime.unavailable_task = None + state = runtime.coordinators.get("state") + if state is not None: + async_delete_unavailable_issue(state.hass, state.config_entry.entry_id) + + +def _mark_disconnected( + runtime: HypercolorRuntimeData, + options: dict[str, Any], + error: BaseException, +) -> None: + runtime.connection_state.set_disconnected(error) + if runtime.unavailable_task is not None: + return + state = runtime.coordinators.get("state") + if state is None: + return + unavailable_after_s = int(options.get("unavailable_after_s", 30)) + runtime.unavailable_task = state.hass.async_create_task( + _mark_unavailable_after(runtime, unavailable_after_s), + ) + + +async def _mark_unavailable_after( + runtime: HypercolorRuntimeData, + delay_s: int, +) -> None: + await asyncio.sleep(delay_s) + if runtime.connection_state.connected: + return + state = runtime.coordinators.get("state") + if state is None: + return + error = ConnectionError("Hypercolor WebSocket is disconnected") + for coordinator in runtime.coordinators.values(): + coordinator.async_set_update_error(error) + async_create_unavailable_issue(state.hass, state.config_entry.entry_id) diff --git a/custom_components/hypercolor/entity.py b/custom_components/hypercolor/entity.py index 528a518..566e2e4 100644 --- a/custom_components/hypercolor/entity.py +++ b/custom_components/hypercolor/entity.py @@ -1,15 +1,68 @@ from __future__ import annotations +from collections import Counter from collections.abc import Mapping -from typing import Any +from typing import Any, Protocol +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.util import slugify +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator from .const import DOMAIN from .runtime_data import HypercolorRuntimeData +class _DeviceEntityFactory(Protocol): + def __call__(self, entry: ConfigEntry[HypercolorRuntimeData], device: Any) -> Any: ... + + +class MultiCoordinatorEntity(CoordinatorEntity): + def __init__( + self, + coordinator: DataUpdateCoordinator[Any], + *secondary_coordinators: DataUpdateCoordinator[Any], + ) -> None: + super().__init__(coordinator) + self._secondary_coordinators = secondary_coordinators + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + for coordinator in self._secondary_coordinators: + self.async_on_remove(coordinator.async_add_listener(self._handle_secondary_update)) + + @callback + def _handle_secondary_update(self) -> None: + self.async_write_ha_state() + + +def add_configured_device_entities( + entry: ConfigEntry[HypercolorRuntimeData], + async_add_entities: AddEntitiesCallback, + factory: _DeviceEntityFactory, +) -> None: + coordinator = entry.runtime_data.coordinators["devices"] + known_ids: set[str] = set() + + @callback + def sync_entities() -> None: + configured_ids = set(entry.options.get("per_device_entities", [])) + fresh = [ + device + for device in coordinator.data or [] + if (device_id := str(read_field(device, "id"))) in configured_ids + and device_id not in known_ids + ] + if not fresh: + return + known_ids.update(str(read_field(device, "id")) for device in fresh) + async_add_entities([factory(entry, device) for device in fresh]) + + sync_entities() + entry.async_on_unload(coordinator.async_add_listener(sync_entities)) + + def hub_device_info(runtime: HypercolorRuntimeData, entry_data: Mapping[str, Any]) -> DeviceInfo: return DeviceInfo( identifiers={(DOMAIN, runtime.server.instance_id)}, @@ -38,10 +91,6 @@ def child_device_identifier(runtime: HypercolorRuntimeData, device_id: str) -> s return f"{runtime.server.instance_id}:device:{device_id}" -def device_slug(device_id: str) -> str: - return slugify(device_id).replace("__", "_") - - def catalog_items(catalog: Any, key: str) -> list[Any]: if isinstance(catalog, Mapping): value = catalog.get(key, []) @@ -52,7 +101,13 @@ def catalog_items(catalog: Any, key: str) -> list[Any]: def option_map(items: list[Any]) -> dict[str, str]: - return {item_name(item): item_id(item) for item in items} + name_counts = Counter(item_name(item) for item in items) + return {item_option(item, name_counts): item_id(item) for item in items} + + +def item_option(item: Any, name_counts: Mapping[str, int]) -> str: + name = item_name(item) + return name if name_counts[name] == 1 else f"{name} ({item_id(item)})" def item_id(item: Any) -> str: diff --git a/custom_components/hypercolor/light.py b/custom_components/hypercolor/light.py index ce6bfe8..135107b 100644 --- a/custom_components/hypercolor/light.py +++ b/custom_components/hypercolor/light.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from typing import Any from homeassistant.components.light import ( @@ -10,15 +11,15 @@ LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .brightness import daemon_to_ha, ha_to_daemon -from .client import async_stop_effect -from .const import CONF_PER_DEVICE_ENTITIES, OPTIONS_DEFAULTS from .entity import ( + MultiCoordinatorEntity, + add_configured_device_entities, catalog_items, child_device_info, control_scalar, @@ -36,19 +37,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: entities: list[LightEntity] = [HypercolorMasterLight(entry)] - enabled_devices = set( - entry.options.get( - CONF_PER_DEVICE_ENTITIES, - OPTIONS_DEFAULTS[CONF_PER_DEVICE_ENTITIES], - ) - ) - devices = entry.runtime_data.coordinators["devices"].data or [] - entities.extend( - HypercolorDeviceLight(entry, device) - for device in devices - if str(read_field(device, "id")) in enabled_devices - ) async_add_entities(entities) + add_configured_device_entities(entry, async_add_entities, HypercolorDeviceLight) state = entry.runtime_data.coordinators["state"] known_zone_ids: set[str] = set() @@ -70,7 +60,7 @@ def _sync_zone_entities() -> None: entry.async_on_unload(state.async_add_listener(_sync_zone_entities)) -class HypercolorMasterLight(CoordinatorEntity, LightEntity): +class HypercolorMasterLight(MultiCoordinatorEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_has_entity_name = True _attr_name = None @@ -78,32 +68,12 @@ class HypercolorMasterLight(CoordinatorEntity, LightEntity): def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: runtime = entry.runtime_data - super().__init__(runtime.coordinators["state"]) + super().__init__(runtime.coordinators["state"], runtime.coordinators["catalog"]) self._entry = entry self._catalog = runtime.coordinators["catalog"] self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._attr_unique_id = f"{runtime.server.instance_id}:master" - self._last_effect_id, self._last_preset_id = self._running_effect_ref() - - @callback - def _handle_coordinator_update(self) -> None: - # The daemon forgets the active effect (and the preset it was applied - # with) when rendering stops, so keep the last running pair to resume - # the full look on a plain turn-on, not just the bare effect. - effect_id, preset_id = self._running_effect_ref() - if effect_id: - self._last_effect_id = effect_id - self._last_preset_id = preset_id - super()._handle_coordinator_update() - - def _running_effect_ref(self) -> tuple[str | None, str | None]: - effect_id = read_field(self.coordinator.data, "active_effect_id") - preset_id = read_field(self.coordinator.data, "active_preset") - return ( - str(effect_id) if effect_id else None, - str(preset_id) if preset_id else None, - ) @property def brightness(self) -> int | None: @@ -134,6 +104,8 @@ def extra_state_attributes(self) -> dict[str, Any]: return { "active_effect": self.effect, "active_effect_id": active_id, + "active_preset_id": read_field(state, "active_preset"), + "active_preset_modified": bool(read_field(state, "active_preset_modified", False)), "active_effect_cover_image_url": cover_image_url, "device_count": read_field(state, "device_count"), # `effect_image` mirrors the SignalRGB attribute the card reads for @@ -157,30 +129,45 @@ def extra_state_attributes(self) -> dict[str, Any]: @property def is_on(self) -> bool | None: - return self.effect is not None + if self.effect is None: + return False + return read_field(self.coordinator.data, "active_effect_state") != "paused" async def async_turn_on(self, **kwargs: Any) -> None: client = self._entry.runtime_data.client + was_paused = read_field(self.coordinator.data, "active_effect_state") == "paused" + effect_changed = False if ATTR_BRIGHTNESS in kwargs: await client.set_brightness(ha_to_daemon(int(kwargs[ATTR_BRIGHTNESS]))) effect = kwargs.get(ATTR_EFFECT) if effect: await client.apply_effect(effect_id_for_name(self._catalog.data, str(effect))) - elif not self.is_on and ( - resume := self._last_effect_id or first_effect_id(self._catalog.data) - ): - preset = self._last_preset_id if resume == self._last_effect_id else None - if preset: - await client.apply_effect_preset(resume, preset) - else: - await client.apply_effect(resume) - - await self.coordinator.async_request_refresh() + effect_changed = True + elif was_paused: + result = await client.resume_rendering() + self._set_output_state(read_field(result, "state", "running")) + return + elif self.effect is None and (effect_id := first_effect_id(self._catalog.data)): + await client.apply_effect(effect_id) + effect_changed = True + + if effect_changed: + await asyncio.gather( + self.coordinator.async_refresh(), + self._catalog.async_refresh(), + ) + else: + await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: - await async_stop_effect(self._entry.runtime_data.client) - await self.coordinator.async_request_refresh() + result = await self._entry.runtime_data.client.pause_rendering() + self._set_output_state(read_field(result, "state", "paused")) + + def _set_output_state(self, state: Any) -> None: + current = dict(self.coordinator.data or {}) + current["active_effect_state"] = str(state) + self.coordinator.async_set_updated_data(current) class HypercolorDeviceLight(CoordinatorEntity, LightEntity): @@ -231,7 +218,7 @@ def _device(self) -> Any | None: return None -class HypercolorZoneLight(CoordinatorEntity, LightEntity): +class HypercolorZoneLight(MultiCoordinatorEntity, LightEntity): """One zone (render group) of the active scene. Zones are scene-scoped: when the active scene changes, entities for @@ -244,7 +231,7 @@ class HypercolorZoneLight(CoordinatorEntity, LightEntity): def __init__(self, entry: ConfigEntry[HypercolorRuntimeData], zone_id: str) -> None: runtime = entry.runtime_data - super().__init__(runtime.coordinators["state"]) + super().__init__(runtime.coordinators["state"], runtime.coordinators["catalog"]) self._entry = entry self._zone_id = zone_id self._catalog = runtime.coordinators["catalog"] diff --git a/custom_components/hypercolor/repairs.py b/custom_components/hypercolor/repairs.py index d18517a..02c5e02 100644 --- a/custom_components/hypercolor/repairs.py +++ b/custom_components/hypercolor/repairs.py @@ -33,6 +33,9 @@ def async_create_unavailable_issue(hass: HomeAssistant, entry_id: str) -> None: ) -def async_delete_runtime_issues(hass: HomeAssistant, entry_id: str) -> None: +def async_delete_auth_issue(hass: HomeAssistant, entry_id: str) -> None: ir.async_delete_issue(hass, DOMAIN, f"{ISSUE_AUTH_INVALID}_{entry_id}") + + +def async_delete_unavailable_issue(hass: HomeAssistant, entry_id: str) -> None: ir.async_delete_issue(hass, DOMAIN, f"{ISSUE_DAEMON_UNAVAILABLE}_{entry_id}") diff --git a/custom_components/hypercolor/runtime_data.py b/custom_components/hypercolor/runtime_data.py index 2fc56ac..5171fb0 100644 --- a/custom_components/hypercolor/runtime_data.py +++ b/custom_components/hypercolor/runtime_data.py @@ -1,10 +1,13 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass, field from datetime import UTC, datetime from typing import Any +from homeassistant.core import CALLBACK_TYPE, callback + from .api import ServerInfo @@ -14,16 +17,43 @@ class ConnectionState: last_connected_at: datetime | None = None last_disconnected_at: datetime | None = None last_error: str | None = None + _listeners: set[Callable[[], None]] = field(default_factory=set) - def set_connected(self) -> None: + def set_connected(self) -> bool: + changed = not self.connected or self.last_error is not None self.connected = True - self.last_connected_at = datetime.now(UTC) + if changed: + self.last_connected_at = datetime.now(UTC) self.last_error = None + if changed: + self._notify() + return changed - def set_disconnected(self, error: BaseException | None = None) -> None: + def set_disconnected(self, error: BaseException | None = None) -> bool: + message = str(error) if error else None + changed = self.connected or self.last_disconnected_at is None self.connected = False - self.last_disconnected_at = datetime.now(UTC) - self.last_error = str(error) if error else None + if changed: + self.last_disconnected_at = datetime.now(UTC) + self.last_error = message + if changed: + self._notify() + return changed + + @callback + def async_add_listener(self, listener: Callable[[], None]) -> CALLBACK_TYPE: + self._listeners.add(listener) + + @callback + def remove_listener() -> None: + self._listeners.discard(listener) + + return remove_listener + + @callback + def _notify(self) -> None: + for listener in tuple(self._listeners): + listener() def snapshot(self) -> dict[str, Any]: return { @@ -40,6 +70,6 @@ class HypercolorRuntimeData: server: ServerInfo coordinators: dict[str, Any] = field(default_factory=dict) connection_state: ConnectionState = field(default_factory=ConnectionState) - per_device_entity_ids: set[str] = field(default_factory=set) ws_task: asyncio.Task[None] | None = None reconcile_task: asyncio.Task[None] | None = None + unavailable_task: asyncio.Task[None] | None = None diff --git a/custom_components/hypercolor/select.py b/custom_components/hypercolor/select.py index 3c18759..4919f20 100644 --- a/custom_components/hypercolor/select.py +++ b/custom_components/hypercolor/select.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from collections.abc import Awaitable, Callable from typing import Any @@ -10,7 +11,15 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_CHANNELS_AUDIO -from .entity import catalog_items, hub_device_info, item_id, item_name, option_map, read_field +from .entity import ( + MultiCoordinatorEntity, + catalog_items, + hub_device_info, + item_id, + item_name, + option_map, + read_field, +) from .runtime_data import HypercolorRuntimeData @@ -51,7 +60,7 @@ async def async_setup_entry( async_add_entities(entities) -class HypercolorCatalogSelect(CoordinatorEntity, SelectEntity): +class HypercolorCatalogSelect(MultiCoordinatorEntity, SelectEntity): _attr_has_entity_name = True def __init__( @@ -65,7 +74,7 @@ def __init__( action: Callable[[str], Awaitable[Any]], ) -> None: runtime = entry.runtime_data - super().__init__(runtime.coordinators["catalog"]) + super().__init__(runtime.coordinators["catalog"], runtime.coordinators["state"]) self._entry = entry self._state = runtime.coordinators["state"] self._key = key @@ -77,7 +86,7 @@ def __init__( @property def options(self) -> list[str]: - return [item_name(item) for item in self._items] + return list(option_map(self._items)) @property def current_option(self) -> str | None: @@ -86,10 +95,14 @@ def current_option(self) -> str | None: active_id = read_field(self._state.data, self._active_key) if not active_id: return None - for item in self._items: - if item_id(item) == str(active_id): - return item_name(item) - return None + return next( + ( + option + for option, identifier in option_map(self._items).items() + if identifier == str(active_id) + ), + None, + ) async def async_select_option(self, option: str) -> None: mapping = option_map(self._items) @@ -102,13 +115,13 @@ def _items(self) -> list[Any]: return catalog_items(self.coordinator.data, self._key) -class HypercolorPresetSelect(CoordinatorEntity, SelectEntity): +class HypercolorPresetSelect(MultiCoordinatorEntity, SelectEntity): _attr_has_entity_name = True _attr_name = "Preset" def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: runtime = entry.runtime_data - super().__init__(runtime.coordinators["catalog"]) + super().__init__(runtime.coordinators["catalog"], runtime.coordinators["state"]) self._entry = entry self._state = runtime.coordinators["state"] self._attr_device_info = hub_device_info(runtime, entry.data) @@ -128,6 +141,14 @@ def current_option(self) -> str | None: return option return None + @property + def extra_state_attributes(self) -> dict[str, Any]: + return { + "active_preset_modified": bool( + read_field(self._state.data, "active_preset_modified", False) + ) + } + async def async_select_option(self, option: str) -> None: preset = _preset_option_map(self._items).get(option) if preset is None: @@ -136,8 +157,10 @@ async def async_select_option(self, option: str) -> None: str(read_field(preset, "effect_id")), item_id(preset), ) - await self._state.async_request_refresh() - await self.coordinator.async_request_refresh() + await asyncio.gather( + self._state.async_refresh(), + self.coordinator.async_refresh(), + ) @property def _items(self) -> list[Any]: @@ -182,15 +205,19 @@ def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: @property def options(self) -> list[str]: - return [item_name(device) for device in self._devices] + return list(option_map(self._devices)) @property def current_option(self) -> str | None: current = read_field(read_field(self.coordinator.data, "devices"), "current") - for device in self._devices: - if item_id(device) == current: - return item_name(device) - return None + return next( + ( + option + for option, identifier in option_map(self._devices).items() + if identifier == current + ), + None, + ) async def async_select_option(self, option: str) -> None: mapping = option_map(self._devices) diff --git a/custom_components/hypercolor/sensor.py b/custom_components/hypercolor/sensor.py index 777b312..51a5184 100644 --- a/custom_components/hypercolor/sensor.py +++ b/custom_components/hypercolor/sensor.py @@ -20,10 +20,9 @@ async def async_setup_entry( ) -> None: entities: list[SensorEntity] = [ HypercolorActiveEffectSensor(entry), - HypercolorFpsSensor(entry), ] if entry.options.get(CONF_CHANNELS_METRICS, False): - entities.append(HypercolorRenderTimeSensor(entry)) + entities.extend([HypercolorFpsSensor(entry), HypercolorRenderTimeSensor(entry)]) if entry.options.get(CONF_CHANNELS_AUDIO, False): entities.append(HypercolorAudioEnergySensor(entry)) async_add_entities(entities) @@ -53,18 +52,14 @@ class HypercolorFpsSensor(CoordinatorEntity, SensorEntity): def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: runtime = entry.runtime_data - super().__init__(runtime.coordinators["state"]) - self._metrics = runtime.coordinators["metrics"] + super().__init__(runtime.coordinators["metrics"]) self._attr_device_info = hub_device_info(runtime, entry.data) self._attr_unique_id = f"{runtime.server.instance_id}:fps" @property def native_value(self) -> float | None: - metrics_value = _first_number(self._metrics.data, "fps", "render_fps") - if metrics_value is not None: - return metrics_value - render_loop = read_field(self.coordinator.data, "render_loop", {}) - return _first_number(render_loop, "fps", "target_fps", "actual_fps") + fps = read_field(self.coordinator.data, "fps", {}) + return _first_number(fps, "actual", "delivered", "target") class HypercolorRenderTimeSensor(CoordinatorEntity, SensorEntity): @@ -81,7 +76,8 @@ def __init__(self, entry: ConfigEntry[HypercolorRuntimeData]) -> None: @property def native_value(self) -> float | None: - return _first_number(self.coordinator.data, "render_time_ms", "frame_time_ms") + frame_time = read_field(self.coordinator.data, "frame_time", {}) + return _first_number(frame_time, "avg_ms", "p95_ms") class HypercolorAudioEnergySensor(CoordinatorEntity, SensorEntity): diff --git a/custom_components/hypercolor/services.py b/custom_components/hypercolor/services.py index af34290..fc6212f 100644 --- a/custom_components/hypercolor/services.py +++ b/custom_components/hypercolor/services.py @@ -1,7 +1,10 @@ from __future__ import annotations +import os +import stat from collections.abc import Callable, Coroutine from dataclasses import asdict +from functools import partial from pathlib import Path from typing import Any @@ -10,7 +13,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv, selector, service as service_helper from .const import DOMAIN from .runtime_data import HypercolorRuntimeData @@ -38,6 +41,8 @@ SERVICE_UPLOAD_EFFECT = "upload_effect" SERVICE_RUN_DIAGNOSTICS = "run_diagnostics" +_MAX_EFFECT_SIZE_BYTES = 1024 * 1024 + def async_setup_services(hass: HomeAssistant) -> None: _register( @@ -252,7 +257,8 @@ def _register( ) -> None: if hass.services.has_service(DOMAIN, service): return - hass.services.async_register( + service_helper.async_register_admin_service( + hass, DOMAIN, service, handler, @@ -476,9 +482,24 @@ async def _upload_effect(call: ServiceCall) -> dict[str, Any]: if content is None: if path is None: raise HomeAssistantError("path or html is required") - effect_path = Path(path) - content = await call.hass.async_add_executor_job(effect_path.read_bytes) + try: + effect_path = await call.hass.async_add_executor_job( + partial(Path(path).resolve, strict=True), + ) + except OSError as exc: + raise HomeAssistantError(f"Unable to read effect file: {exc}") from exc + if not call.hass.config.is_allowed_path(str(effect_path)): + raise HomeAssistantError("Effect path is outside Home Assistant's allowed paths") + try: + content = await call.hass.async_add_executor_job(_read_limited_effect, effect_path) + except HomeAssistantError: + raise + except OSError as exc: + raise HomeAssistantError(f"Unable to read effect file: {exc}") from exc file_name = file_name or effect_path.name + content_size = len(content.encode()) if isinstance(content, str) else len(content) + if content_size > _MAX_EFFECT_SIZE_BYTES: + raise HomeAssistantError("Effect content exceeds the 1 MiB upload limit") result = await entry.runtime_data.client.upload_effect( file_name or "hypercolor-effect.html", content, @@ -486,6 +507,34 @@ async def _upload_effect(call: ServiceCall) -> dict[str, Any]: return {"effect": result} +def _read_limited_effect(effect_path: Path) -> bytes: + before_open = effect_path.stat(follow_symlinks=False) + if not stat.S_ISREG(before_open.st_mode): + raise HomeAssistantError("Effect path must reference a regular file") + if before_open.st_size > _MAX_EFFECT_SIZE_BYTES: + raise HomeAssistantError("Effect file exceeds the 1 MiB upload limit") + + flags = ( + os.O_RDONLY + | getattr(os, "O_BINARY", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + descriptor = os.open(effect_path, flags) + try: + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode): + raise HomeAssistantError("Effect path must reference a regular file") + if (opened.st_dev, opened.st_ino) != (before_open.st_dev, before_open.st_ino): + raise HomeAssistantError("Effect file changed while it was being opened") + if opened.st_size > _MAX_EFFECT_SIZE_BYTES: + raise HomeAssistantError("Effect file exceeds the 1 MiB upload limit") + with os.fdopen(descriptor, "rb", closefd=False) as effect_file: + return effect_file.read(_MAX_EFFECT_SIZE_BYTES + 1) + finally: + os.close(descriptor) + + async def _run_diagnostics(call: ServiceCall) -> dict[str, Any]: entry = _entry(call.hass, call) runtime = entry.runtime_data diff --git a/custom_components/hypercolor/strings.json b/custom_components/hypercolor/strings.json index 3289430..ae1211a 100644 --- a/custom_components/hypercolor/strings.json +++ b/custom_components/hypercolor/strings.json @@ -27,7 +27,8 @@ }, "error": { "cannot_connect": "Unable to connect to the Hypercolor daemon.", - "invalid_auth": "The API key was rejected." + "invalid_auth": "The API key was rejected.", + "unsupported_daemon": "Update the Hypercolor daemon to a version that supports persistent output pause." }, "abort": { "already_configured": "This Hypercolor daemon is already configured.", @@ -43,7 +44,6 @@ "reconcile_interval_s": "Reconcile interval", "channels.audio": "Audio entities", "channels.metrics": "Metrics entities", - "channels.device_metrics": "Device metrics", "live_controls_enabled": "Live control number entities", "audio_beat_hold_ms": "Audio beat hold", "disconnect_grace_s": "Disconnect grace", diff --git a/custom_components/hypercolor/switch.py b/custom_components/hypercolor/switch.py index f0c3d26..541c6e6 100644 --- a/custom_components/hypercolor/switch.py +++ b/custom_components/hypercolor/switch.py @@ -8,8 +8,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_CHANNELS_AUDIO, CONF_PER_DEVICE_ENTITIES, OPTIONS_DEFAULTS -from .entity import child_device_info, hub_device_info, read_field +from .const import CONF_CHANNELS_AUDIO +from .entity import add_configured_device_entities, child_device_info, hub_device_info, read_field from .runtime_data import HypercolorRuntimeData _AUDIO_DEVICE_DEFAULT = "default" @@ -24,19 +24,8 @@ async def async_setup_entry( entities: list[SwitchEntity] = [] if entry.options.get(CONF_CHANNELS_AUDIO, False): entities.append(HypercolorAudioReactiveSwitch(entry)) - enabled_devices = set( - entry.options.get( - CONF_PER_DEVICE_ENTITIES, - OPTIONS_DEFAULTS[CONF_PER_DEVICE_ENTITIES], - ) - ) - devices = entry.runtime_data.coordinators["devices"].data or [] - entities.extend( - HypercolorDeviceEnabledSwitch(entry, device) - for device in devices - if str(read_field(device, "id")) in enabled_devices - ) async_add_entities(entities) + add_configured_device_entities(entry, async_add_entities, HypercolorDeviceEnabledSwitch) class HypercolorAudioReactiveSwitch(CoordinatorEntity, SwitchEntity): diff --git a/custom_components/hypercolor/translations/en.json b/custom_components/hypercolor/translations/en.json index 5167d44..05be4d6 100644 --- a/custom_components/hypercolor/translations/en.json +++ b/custom_components/hypercolor/translations/en.json @@ -27,7 +27,8 @@ }, "error": { "cannot_connect": "Unable to connect to the Hypercolor daemon.", - "invalid_auth": "The API key was rejected." + "invalid_auth": "The API key was rejected.", + "unsupported_daemon": "Update the Hypercolor daemon to a version that supports persistent output pause." }, "abort": { "already_configured": "This Hypercolor daemon is already configured.", diff --git a/pyproject.toml b/pyproject.toml index 570b03f..d102eeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Private :: Do Not Upload", ] dependencies = [ - "hypercolor>=0.3.1,<0.4.0", + "hypercolor>=0.3.2,<0.4.0", ] [dependency-groups] diff --git a/tests/test_api.py b/tests/test_api.py index 2dcbba2..4d82748 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,6 +6,7 @@ from custom_components.hypercolor.api import ( CannotConnectError, InvalidAuthError, + UnsupportedDaemonError, _normalize_server_info, async_validate_daemon, ) @@ -76,8 +77,6 @@ def handler(request: httpx.Request) -> httpx.Response: } }, ) - if request.url.path == "/api/v1/effects": - return httpx.Response(200, json={"data": []}) return httpx.Response(403, json={"error": {"code": "forbidden"}}) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: @@ -88,3 +87,65 @@ def handler(request: httpx.Request) -> httpx.Response: port=9420, api_key="hc_ak_r_read_only", ) + + +async def test_validate_daemon_uses_non_mutating_control_probe() -> None: + requests: list[tuple[str, str, bytes]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append((request.method, request.url.path, request.content)) + if request.url.path == "/api/v1/server": + return httpx.Response( + 200, + json={ + "data": { + "identity": { + "instance_id": "srv_1", + "instance_name": "Hyperia", + "version": "0.1.0", + }, + "auth_required": True, + } + }, + ) + return httpx.Response(200, json={"data": {}}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + await async_validate_daemon( + client, + host="127.0.0.1", + port=9420, + api_key="hc_ak_control", + ) + + assert [(method, path) for method, path, _ in requests] == [ + ("GET", "/api/v1/server"), + ("GET", "/api/v1/output/power"), + ("POST", "/api/v1/diagnose"), + ] + assert all(path != "/api/v1/effects/current/controls" for _, path, _ in requests) + + +async def test_validate_daemon_rejects_missing_output_power_contract() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/api/v1/server": + return httpx.Response( + 200, + json={ + "data": { + "instance_id": "srv_1", + "instance_name": "Hyperia", + "version": "0.3.1", + } + }, + ) + return httpx.Response(404, json={"error": {"code": "not_found"}}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(UnsupportedDaemonError): + await async_validate_daemon( + client, + host="127.0.0.1", + port=9420, + api_key=None, + ) diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index 1fbc50d..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -import pytest - -from custom_components.hypercolor.client import async_stop_effect -from hypercolor import HypercolorNotFoundError - - -class _StopClient: - def __init__(self, error: Exception | None = None) -> None: - self.error = error - self.stop_calls = 0 - - async def stop_effect(self) -> None: - self.stop_calls += 1 - if self.error is not None: - raise self.error - - -async def test_stop_effect_succeeds_normally() -> None: - client = _StopClient() - - await async_stop_effect(client) - - assert client.stop_calls == 1 - - -async def test_stop_effect_accepts_already_stopped_response() -> None: - client = _StopClient(HypercolorNotFoundError("No effect is currently active")) - - await async_stop_effect(client) - - assert client.stop_calls == 1 - - -async def test_stop_effect_preserves_other_not_found_errors() -> None: - client = _StopClient(HypercolorNotFoundError("Stop endpoint is unavailable")) - - with pytest.raises(HypercolorNotFoundError, match="Stop endpoint is unavailable"): - await async_stop_effect(client) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..2e6d123 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +from homeassistant.config_entries import ConfigEntryState + +from custom_components.hypercolor import async_migrate_entry +from custom_components.hypercolor.config_flow import _device_options + + +def test_device_options_use_live_devices_and_preserve_selected_missing_ids() -> None: + entry: Any = SimpleNamespace( + state=ConfigEntryState.LOADED, + runtime_data=SimpleNamespace( + coordinators={ + "devices": SimpleNamespace(data=[{"id": "wled-office", "name": "Office WLED"}]) + } + ), + ) + + options = _device_options(entry, ["corsair-offline"]) + + assert options == [ + {"value": "corsair-offline", "label": "corsair-offline"}, + {"value": "wled-office", "label": "Office WLED"}, + ] + + +async def test_migration_disables_legacy_default_polling_and_dead_channel() -> None: + updates: dict[str, Any] = {} + hass: Any = SimpleNamespace( + config_entries=SimpleNamespace( + async_update_entry=lambda _entry, **values: updates.update(values) + ) + ) + entry: Any = SimpleNamespace( + version=1, + minor_version=1, + options={ + "reconcile_interval_s": 60, + "channels.device_metrics": True, + }, + ) + + assert await async_migrate_entry(hass, entry) + assert updates["minor_version"] == 2 + assert updates["options"]["reconcile_interval_s"] == 0 + assert "channels.device_metrics" not in updates["options"] diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index fe4081b..1f394d3 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -1,9 +1,27 @@ from __future__ import annotations +import asyncio from types import SimpleNamespace from typing import Any -from custom_components.hypercolor.coordinator import _handle_ws_message, load_catalog, load_state +import pytest +from hypercolor.websocket import EventMessage, HelloMessage, MetricsMessage +from websockets.datastructures import Headers +from websockets.exceptions import InvalidStatus +from websockets.http11 import Response + +from custom_components.hypercolor.coordinator import ( + _handle_ws_message, + _mark_disconnected, + _normalize_websocket_error, + _process_ws_message, + _reconcile_after_reconnect, + _seed_hello, + load_catalog, + load_state, +) +from custom_components.hypercolor.runtime_data import ConnectionState +from hypercolor import HypercolorAuthenticationError async def test_load_state_flattens_status_and_active_resources() -> None: @@ -22,7 +40,9 @@ async def test_load_state_flattens_status_and_active_resources() -> None: SimpleNamespace( id="aurora", name="Aurora", + state="paused", active_preset_id="soft", + active_preset_modified=True, cover_image_url="/api/v1/effects/aurora/cover", ) ), @@ -38,7 +58,7 @@ async def test_load_state_flattens_status_and_active_resources() -> None: ) ), get_active_layout=_async_value(SimpleNamespace(id="layout-1")), - root_url="http://hyperia.test:9420", + get_effect=_async_value(SimpleNamespace(presets=[])), active_effect_cover_image_url=lambda: ( "http://hyperia.test:9420/api/v1/effects/active/cover" ), @@ -53,6 +73,8 @@ async def test_load_state_flattens_status_and_active_resources() -> None: == "http://hyperia.test:9420/api/v1/effects/active/cover" ) assert state["active_preset"] == "soft" + assert state["active_preset_modified"] is True + assert state["active_effect_state"] == "paused" assert state["global_brightness"] == 66 assert state["active_scene"] == "scene-1" assert state["active_scene_name"] == "Battlestation" @@ -61,7 +83,7 @@ async def test_load_state_flattens_status_and_active_resources() -> None: assert state["groups_revision"] == 7 -async def test_load_state_resolves_cover_image_without_client_helper() -> None: +async def test_load_state_uses_client_active_cover_url() -> None: client = SimpleNamespace( get_status=_async_value(SimpleNamespace(active_effect="Aurora")), get_active_effect=_async_value( @@ -69,14 +91,17 @@ async def test_load_state_resolves_cover_image_without_client_helper() -> None: ), get_active_scene=_async_value(None), get_active_layout=_async_value(None), - root_url="http://hyperia.test:9420/api/v1", + get_effect=_async_value(SimpleNamespace(presets=[])), + active_effect_cover_image_url=lambda: ( + "http://hyperia.test:9420/api/v1/effects/active/cover" + ), ) state = await load_state(client) assert ( state["active_effect_cover_image_url"] - == "http://hyperia.test:9420/api/v1/effects/aurora/cover" + == "http://hyperia.test:9420/api/v1/effects/active/cover" ) @@ -117,10 +142,10 @@ async def test_load_catalog_has_empty_preset_stack_without_active_effect() -> No assert catalog["presets"] == [] -def test_ws_events_schedule_refresh_without_overwriting_state() -> None: +def test_ws_events_refresh_only_the_owning_coordinator() -> None: state = _FakeCoordinator({"active_effect": "Aurora"}) runtime: Any = SimpleNamespace( - connection_state=SimpleNamespace(set_connected=lambda: None), + connection_state=SimpleNamespace(set_connected=lambda: False), coordinators={ "state": state, "catalog": _FakeCoordinator({}), @@ -130,18 +155,274 @@ def test_ws_events_schedule_refresh_without_overwriting_state() -> None: _handle_ws_message( runtime, - EventMessage("effect_degraded", {"state": "failed"}), + EventMessage(event="effect_degraded", timestamp="now", data={"state": "failed"}), {}, ) assert state.data == {"active_effect": "Aurora"} assert state.hass.scheduled == 1 + assert runtime.coordinators["catalog"].hass.scheduled == 0 + assert runtime.coordinators["devices"].hass.scheduled == 0 + + +def test_ws_effect_switch_refreshes_state_and_effect_scoped_presets() -> None: + runtime: Any = SimpleNamespace( + connection_state=SimpleNamespace(set_connected=lambda: False), + coordinators={ + "state": _FakeCoordinator({}), + "catalog": _FakeCoordinator({}), + "devices": _FakeCoordinator([]), + }, + ) + + _handle_ws_message( + runtime, + EventMessage(event="effect_started", timestamp="now", data={"effect": "Aurora"}), + {}, + ) + + assert runtime.coordinators["state"].hass.scheduled == 1 assert runtime.coordinators["catalog"].hass.scheduled == 1 + assert runtime.coordinators["devices"].hass.scheduled == 0 + + +def test_ws_pause_resume_and_brightness_patch_state_without_http_refresh() -> None: + state = _FakeCoordinator({"active_effect": "Aurora", "active_effect_state": "running"}) + runtime: Any = SimpleNamespace( + connection_state=SimpleNamespace(set_connected=lambda: False), + coordinators={"state": state}, + ) + + _handle_ws_message(runtime, EventMessage(event="paused", timestamp="now", data={}), {}) + assert state.data["active_effect_state"] == "paused" + + _handle_ws_message( + runtime, + EventMessage(event="brightness_changed", timestamp="now", data={"new_value": 42}), + {}, + ) + assert state.data["global_brightness"] == 42 + assert state.hass.scheduled == 0 + + _handle_ws_message(runtime, EventMessage(event="resumed", timestamp="now", data={}), {}) + assert state.data["active_effect_state"] == "running" + + +def test_ws_resync_hint_refreshes_every_coordinator() -> None: + runtime: Any = SimpleNamespace( + connection_state=SimpleNamespace(set_connected=lambda: False), + coordinators={ + name: _FakeCoordinator({}) + for name in ("state", "catalog", "devices", "metrics", "audio") + }, + ) + + _handle_ws_message( + runtime, + EventMessage( + event="resync_required", + timestamp="now", + data={"dropped_events": 17}, + ), + {}, + ) + + assert all(coordinator.hass.scheduled == 1 for coordinator in runtime.coordinators.values()) + + +async def test_ws_resync_is_a_barrier_before_newer_events() -> None: + release_refresh = asyncio.Event() + refresh_started = asyncio.Event() + state = _BarrierCoordinator(release_refresh, refresh_started) + runtime: Any = SimpleNamespace( + connection_state=SimpleNamespace(set_connected=lambda: False), + coordinators={"state": state}, + ) + resync = EventMessage( + event="resync_required", + timestamp="now", + data={"dropped_events": 1}, + ) + + barrier = asyncio.create_task(_process_ws_message(runtime, resync, {})) + await refresh_started.wait() + + assert not barrier.done() + release_refresh.set() + await barrier + await _process_ws_message( + runtime, + EventMessage(event="resumed", timestamp="now", data={}), + {}, + ) + + assert state.data["active_effect_state"] == "running" + + +def test_ws_catalog_audio_and_device_events_are_targeted() -> None: + runtime: Any = SimpleNamespace( + connection_state=SimpleNamespace(set_connected=lambda: False), + coordinators={ + name: _FakeCoordinator({} if name != "devices" else []) + for name in ("state", "catalog", "devices", "audio") + }, + ) + + _handle_ws_message( + runtime, + EventMessage(event="library_store_changed", timestamp="now", data={}), + {}, + ) + assert runtime.coordinators["catalog"].hass.scheduled == 1 + assert runtime.coordinators["state"].hass.scheduled == 0 + + _handle_ws_message( + runtime, + EventMessage(event="audio_source_changed", timestamp="now", data={}), + {}, + ) + assert runtime.coordinators["audio"].hass.scheduled == 1 + assert runtime.coordinators["state"].hass.scheduled == 1 + + _handle_ws_message( + runtime, + EventMessage(event="device_connected", timestamp="now", data={}), + {}, + ) assert runtime.coordinators["devices"].hass.scheduled == 1 + _handle_ws_message( + runtime, + EventMessage(event="control_surface_changed", timestamp="now", data={}), + {}, + ) + assert runtime.coordinators["devices"].hass.scheduled == 2 + + +def test_ws_metrics_keep_nested_daemon_schema() -> None: + metrics = _FakeCoordinator({}) + runtime: Any = SimpleNamespace( + connection_state=SimpleNamespace(set_connected=lambda: False), + coordinators={"metrics": metrics}, + ) + + _handle_ws_message( + runtime, + MetricsMessage( + timestamp="now", + data={"fps": {"actual": 58.5}, "frame_time": {"avg_ms": 4.2}}, + ), + {}, + ) + + assert metrics.data["fps"]["actual"] == 58.5 + assert metrics.data["frame_time"]["avg_ms"] == 4.2 + + +def test_ws_preset_library_event_refreshes_catalog_and_state() -> None: + runtime: Any = SimpleNamespace( + connection_state=SimpleNamespace(set_connected=lambda: False), + coordinators={ + "state": _FakeCoordinator({}), + "catalog": _FakeCoordinator({}), + }, + ) + + _handle_ws_message( + runtime, + EventMessage( + event="library_store_changed", + timestamp="now", + data={"collection": "presets", "kind": "updated"}, + ), + {}, + ) + + assert runtime.coordinators["catalog"].hass.scheduled == 1 + assert runtime.coordinators["state"].hass.scheduled == 1 + + +def test_ws_hello_patches_canonical_state_and_metrics_fields() -> None: + state = _FakeCoordinator( + {"active_effect": "Old", "active_effect_id": "old", "active_effect_state": "running"} + ) + metrics = _FakeCoordinator({}) + runtime: Any = SimpleNamespace(coordinators={"state": state, "metrics": metrics}) + + _seed_hello( + runtime, + HelloMessage( + version="1", + state={ + "paused": True, + "brightness": 42, + "effect": {"id": "aurora", "name": "Aurora"}, + "scene": {"id": "scene-1", "name": "Desk"}, + "device_count": 3, + "fps": {"actual": 58.5, "target": 60}, + }, + capabilities=[], + subscriptions=[], + ), + ) + + assert state.data["active_effect_state"] == "paused" + assert state.data["global_brightness"] == 42 + assert state.data["active_effect_id"] == "aurora" + assert state.data["active_scene"] == "scene-1" + assert state.data["device_count"] == 3 + assert metrics.data["fps"]["actual"] == 58.5 + + +def test_ws_rejected_handshake_is_typed_as_authentication_failure() -> None: + response = Response(401, "Unauthorized", Headers()) + + error = _normalize_websocket_error(InvalidStatus(response)) + + assert isinstance(error, HypercolorAuthenticationError) + assert error.status_code == 401 + + +async def test_websocket_disconnect_marks_all_coordinators_unavailable_after_threshold( + monkeypatch: Any, +) -> None: + created_issues: list[str] = [] + monkeypatch.setattr( + "custom_components.hypercolor.coordinator.async_create_unavailable_issue", + lambda _hass, entry_id: created_issues.append(entry_id), + ) + hass = SimpleNamespace(async_create_task=asyncio.create_task) + state: Any = _FakeCoordinator({}) + state.hass = hass + state.config_entry = SimpleNamespace(entry_id="entry-1") + catalog = _FakeCoordinator({}) + runtime: Any = SimpleNamespace( + connection_state=ConnectionState(connected=True), + coordinators={"state": state, "catalog": catalog}, + unavailable_task=None, + ) + + _mark_disconnected(runtime, {"unavailable_after_s": 0}, ConnectionError("offline")) + await runtime.unavailable_task + + assert isinstance(state.update_error, ConnectionError) + assert isinstance(catalog.update_error, ConnectionError) + assert created_issues == ["entry-1"] + + +async def test_reconnect_reconciliation_does_not_swallow_refresh_failures() -> None: + coordinator = _RetryCoordinator() + runtime: Any = SimpleNamespace(coordinators={"state": coordinator}) + + with pytest.raises(ConnectionError, match="retry me"): + await _reconcile_after_reconnect(runtime, {}) + await _reconcile_after_reconnect(runtime, {}) + + assert coordinator.calls == 2 + def _async_value(value: object): - async def _loader() -> object: + async def _loader(*_args: object) -> object: return value return _loader @@ -155,12 +436,6 @@ async def _loader(effect_id: str) -> object: return _loader -class EventMessage: - def __init__(self, event: str, data: object) -> None: - self.event = event - self.data = data - - class _FakeHass: def __init__(self) -> None: self.scheduled = 0 @@ -171,9 +446,38 @@ def async_create_task(self, coro: Any) -> None: class _FakeCoordinator: - def __init__(self, data: object) -> None: - self.data = data + def __init__(self, data: Any) -> None: + self.data: Any = data self.hass = _FakeHass() + self.update_error: BaseException | None = None async def async_request_refresh(self) -> None: return None + + def async_set_updated_data(self, data: Any) -> None: + self.data = data + + def async_set_update_error(self, error: BaseException) -> None: + self.update_error = error + + +class _BarrierCoordinator(_FakeCoordinator): + def __init__(self, release_refresh: asyncio.Event, refresh_started: asyncio.Event) -> None: + super().__init__({"active_effect_state": "running"}) + self._release_refresh = release_refresh + self._refresh_started = refresh_started + + async def async_request_refresh(self) -> None: + self._refresh_started.set() + await self._release_refresh.wait() + self.data = {"active_effect_state": "paused"} + + +class _RetryCoordinator: + def __init__(self) -> None: + self.calls = 0 + + async def async_request_refresh(self) -> None: + self.calls += 1 + if self.calls == 1: + raise ConnectionError("retry me") diff --git a/tests/test_entity.py b/tests/test_entity.py new file mode 100644 index 0000000..3f4e065 --- /dev/null +++ b/tests/test_entity.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +from custom_components.hypercolor.entity import add_configured_device_entities + + +def test_configured_device_entities_follow_live_discovery() -> None: + coordinator = _Coordinator([{"id": "wled-office"}]) + entry: Any = SimpleNamespace( + options={"per_device_entities": ["wled-office", "corsair-lcd"]}, + runtime_data=SimpleNamespace(coordinators={"devices": coordinator}), + async_on_unload=lambda remove: None, + ) + added: list[str] = [] + + def add_entities(entities: list[Any]) -> None: + added.extend(str(entity) for entity in entities) + + add_configured_device_entities( + entry, + cast(Any, add_entities), + cast(Any, lambda _entry, device: str(device["id"])), + ) + coordinator.data.append({"id": "corsair-lcd"}) + coordinator.listener() + coordinator.listener() + + assert added == ["wled-office", "corsair-lcd"] + + +class _Coordinator: + def __init__(self, data: list[dict[str, str]]) -> None: + self.data = data + self.listener = lambda: None + + def async_add_listener(self, listener: Any) -> Any: + self.listener = listener + return lambda: None diff --git a/tests/test_hass_e2e.py b/tests/test_hass_e2e.py index 860af80..45baba2 100644 --- a/tests/test_hass_e2e.py +++ b/tests/test_hass_e2e.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.hypercolor.const import ( @@ -42,6 +42,7 @@ async def fake_daemon( app.router.add_patch("/api/v1/effects/current/controls", daemon.update_controls) app.router.add_put("/api/v1/settings/brightness", daemon.set_brightness) app.router.add_put("/api/v1/devices/{device_id}", daemon.update_device) + app.router.add_put("/api/v1/output/power", daemon.set_output_power) app.router.add_post("/api/v1/effects/stop", daemon.stop_effect) app.router.add_patch("/api/v1/scenes/{scene_id}/zones/{zone_id}", daemon.update_zone) app.router.add_route("*", "/api/v1/{tail:.*}", daemon.handle_api) @@ -62,6 +63,13 @@ async def test_config_entry_boots_and_controls_fake_daemon( fake_daemon: _FakeHypercolorDaemon, ) -> None: entry = await _setup_entry(hass, port=fake_daemon.port) + assert entry.runtime_data.reconcile_task is None + device_registry = dr.async_get(hass) + hub = device_registry.async_get_device(identifiers={(DOMAIN, "srv_e2e")}) + child = device_registry.async_get_device(identifiers={(DOMAIN, "srv_e2e:device:wled-studio")}) + assert hub is not None + assert child is not None + assert child.via_device_id == hub.id master = _first_state(hass, "light", lambda state: state.attributes.get("effect") == "Rainbow") assert master.state == "on" @@ -111,6 +119,12 @@ async def test_config_entry_boots_and_controls_fake_daemon( "effect_id": "solid_color", "controls": {}, } + preset_select = _first_state( + hass, + "select", + lambda state: state.entity_id.endswith("_preset"), + ) + assert preset_select.attributes["options"] == [] await hass.services.async_call( DOMAIN, @@ -214,13 +228,39 @@ async def test_stale_zone_entities_are_pruned_at_setup( assert await hass.config_entries.async_unload(entry.entry_id) -async def test_master_turn_on_resumes_last_effect( +async def test_offline_opted_out_device_entities_are_pruned_at_setup( + hass: HomeAssistant, + enable_custom_integrations: None, + fake_daemon: _FakeHypercolorDaemon, +) -> None: + entry = await _setup_entry(hass, port=fake_daemon.port, setup=False) + entity_registry = er.async_get(hass) + stale = [ + entity_registry.async_get_or_create( + domain, + DOMAIN, + f"srv_e2e:device:corsair-offline:{suffix}", + config_entry=entry, + ) + for domain, suffix in ( + ("light", "light"), + ("button", "identify"), + ("switch", "enabled"), + ) + ] + + await _activate_entry(hass, entry) + + assert all(entity_registry.async_get(item.entity_id) is None for item in stale) + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_master_pause_resume_preserves_exact_effect_state( hass: HomeAssistant, enable_custom_integrations: None, fake_daemon: _FakeHypercolorDaemon, ) -> None: entry = await _setup_entry(hass, port=fake_daemon.port) - state_coordinator = entry.runtime_data.coordinators["state"] master = _first_state(hass, "light", lambda state: "active_effect_id" in state.attributes) assert master.state == "on" assert master.attributes["effect"] == "Rainbow" @@ -228,7 +268,6 @@ async def test_master_turn_on_resumes_last_effect( await hass.services.async_call( "light", "turn_off", {"entity_id": master.entity_id}, blocking=True ) - await state_coordinator.async_refresh() stopped = hass.states.get(master.entity_id) assert stopped is not None assert stopped.state == "off" @@ -236,15 +275,12 @@ async def test_master_turn_on_resumes_last_effect( await hass.services.async_call( "light", "turn_on", {"entity_id": master.entity_id}, blocking=True ) - await state_coordinator.async_refresh() - - # A plain turn-on must resume the effect AND the preset it was running with - # before turn-off, not just the bare effect. - assert { - "effect_id": "rainbow", - "controls": {"speed": 60}, - "preset_id": "preset-rainbow", - } in fake_daemon.applied_effects + assert fake_daemon.pause_requests == 1 + assert fake_daemon.resume_requests == 1 + assert fake_daemon.active_effect_id == "rainbow" + assert fake_daemon.active_preset_id == "preset-rainbow" + assert fake_daemon.control_values == {"speed": 60.0, "brightness": 80.0} + assert fake_daemon.applied_effects == [] resumed = hass.states.get(master.entity_id) assert resumed is not None assert resumed.state == "on" @@ -277,6 +313,10 @@ async def test_preset_select_applies_unified_effect_preset( "controls": {"speed": 60}, "preset_id": "preset-rainbow", } in fake_daemon.applied_effects + selected = hass.states.get(preset_select.entity_id) + assert selected is not None + assert selected.state == "Rainbow Soft" + assert selected.attributes["active_preset_modified"] is False assert await hass.config_entries.async_unload(entry.entry_id) @@ -300,8 +340,36 @@ async def test_master_turn_off_and_stop_button_are_idempotent( await hass.services.async_call( "button", "press", {"entity_id": stop_button.entity_id}, blocking=True ) + await hass.services.async_call( + "button", "press", {"entity_id": stop_button.entity_id}, blocking=True + ) - assert fake_daemon.stop_requests == 3 + assert fake_daemon.pause_requests == 2 + assert fake_daemon.stop_requests == 2 + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_selecting_effect_while_paused_uses_effect_apply_wake( + hass: HomeAssistant, + enable_custom_integrations: None, + fake_daemon: _FakeHypercolorDaemon, +) -> None: + entry = await _setup_entry(hass, port=fake_daemon.port) + master = _first_state(hass, "light", lambda state: "active_effect_id" in state.attributes) + + await hass.services.async_call( + "light", "turn_off", {"entity_id": master.entity_id}, blocking=True + ) + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": master.entity_id, "effect": "Solid Color"}, + blocking=True, + ) + + assert fake_daemon.active_effect_id == "solid_color" + assert fake_daemon.paused is False + assert fake_daemon.resume_requests == 0 assert await hass.config_entries.async_unload(entry.entry_id) @@ -323,7 +391,7 @@ async def _setup_entry( }, options={ **OPTIONS_DEFAULTS, - CONF_RECONCILE_INTERVAL_S: 3600, + CONF_RECONCILE_INTERVAL_S: 0, CONF_CHANNELS_AUDIO: False, CONF_CHANNELS_METRICS: False, CONF_LIVE_CONTROLS_ENABLED: True, @@ -360,6 +428,7 @@ def __init__(self) -> None: self.port = 0 self.active_effect_id = "rainbow" self.active_preset_id = "preset-rainbow" + self.paused = False self.brightness = 80 self.control_values: dict[str, Any] = {"speed": 60.0, "brightness": 80.0} self.control_updates: list[dict[str, Any]] = [] @@ -367,6 +436,8 @@ def __init__(self) -> None: self.device_updates: list[dict[str, Any]] = [] self.zone_updates: list[dict[str, Any]] = [] self.stop_requests = 0 + self.pause_requests = 0 + self.resume_requests = 0 async def websocket(self, request: web.Request) -> web.WebSocketResponse: ws = web.WebSocketResponse(protocols=("hypercolor-v1",)) @@ -404,6 +475,7 @@ async def handle_api(self, request: web.Request) -> web.Response: return self._ok(self._items(presets)) responses = { "GET /server": self._server, + "GET /output/power": lambda: {"state": "paused" if self.paused else "running"}, "GET /status": self._status, "GET /effects": lambda: self._items(self._effects()), "GET /effects/active": self._active_effect, @@ -423,6 +495,8 @@ async def apply_effect(self, request: web.Request) -> web.Response: body = await _json_body(request) effect_id = request.match_info["effect_id"] self.active_effect_id = effect_id + self.active_preset_id = body.get("preset_id") + self.paused = False controls = dict(body.get("controls") or {}) self.control_values.update(controls) applied = {"effect_id": effect_id, "controls": controls} @@ -491,8 +565,19 @@ async def stop_effect(self, request: web.Request) -> web.Response: status=404, ) self.active_effect_id = "" + self.active_preset_id = None + self.paused = False return self._ok({"stopped": True}) + async def set_output_power(self, request: web.Request) -> web.Response: + body = await _json_body(request) + self.paused = body["state"] == "paused" + if self.paused: + self.pause_requests += 1 + else: + self.resume_requests += 1 + return self._ok({"state": body["state"]}) + async def update_zone(self, request: web.Request) -> web.Response: body = await _json_body(request) self.zone_updates.append( @@ -534,7 +619,11 @@ def _status(self) -> dict[str, Any]: "global_brightness": self.brightness, "audio_available": True, "capture_available": False, - "render_loop": {"state": "running", "fps_tier": "30fps", "total_frames": 123}, + "render_loop": { + "state": "paused" if self.paused else "running", + "fps_tier": "30fps", + "total_frames": 123, + }, "event_bus_subscribers": 1, "active_effect": self._effect_name(self.active_effect_id), } @@ -571,7 +660,7 @@ def _active_effect(self) -> dict[str, Any]: effect = { "id": self.active_effect_id, "name": self._effect_name(self.active_effect_id), - "state": "running", + "state": "paused" if self.paused else "running", "controls": [ { "id": "speed", diff --git a/tests/test_runtime_data.py b/tests/test_runtime_data.py new file mode 100644 index 0000000..1500c5e --- /dev/null +++ b/tests/test_runtime_data.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from custom_components.hypercolor.runtime_data import ConnectionState + + +def test_connection_state_notifies_only_on_transitions() -> None: + state = ConnectionState() + notifications = 0 + + def listener() -> None: + nonlocal notifications + notifications += 1 + + remove = state.async_add_listener(listener) + state.set_connected() + state.set_connected() + state.set_disconnected(ConnectionError("offline")) + state.set_disconnected(ConnectionError("offline")) + remove() + state.set_connected() + + assert notifications == 2 diff --git a/tests/test_select.py b/tests/test_select.py index bdd52f7..f1e7075 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -34,3 +34,19 @@ def test_preset_options_hide_stack_from_stale_effect() -> None: entity._state = SimpleNamespace(data={"active_effect_id": "rainbow"}) assert entity.options == [] + + +def test_modified_preset_stays_selected_and_reports_derivation() -> None: + preset = SimpleNamespace(id="preset-soft", name="Soft", effect_id="aurora") + entity = object.__new__(HypercolorPresetSelect) + entity.coordinator = SimpleNamespace(data={"preset_effect_id": "aurora", "presets": [preset]}) + entity._state = SimpleNamespace( + data={ + "active_effect_id": "aurora", + "active_preset": "preset-soft", + "active_preset_modified": True, + } + ) + + assert entity.current_option == "Soft" + assert entity.extra_state_attributes == {"active_preset_modified": True} diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100644 index 0000000..328f56b --- /dev/null +++ b/tests/test_sensor.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +from custom_components.hypercolor.sensor import HypercolorFpsSensor, HypercolorRenderTimeSensor + + +def test_metrics_sensors_read_nested_daemon_payload() -> None: + coordinator = SimpleNamespace( + data={ + "fps": {"actual": 58.75, "target": 60}, + "frame_time": {"avg_ms": 4.25, "p95_ms": 7.5}, + }, + last_update_success=True, + ) + entry: Any = SimpleNamespace( + data={"host": "hyperia", "port": 9420}, + runtime_data=SimpleNamespace( + server=SimpleNamespace( + instance_id="srv-1", + instance_name="Hyperia", + version="0.3.2", + ), + coordinators={"metrics": coordinator}, + ), + ) + + assert HypercolorFpsSensor(entry).native_value == 58.75 + assert HypercolorRenderTimeSensor(entry).native_value == 4.25 diff --git a/tests/test_services.py b/tests/test_services.py index 988d512..7808686 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -6,12 +6,16 @@ import pytest import voluptuous as vol from homeassistant.const import CONF_NAME -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import config_validation as cv +from pytest_homeassistant_custom_component.common import MockUser +from custom_components.hypercolor import services as services_module from custom_components.hypercolor.const import DOMAIN from custom_components.hypercolor.services import ( CONF_CONFIG_ENTRY_ID, + SERVICE_APPLY_EFFECT, _apply_effect, _apply_preset, _list_presets, @@ -22,6 +26,7 @@ _set_unassigned_behavior, _set_zone, _upload_effect, + async_setup_services, ) @@ -32,6 +37,22 @@ def test_service_schema_requires_mutation_fields() -> None: schema({CONF_CONFIG_ENTRY_ID: "entry-1"}) +async def test_registered_services_reject_non_admin_users( + hass: HomeAssistant, + hass_read_only_user: MockUser, +) -> None: + async_setup_services(hass) + + with pytest.raises(Unauthorized): + await hass.services.async_call( + DOMAIN, + SERVICE_APPLY_EFFECT, + {CONF_CONFIG_ENTRY_ID: "entry-1", "effect_id": "aurora"}, + blocking=True, + context=Context(user_id=hass_read_only_user.id), + ) + + async def test_apply_effect_can_route_to_preset() -> None: client = _FakeClient() call = _call(client, {"effect_id": "aurora", "preset_id": "preset-1"}) @@ -185,6 +206,49 @@ async def test_upload_effect_accepts_inline_html() -> None: assert result == {"effect": {"id": "user:neon"}} +async def test_upload_effect_rejects_path_outside_allowed_roots(tmp_path: Any) -> None: + path = tmp_path / "secret.html" + path.write_text("") + call = _call(_FakeClient(), {"path": str(path)}) + call.hass.config = SimpleNamespace(is_allowed_path=lambda _: False) + + with pytest.raises(HomeAssistantError, match="outside Home Assistant's allowed paths"): + await _upload_effect(call) + + +async def test_upload_effect_rejects_oversized_file_before_read(tmp_path: Any) -> None: + path = tmp_path / "huge.html" + path.write_bytes(b"x" * (1024 * 1024 + 1)) + call = _call(_FakeClient(), {"path": str(path)}) + call.hass.config = SimpleNamespace(is_allowed_path=lambda _: True) + + with pytest.raises(HomeAssistantError, match="exceeds the 1 MiB"): + await _upload_effect(call) + + +async def test_upload_effect_rejects_path_replacement_during_open( + tmp_path: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "effect.html" + outside = tmp_path / "secret.html" + path.write_text("safe") + outside.write_text("secret") + call = _call(_FakeClient(), {"path": str(path)}) + call.hass.config = SimpleNamespace(is_allowed_path=lambda _: True) + real_open = services_module.os.open + + def replace_then_open(file_path: Any, flags: int) -> int: + path.unlink() + path.symlink_to(outside) + return real_open(file_path, flags) + + monkeypatch.setattr(services_module.os, "open", replace_then_open) + + with pytest.raises(HomeAssistantError, match=r"Unable to read effect file|changed while"): + await _upload_effect(call) + + class _FakeClient: def __init__(self) -> None: self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = [] diff --git a/uv.lock b/uv.lock index 0e9e150..fa3ccd8 100644 --- a/uv.lock +++ b/uv.lock @@ -1047,7 +1047,7 @@ wheels = [ [[package]] name = "hypercolor" -version = "0.3.1" +version = "0.3.2" source = { editable = "../hypercolor/python" } dependencies = [ { name = "attrs" },