feat(api-input): expose timeout/interpolation, fix 422 on light turn-on
- coordinator.update_source now auto-injects source_type from cached data so the server's discriminated-union body validates (was rejecting fallback_color updates with 422). - Add Fallback Timeout (number) and Interpolation (select) entities per api_input CSS source. - Light no longer zeros fallback_color on turn_off; state restored across HA restarts via RestoreEntity so the chosen default color persists. - Bump manifest to 0.2.2 and update en/ru translations.
This commit is contained in:
@@ -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"},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Ближайший"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user