diff --git a/custom_components/ledgrab/coordinator.py b/custom_components/ledgrab/coordinator.py index 1a00a0d..d0a2a57 100644 --- a/custom_components/ledgrab/coordinator.py +++ b/custom_components/ledgrab/coordinator.py @@ -349,7 +349,20 @@ class LedGrabCoordinator(DataUpdateCoordinator): await self.async_request_refresh() async def update_source(self, source_id: str, **kwargs: Any) -> None: - """Update a color strip source's fields.""" + """Update a color strip source's fields. + + The server uses a discriminated-union body keyed on ``source_type``; + if the caller didn't supply it, look it up from the cached sources so + the request validates server-side. + """ + if "source_type" not in kwargs and self.data: + for source in self.data.get("css_sources", []): + if source.get("id") == source_id: + src_type = source.get("source_type") + if src_type: + kwargs["source_type"] = src_type + break + async with self.session.put( f"{self.server_url}/api/v1/color-strip-sources/{source_id}", headers={**self._auth_headers, "Content-Type": "application/json"}, diff --git a/custom_components/ledgrab/light.py b/custom_components/ledgrab/light.py index 7f6dc6b..2976f55 100644 --- a/custom_components/ledgrab/light.py +++ b/custom_components/ledgrab/light.py @@ -13,6 +13,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, DATA_COORDINATOR @@ -41,8 +42,16 @@ async def async_setup_entry( async_add_entities(entities) -class ApiInputLight(CoordinatorEntity, LightEntity): - """Representation of an api_input CSS source as a light entity.""" +class ApiInputLight(CoordinatorEntity, LightEntity, RestoreEntity): + """Representation of an api_input CSS source as a light entity. + + The light's RGB color doubles as the source's ``fallback_color`` (the + resting color shown when no API input arrives within ``timeout``). + Turning the light off only pushes black segments — it does *not* zero + ``fallback_color`` — so the chosen default color persists across off/on + cycles. The on/off state itself is restored across HA restarts via + ``RestoreEntity``. + """ _attr_has_entity_name = True _attr_color_mode = ColorMode.RGB @@ -63,15 +72,37 @@ class ApiInputLight(CoordinatorEntity, LightEntity): self._entry_id = entry_id self._attr_unique_id = f"{self._source_id}_light" - # Restore state from fallback_color + # Seed color from the source's current fallback_color; default to + # white if it's unset/black so the picker has a sensible starting + # value. is_on is provisional here and refined in async_added_to_hass + # from RestoreEntity, since fallback_color alone no longer indicates + # on/off. fallback = self._get_fallback_color() - is_off = fallback == [0, 0, 0] - self._is_on: bool = not is_off + has_color = fallback != [0, 0, 0] self._rgb_color: tuple[int, int, int] = ( - (255, 255, 255) if is_off else tuple(fallback) # type: ignore[arg-type] + tuple(fallback) if has_color else (255, 255, 255) # type: ignore[arg-type] ) + self._is_on: bool = has_color self._brightness: int = 255 + async def async_added_to_hass(self) -> None: + """Restore on/off state and last color from the previous HA session.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if last_state is None: + return + self._is_on = last_state.state == "on" + last_rgb = last_state.attributes.get("rgb_color") + if ( + isinstance(last_rgb, (list, tuple)) + and len(last_rgb) == 3 + and all(isinstance(c, (int, float)) for c in last_rgb) + ): + self._rgb_color = tuple(int(c) for c in last_rgb) # type: ignore[assignment] + last_brightness = last_state.attributes.get("brightness") + if isinstance(last_brightness, (int, float)): + self._brightness = int(last_brightness) + @property def device_info(self) -> dict[str, Any]: """Return device information — one virtual device per api_input source.""" @@ -126,14 +157,14 @@ class ApiInputLight(CoordinatorEntity, LightEntity): self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the light by pushing black and setting fallback to black.""" - off_color = [0, 0, 0] + """Turn off the light by pushing black segments. + + ``fallback_color`` is intentionally left untouched so the chosen + default color survives off/on cycles. + """ await self.coordinator.push_segments( self._source_id, - [{"start": 0, "length": 9999, "mode": "solid", "color": off_color}], - ) - await self.coordinator.update_source( - self._source_id, fallback_color=off_color, + [{"start": 0, "length": 9999, "mode": "solid", "color": [0, 0, 0]}], ) self._is_on = False self.async_write_ha_state() diff --git a/custom_components/ledgrab/manifest.json b/custom_components/ledgrab/manifest.json index 4fac256..b4d7d05 100644 --- a/custom_components/ledgrab/manifest.json +++ b/custom_components/ledgrab/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration/issues", "requirements": ["aiohttp>=3.9.0"], - "version": "0.2.1" + "version": "0.2.2" } diff --git a/custom_components/ledgrab/number.py b/custom_components/ledgrab/number.py index 525c7d1..bb4252d 100644 --- a/custom_components/ledgrab/number.py +++ b/custom_components/ledgrab/number.py @@ -27,6 +27,11 @@ async def async_setup_entry( coordinator: LedGrabCoordinator = data[DATA_COORDINATOR] entities: list[NumberEntity] = [] + if coordinator.data: + for source in coordinator.data.get("css_sources") or []: + if source.get("source_type") == "api_input": + entities.append(ApiInputTimeout(coordinator, source, entry.entry_id)) + if coordinator.data and "targets" in coordinator.data: devices = coordinator.data.get("devices") or {} @@ -231,3 +236,67 @@ class HALightColorTolerance(_HALightNumberBase): super().__init__(coordinator, target_id, entry_id, field_name="color_tolerance") self._attr_unique_id = f"{target_id}_color_tolerance" self._attr_translation_key = "ha_light_color_tolerance" + + +# --- api_input CSS source number entities --- + + +class ApiInputTimeout(CoordinatorEntity, NumberEntity): + """Fallback timeout for an api_input CSS source. + + The server reverts the strip to ``fallback_color`` when no LED data has + arrived for this many seconds. Stored server-side as a ``BindableFloat`` + (plain number when unbound, ``{"value", "source_id"}`` when bound). + """ + + _attr_has_entity_name = True + _attr_native_min_value = 0.0 + _attr_native_max_value = 300.0 + _attr_native_step = 0.5 + _attr_native_unit_of_measurement = "s" + _attr_mode = NumberMode.BOX + _attr_icon = "mdi:timer-sand" + + def __init__( + self, + coordinator: LedGrabCoordinator, + source: dict[str, Any], + entry_id: str, + ) -> None: + super().__init__(coordinator) + self._source_id: str = source["id"] + self._entry_id = entry_id + self._attr_unique_id = f"{self._source_id}_timeout" + self._attr_translation_key = "api_input_timeout" + + @property + def device_info(self) -> dict[str, Any]: + return {"identifiers": {(DOMAIN, self._source_id)}} + + @property + def native_value(self) -> float | None: + source = self._get_source() + if not source: + return None + raw = source.get("timeout") + if isinstance(raw, dict): + return raw.get("value") + if isinstance(raw, (int, float)): + return float(raw) + return None + + @property + def available(self) -> bool: + return self._get_source() is not None + + async def async_set_native_value(self, value: float) -> None: + await self.coordinator.update_source(self._source_id, timeout=round(value, 2)) + await self.coordinator.async_request_refresh() + + def _get_source(self) -> dict[str, Any] | None: + if not self.coordinator.data: + return None + for source in self.coordinator.data.get("css_sources") or []: + if source.get("id") == self._source_id: + return source + return None diff --git a/custom_components/ledgrab/select.py b/custom_components/ledgrab/select.py index dec5a05..d949852 100644 --- a/custom_components/ledgrab/select.py +++ b/custom_components/ledgrab/select.py @@ -29,6 +29,11 @@ async def async_setup_entry( coordinator: LedGrabCoordinator = data[DATA_COORDINATOR] entities: list[SelectEntity] = [] + if coordinator.data: + for source in coordinator.data.get("css_sources") or []: + if source.get("source_type") == "api_input": + entities.append(ApiInputInterpolationSelect(coordinator, source, entry.entry_id)) + if coordinator.data and "targets" in coordinator.data: for target_id, target_data in coordinator.data["targets"].items(): info = target_data["info"] @@ -176,3 +181,64 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity): self._target_id, brightness={"value": 1.0, "source_id": source_id} if source_id else 1.0, ) + + +# --- api_input CSS source select entities --- + +INTERPOLATION_OPTIONS = ["none", "linear", "nearest"] + + +class ApiInputInterpolationSelect(CoordinatorEntity, SelectEntity): + """LED count interpolation mode for an api_input CSS source. + + Controls how the source resamples pushed colors when the target strip + has a different LED count than the input. Server accepts + ``none`` | ``linear`` | ``nearest``. + """ + + _attr_has_entity_name = True + _attr_icon = "mdi:vector-polyline" + _attr_options = INTERPOLATION_OPTIONS + + def __init__( + self, + coordinator: LedGrabCoordinator, + source: dict[str, Any], + entry_id: str, + ) -> None: + super().__init__(coordinator) + self._source_id: str = source["id"] + self._entry_id = entry_id + self._attr_unique_id = f"{self._source_id}_interpolation" + self._attr_translation_key = "api_input_interpolation" + + @property + def device_info(self) -> dict[str, Any]: + return {"identifiers": {(DOMAIN, self._source_id)}} + + @property + def current_option(self) -> str | None: + source = self._get_source() + if not source: + return None + value = source.get("interpolation") + return value if value in INTERPOLATION_OPTIONS else None + + @property + def available(self) -> bool: + return self._get_source() is not None + + async def async_select_option(self, option: str) -> None: + if option not in INTERPOLATION_OPTIONS: + _LOGGER.error("Invalid interpolation option: %s", option) + return + await self.coordinator.update_source(self._source_id, interpolation=option) + await self.coordinator.async_request_refresh() + + def _get_source(self) -> dict[str, Any] | None: + if not self.coordinator.data: + return None + for source in self.coordinator.data.get("css_sources") or []: + if source.get("id") == self._source_id: + return source + return None diff --git a/custom_components/ledgrab/strings.json b/custom_components/ledgrab/strings.json index 8060675..ed9795d 100644 --- a/custom_components/ledgrab/strings.json +++ b/custom_components/ledgrab/strings.json @@ -73,6 +73,9 @@ }, "ha_light_color_tolerance": { "name": "Color Tolerance" + }, + "api_input_timeout": { + "name": "Fallback Timeout" } }, "select": { @@ -81,6 +84,14 @@ }, "brightness_source": { "name": "Brightness Source" + }, + "api_input_interpolation": { + "name": "Interpolation", + "state": { + "none": "None", + "linear": "Linear", + "nearest": "Nearest" + } } } }, diff --git a/custom_components/ledgrab/translations/en.json b/custom_components/ledgrab/translations/en.json index 2e7cb7a..5452dc0 100644 --- a/custom_components/ledgrab/translations/en.json +++ b/custom_components/ledgrab/translations/en.json @@ -73,6 +73,9 @@ }, "ha_light_color_tolerance": { "name": "Color Tolerance" + }, + "api_input_timeout": { + "name": "Fallback Timeout" } }, "select": { @@ -81,6 +84,14 @@ }, "brightness_source": { "name": "Brightness Source" + }, + "api_input_interpolation": { + "name": "Interpolation", + "state": { + "none": "None", + "linear": "Linear", + "nearest": "Nearest" + } } } } diff --git a/custom_components/ledgrab/translations/ru.json b/custom_components/ledgrab/translations/ru.json index b7e4e1a..3ada8ba 100644 --- a/custom_components/ledgrab/translations/ru.json +++ b/custom_components/ledgrab/translations/ru.json @@ -73,6 +73,9 @@ }, "ha_light_color_tolerance": { "name": "Допуск цвета" + }, + "api_input_timeout": { + "name": "Таймаут подмены" } }, "select": { @@ -81,6 +84,14 @@ }, "brightness_source": { "name": "Источник яркости" + }, + "api_input_interpolation": { + "name": "Интерполяция", + "state": { + "none": "Нет", + "linear": "Линейная", + "nearest": "Ближайший" + } } } }