Files
ledgrab-haos-integration/custom_components/ledgrab/number.py
T
alexei.dolgolyov a666d9eb9c feat: server telemetry, update entity, sync-clock controls
- Server device exposing CPU/RAM/GPU/temperature/battery sensors via
  /api/v1/system/performance, plus last-restart timestamp (cached with
  jitter threshold so the recorder doesn't see poll wobble) and version.
- Update entity backed by /api/v1/system/update — installs via
  /apply, hides the install button when the server reports
  can_auto_update=false.
- Sync-clock entities: reset button, speed number, running switch, and
  the event listener now refreshes on entity_changed events too.
- Bump manifest to 0.4.0.
2026-04-27 01:35:42 +03:00

363 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Number platform for LED Screen Controller (device brightness & HA light settings)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_HA_LIGHT
from .coordinator import LedGrabCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller number entities."""
data = hass.data[DOMAIN][entry.entry_id]
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))
for clock in coordinator.data.get("sync_clocks") or []:
entities.append(SyncClockSpeed(coordinator, clock["id"], entry.entry_id))
if coordinator.data and "targets" in coordinator.data:
devices = coordinator.data.get("devices") or {}
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
target_type = info.get("target_type", "led")
if target_type == TARGET_TYPE_HA_LIGHT:
# HA Light target — expose tunable settings
entities.append(HALightUpdateRate(coordinator, target_id, entry.entry_id))
entities.append(HALightTransition(coordinator, target_id, entry.entry_id))
entities.append(HALightMinBrightness(coordinator, target_id, entry.entry_id))
entities.append(HALightColorTolerance(coordinator, target_id, entry.entry_id))
continue
# LED target — brightness lives on the device
device_id = info.get("device_id", "")
if not device_id:
continue
device_data = devices.get(device_id)
if not device_data:
continue
capabilities = device_data.get("info", {}).get("capabilities") or []
if "brightness_control" not in capabilities or "static_color" in capabilities:
continue
entities.append(
LedGrabBrightness(
coordinator,
target_id,
device_id,
entry.entry_id,
)
)
async_add_entities(entities)
class LedGrabBrightness(CoordinatorEntity, NumberEntity):
"""Brightness control for an LED device associated with a target."""
_attr_has_entity_name = True
_attr_native_min_value = 0
_attr_native_max_value = 255
_attr_native_step = 1
_attr_mode = NumberMode.SLIDER
_attr_icon = "mdi:brightness-6"
def __init__(
self,
coordinator: LedGrabCoordinator,
target_id: str,
device_id: str,
entry_id: str,
) -> None:
"""Initialize the brightness number."""
super().__init__(coordinator)
self._target_id = target_id
self._device_id = device_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_brightness"
self._attr_translation_key = "brightness"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> float | None:
"""Return the current brightness value."""
if not self.coordinator.data:
return None
device_data = self.coordinator.data.get("devices", {}).get(self._device_id)
if not device_data:
return None
return device_data.get("brightness")
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.data:
return False
targets = self.coordinator.data.get("targets", {})
devices = self.coordinator.data.get("devices", {})
return self._target_id in targets and self._device_id in devices
async def async_set_native_value(self, value: float) -> None:
"""Set brightness value."""
await self.coordinator.set_brightness(self._device_id, int(value))
# --- HA Light target number entities ---
class _HALightNumberBase(CoordinatorEntity, NumberEntity):
"""Base class for HA Light target number entities."""
_attr_has_entity_name = True
_attr_mode = NumberMode.SLIDER
def __init__(
self,
coordinator: LedGrabCoordinator,
target_id: str,
entry_id: str,
*,
field_name: str,
) -> None:
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._field_name = field_name
@property
def device_info(self) -> dict[str, Any]:
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> float | None:
target_data = self._get_target_data()
if not target_data:
return None
return target_data.get("info", {}).get(self._field_name)
@property
def available(self) -> bool:
return self._get_target_data() is not None
async def async_set_native_value(self, value: float) -> None:
await self.coordinator.update_target(self._target_id, **{self._field_name: round(value, 2)})
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
class HALightUpdateRate(_HALightNumberBase):
"""Update rate (Hz) for an HA Light target."""
_attr_native_min_value = 0.5
_attr_native_max_value = 5.0
_attr_native_step = 0.5
_attr_native_unit_of_measurement = "Hz"
_attr_icon = "mdi:update"
def __init__(
self, coordinator: LedGrabCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="update_rate")
self._attr_unique_id = f"{target_id}_update_rate"
self._attr_translation_key = "ha_light_update_rate"
class HALightTransition(_HALightNumberBase):
"""Transition time (seconds) for an HA Light target."""
_attr_native_min_value = 0.0
_attr_native_max_value = 10.0
_attr_native_step = 0.1
_attr_native_unit_of_measurement = "s"
_attr_icon = "mdi:transition-masked"
def __init__(
self, coordinator: LedGrabCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="transition")
self._attr_unique_id = f"{target_id}_transition"
self._attr_translation_key = "ha_light_transition"
class HALightMinBrightness(_HALightNumberBase):
"""Minimum brightness threshold for an HA Light target."""
_attr_native_min_value = 0
_attr_native_max_value = 255
_attr_native_step = 1
_attr_icon = "mdi:brightness-4"
def __init__(
self, coordinator: LedGrabCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="min_brightness_threshold")
self._attr_unique_id = f"{target_id}_min_brightness"
self._attr_translation_key = "ha_light_min_brightness"
class HALightColorTolerance(_HALightNumberBase):
"""Color tolerance (RGB delta skip threshold) for an HA Light target."""
_attr_native_min_value = 0
_attr_native_max_value = 50
_attr_native_step = 1
_attr_icon = "mdi:palette-outline"
def __init__(
self, coordinator: LedGrabCoordinator, target_id: str, entry_id: str
) -> None:
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
# --- Sync clock number entities ---
class SyncClockSpeed(CoordinatorEntity, NumberEntity):
"""Speed multiplier for a sync clock (server range 0.110.0).
Hot-applied — running animations linked to the clock change pace
immediately without resetting their position.
"""
_attr_has_entity_name = True
_attr_native_min_value = 0.1
_attr_native_max_value = 10.0
_attr_native_step = 0.1
_attr_mode = NumberMode.SLIDER
_attr_icon = "mdi:speedometer"
def __init__(
self,
coordinator: LedGrabCoordinator,
clock_id: str,
entry_id: str,
) -> None:
super().__init__(coordinator)
self._clock_id = clock_id
self._entry_id = entry_id
self._attr_unique_id = f"{clock_id}_speed"
self._attr_translation_key = "sync_clock_speed"
@property
def device_info(self) -> dict[str, Any]:
return {"identifiers": {(DOMAIN, self._clock_id)}}
@property
def native_value(self) -> float | None:
clock = self._get_clock()
if not clock:
return None
speed = clock.get("speed")
return float(speed) if speed is not None else None
@property
def available(self) -> bool:
return self._get_clock() is not None
async def async_set_native_value(self, value: float) -> None:
await self.coordinator.update_sync_clock(self._clock_id, speed=round(value, 2))
def _get_clock(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
for clock in self.coordinator.data.get("sync_clocks", []):
if clock.get("id") == self._clock_id:
return clock
return None