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" },