feat: nachgezogen, was ha-ninlight laengst hat
Michaels Befund: die Integration ist nicht dasselbe wie die von ninlight. Verglichen und die Luecken geschlossen - alle Felder gibt es in der Firmware schon, sie waren nur nicht bedienbar: * number: fade_in, fade_out, transition_default in Millisekunden, mit den Grenzen der Firmware (10 s, 10 s, 60 s). * select: Einschaltverhalten (last, on, off). * switch: bei Aenderung einschalten. * light: Uebergangszeit beim Ein- und Ausschalten. HA rechnet in Sekunden, das Geraet in Millisekunden; laengeres als 60 s wird gekappt, sonst wiese das Geraet die ganze Anfrage mit 400 ab. Ohne Angabe wird kein Feld geschickt, sonst ueberschriebe jeder Schaltbefehl die Vorgabe. * sensor: letzter Update-Fehler und Startzeit (Zeitstempel statt einer Laufzeit, die jede Abfrage weiterlaeuft), beide ab Werk aus. * diagnostics.py: Diagnosedaten zum Herunterladen, ohne Token, Adressen, MAC und SSID. * config_flow: Neu-Konfigurieren von Adresse und Token, mit Gegenprobe der MAC - ein Tippfehler in der Adresse soll nicht still den falschen Eintrag umbiegen. * Hinweis in den Reparaturen, wenn ein Geraet noch auf dem oeffentlichen Werks-Token steht. Nicht uebernommen: die Auswahl sacn_layout. ninstrip ist immer rgb, ein Strip hat keinen Masterkanal - eine Auswahl mit einer Option waere nur Zierde. 241 Tests, 97 %. Die Kappung der Uebergangszeit per Mutation gegengeprueft.
This commit is contained in:
@@ -46,10 +46,13 @@ Ohne mDNS geht es auch von Hand über Adresse und Token.
|
|||||||
|
|
||||||
| Entität | |
|
| Entität | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `light` | Hauptschalter, Helligkeit, Farbe und Farbtemperatur des ganzen Strips |
|
| `light` | Hauptschalter, Helligkeit, Farbe, Farbtemperatur, Übergangszeit |
|
||||||
| `select` | aufgeschaltete Szene (acht Plätze in der Firmware) |
|
| `select` | aufgeschaltete Szene (acht Plätze in der Firmware) |
|
||||||
|
| `select` | Einschaltverhalten (`last` / `on` / `off`) |
|
||||||
|
| `number` | Einblend-, Ausblend- und Übergangszeit in ms |
|
||||||
|
| `switch` | bei Änderung einschalten |
|
||||||
| `button` | Neustart |
|
| `button` | Neustart |
|
||||||
| `sensor` | IP-Adresse, WLAN-Signal, Laufzeit, freier Speicher, Leistungsbegrenzung |
|
| `sensor` | IP-Adresse, WLAN-Signal, Laufzeit, freier Speicher, Leistungsbegrenzung, letzter Update-Fehler, Startzeit |
|
||||||
| `switch` | sACN-Empfang an/aus |
|
| `switch` | sACN-Empfang an/aus |
|
||||||
| `number` | sACN-Universe und -Startslot |
|
| `number` | sACN-Universe und -Startslot |
|
||||||
| `select` | Verhalten nach Stromende (`release` / `hold`) |
|
| `select` | Verhalten nach Stromende (`release` / `hold`) |
|
||||||
@@ -77,9 +80,28 @@ Weißkanäle bekommen dieselbe Spanne als RGB-Näherung.
|
|||||||
|
|
||||||
Für einzelne Segmente gibt es noch keine Entitäten.
|
Für einzelne Segmente gibt es noch keine Entitäten.
|
||||||
|
|
||||||
|
## Unterschiede zu ha-ninlight
|
||||||
|
|
||||||
|
Gleich sind: Einrichtung samt Neu-Konfigurieren und erneuter Anmeldung,
|
||||||
|
Diagnosedaten, Firmware-Aktualisierung, sACN, die Zeiten, das
|
||||||
|
Einschaltverhalten, „bei Änderung einschalten" und der Hinweis, wenn ein Gerät
|
||||||
|
noch auf dem Werks-Token steht.
|
||||||
|
|
||||||
|
Verschieden ist, was die Geräte verschieden macht:
|
||||||
|
|
||||||
|
- **Kein `sacn_layout`.** ninlight kann `dimmer` (Masterkanal plus Farben) und
|
||||||
|
`rgb`; ein Strip hat keinen Masterkanal, ninstrip ist immer `rgb`. Die
|
||||||
|
Firmware meldet das Feld trotzdem, damit dieselben Werkzeuge beide bedienen.
|
||||||
|
- **Farbe kommt aus den Segmenten**, bei ninlight aus `device/state`.
|
||||||
|
- **Szenen statt Farbmodi je Lampe**: acht Plätze in der Firmware, als
|
||||||
|
Auswahl.
|
||||||
|
- **Leistungsbegrenzung als Sensor** — bei einer Lampe gibt es nichts zu
|
||||||
|
begrenzen.
|
||||||
|
|
||||||
## Noch nicht drin
|
## Noch nicht drin
|
||||||
|
|
||||||
- Einzelne Segmente als eigene Entitäten
|
- Einzelne Segmente als eigene Entitäten
|
||||||
|
- Effekte (die Firmware kann sie je Segment, `device/effects` listet sie)
|
||||||
|
|
||||||
## Firmware-Aktualisierung
|
## Firmware-Aktualisierung
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.const import CONF_HOST, CONF_TOKEN, Platform
|
from homeassistant.const import CONF_HOST, CONF_TOKEN, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers import issue_registry as ir
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .api import NinstripClient
|
from .api import NinstripClient
|
||||||
from .const import DEFAULT_PORT, DOMAIN
|
from .const import DEFAULT_PORT, DEFAULT_TOKEN, DOMAIN
|
||||||
from .coordinator import NinstripConfigEntry, NinstripCoordinator
|
from .coordinator import NinstripConfigEntry, NinstripCoordinator
|
||||||
from .shared import async_get_shared, async_start_catalog, async_stop_catalog
|
from .shared import async_get_shared, async_start_catalog, async_stop_catalog
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinstripConfigEntry) ->
|
|||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
entry.runtime_data = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
_async_werks_token_hinweis(hass, entry)
|
||||||
await async_start_catalog(hass, async_get_shared(hass))
|
await async_start_catalog(hass, async_get_shared(hass))
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
return True
|
return True
|
||||||
@@ -66,6 +67,30 @@ async def async_unload_entry(hass: HomeAssistant, entry: NinstripConfigEntry) ->
|
|||||||
|
|
||||||
async def async_remove_entry(hass: HomeAssistant, entry: NinstripConfigEntry) -> None:
|
async def async_remove_entry(hass: HomeAssistant, entry: NinstripConfigEntry) -> None:
|
||||||
ir.async_delete_issue(hass, DOMAIN, f"wifi_guard_{entry.entry_id}")
|
ir.async_delete_issue(hass, DOMAIN, f"wifi_guard_{entry.entry_id}")
|
||||||
|
ir.async_delete_issue(hass, DOMAIN, f"default_token_{entry.entry_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_werks_token_hinweis(hass: HomeAssistant, entry: NinstripConfigEntry) -> None:
|
||||||
|
"""Weist darauf hin, dass ein Geraet noch auf dem oeffentlichen Token steht.
|
||||||
|
|
||||||
|
Das Werks-Token steht im Repo. Wer es kennt und im selben Netz ist, kann
|
||||||
|
das Geraet umkonfigurieren, neu starten, flashen und zuruecksetzen. Wie in
|
||||||
|
ha-ninlight.
|
||||||
|
"""
|
||||||
|
issue_id = f"default_token_{entry.entry_id}"
|
||||||
|
if entry.data[CONF_TOKEN] == DEFAULT_TOKEN:
|
||||||
|
ir.async_create_issue(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
issue_id,
|
||||||
|
is_fixable=False,
|
||||||
|
severity=ir.IssueSeverity.WARNING,
|
||||||
|
translation_key="default_token",
|
||||||
|
translation_placeholders={"name": entry.title},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||||
|
|
||||||
|
|
||||||
# Kein add_update_listener zum Neuladen: der Einstellungsdialog ist ein
|
# Kein add_update_listener zum Neuladen: der Einstellungsdialog ist ein
|
||||||
|
|||||||
@@ -125,6 +125,37 @@ class NinstripConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_reconfigure(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Adresse und Token eines bestehenden Geraets aendern.
|
||||||
|
|
||||||
|
Gegengeprueft wird die MAC: wer aus Versehen die Adresse eines anderen
|
||||||
|
Geraets eintraegt, soll nicht stillschweigend den falschen Eintrag
|
||||||
|
umbiegen.
|
||||||
|
"""
|
||||||
|
eintrag = self._get_reconfigure_entry()
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
host = user_input[CONF_HOST].strip()
|
||||||
|
info, errors = await self._async_probe(host, user_input[CONF_TOKEN])
|
||||||
|
if info is not None:
|
||||||
|
await self.async_set_unique_id(format_mac(info["mac"]))
|
||||||
|
self._abort_if_unique_id_mismatch(reason="wrong_device")
|
||||||
|
return self.async_update_reload_and_abort(
|
||||||
|
eintrag, data_updates={CONF_HOST: host, CONF_TOKEN: user_input[CONF_TOKEN]}
|
||||||
|
)
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reconfigure",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_HOST, default=eintrag.data[CONF_HOST]): str,
|
||||||
|
vol.Required(CONF_TOKEN, default=eintrag.data[CONF_TOKEN]): str,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowResult:
|
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowResult:
|
||||||
self._host = entry_data[CONF_HOST]
|
self._host = entry_data[CONF_HOST]
|
||||||
return await self.async_step_reauth_confirm()
|
return await self.async_step_reauth_confirm()
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ MAX_SCAN_INTERVAL: Final = 300
|
|||||||
# sofort alle Entitaeten ausgrauen.
|
# sofort alle Entitaeten ausgrauen.
|
||||||
MAX_POLL_FAILURES: Final = 3
|
MAX_POLL_FAILURES: Final = 3
|
||||||
|
|
||||||
|
# Laengster Uebergang, den die Firmware annimmt (nl_api_device.c:
|
||||||
|
# "transition ausserhalb 0..60000 ms").
|
||||||
|
MAX_TRANSITION_MS: Final = 60_000
|
||||||
|
# Die drei Millisekunden-Felder in device/state, mit den Grenzen der Firmware.
|
||||||
|
MAX_FADE_MS: Final = 10_000
|
||||||
|
|
||||||
# Die Firmware erlaubt hoechstens 16 Segmente (nl_segments.h) und 512 Pixel.
|
# Die Firmware erlaubt hoechstens 16 Segmente (nl_segments.h) und 512 Pixel.
|
||||||
MAX_SEGMENTS: Final = 16
|
MAX_SEGMENTS: Final = 16
|
||||||
MAX_PIXELS: Final = 512
|
MAX_PIXELS: Final = 512
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Diagnosedaten zum Herunterladen, fuer Fehlermeldungen."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.diagnostics import async_redact_data
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_TOKEN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .coordinator import NinstripConfigEntry
|
||||||
|
from .shared import DATA_SHARED
|
||||||
|
|
||||||
|
# Token, Adressen und Kennungen fliegen raus: so eine Datei landet schnell in
|
||||||
|
# einem Fehlerbericht.
|
||||||
|
TO_REDACT = {CONF_TOKEN, CONF_HOST, "ssid", "mac", "ip"}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_config_entry_diagnostics(
|
||||||
|
hass: HomeAssistant, entry: NinstripConfigEntry
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
device_type = coordinator.data.info.get("device_type")
|
||||||
|
shared = hass.data.get(DATA_SHARED)
|
||||||
|
abbilder = (
|
||||||
|
[]
|
||||||
|
if shared is None
|
||||||
|
else [
|
||||||
|
{"file": image.path.name, "device_type": image.device_type, "version": image.version}
|
||||||
|
for image in shared.catalog.images
|
||||||
|
if image.device_type == device_type
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**async_redact_data(
|
||||||
|
{
|
||||||
|
"entry": {"data": dict(entry.data), "options": dict(entry.options)},
|
||||||
|
"state": coordinator.data.state,
|
||||||
|
"info": coordinator.data.info,
|
||||||
|
"segments": coordinator.data.segments,
|
||||||
|
"settings": coordinator.data.settings,
|
||||||
|
},
|
||||||
|
TO_REDACT,
|
||||||
|
),
|
||||||
|
"firmware_images": abbilder,
|
||||||
|
}
|
||||||
@@ -15,12 +15,15 @@ from homeassistant.components.light import (
|
|||||||
ATTR_BRIGHTNESS,
|
ATTR_BRIGHTNESS,
|
||||||
ATTR_COLOR_TEMP_KELVIN,
|
ATTR_COLOR_TEMP_KELVIN,
|
||||||
ATTR_RGB_COLOR,
|
ATTR_RGB_COLOR,
|
||||||
|
ATTR_TRANSITION,
|
||||||
ColorMode,
|
ColorMode,
|
||||||
LightEntity,
|
LightEntity,
|
||||||
|
LightEntityFeature,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from .const import MAX_TRANSITION_MS
|
||||||
from .coordinator import NinstripConfigEntry, NinstripCoordinator
|
from .coordinator import NinstripConfigEntry, NinstripCoordinator
|
||||||
from .entity import NinstripEntity
|
from .entity import NinstripEntity
|
||||||
|
|
||||||
@@ -31,6 +34,15 @@ MIN_KELVIN = 2700
|
|||||||
MAX_KELVIN = 6500
|
MAX_KELVIN = 6500
|
||||||
|
|
||||||
|
|
||||||
|
def uebergang_ms(sekunden: float) -> int:
|
||||||
|
"""Home Assistant rechnet in Sekunden, das Geraet in Millisekunden.
|
||||||
|
|
||||||
|
Gekappt auf das, was die Firmware annimmt - ein laengerer Wert wuerde
|
||||||
|
sonst die ganze Anfrage mit 400 abweisen, statt nur langsamer zu blenden.
|
||||||
|
"""
|
||||||
|
return max(0, min(MAX_TRANSITION_MS, round(sekunden * 1000)))
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: NinstripConfigEntry,
|
entry: NinstripConfigEntry,
|
||||||
@@ -44,6 +56,7 @@ class NinstripLight(NinstripEntity, LightEntity):
|
|||||||
|
|
||||||
_attr_name = None
|
_attr_name = None
|
||||||
_attr_supported_color_modes = {ColorMode.RGB, ColorMode.COLOR_TEMP}
|
_attr_supported_color_modes = {ColorMode.RGB, ColorMode.COLOR_TEMP}
|
||||||
|
_attr_supported_features = LightEntityFeature.TRANSITION
|
||||||
_attr_min_color_temp_kelvin = MIN_KELVIN
|
_attr_min_color_temp_kelvin = MIN_KELVIN
|
||||||
_attr_max_color_temp_kelvin = MAX_KELVIN
|
_attr_max_color_temp_kelvin = MAX_KELVIN
|
||||||
|
|
||||||
@@ -131,7 +144,12 @@ class NinstripLight(NinstripEntity, LightEntity):
|
|||||||
fields: dict[str, Any] = {"on": True}
|
fields: dict[str, Any] = {"on": True}
|
||||||
if ATTR_BRIGHTNESS in kwargs:
|
if ATTR_BRIGHTNESS in kwargs:
|
||||||
fields["brightness"] = round(int(kwargs[ATTR_BRIGHTNESS]) * 100 / 255)
|
fields["brightness"] = round(int(kwargs[ATTR_BRIGHTNESS]) * 100 / 255)
|
||||||
|
if ATTR_TRANSITION in kwargs:
|
||||||
|
fields["transition"] = uebergang_ms(kwargs[ATTR_TRANSITION])
|
||||||
await self.coordinator.async_set_state(**fields)
|
await self.coordinator.async_set_state(**fields)
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
await self.coordinator.async_set_state(on=False)
|
fields: dict[str, Any] = {"on": False}
|
||||||
|
if ATTR_TRANSITION in kwargs:
|
||||||
|
fields["transition"] = uebergang_ms(kwargs[ATTR_TRANSITION])
|
||||||
|
await self.coordinator.async_set_state(**fields)
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
"""sACN-Zahlenfelder eines ninstrip-Geraets: Universe und Startslot."""
|
"""Zahlenfelder eines ninstrip-Geraets: Uebergangszeiten und sACN."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components.number import NumberEntity, NumberMode
|
from dataclasses import dataclass
|
||||||
from homeassistant.const import EntityCategory
|
|
||||||
|
from homeassistant.components.number import (
|
||||||
|
NumberDeviceClass,
|
||||||
|
NumberEntity,
|
||||||
|
NumberEntityDescription,
|
||||||
|
NumberMode,
|
||||||
|
)
|
||||||
|
from homeassistant.const import EntityCategory, UnitOfTime
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from .const import MAX_FADE_MS, MAX_TRANSITION_MS
|
||||||
from .coordinator import NinstripConfigEntry, NinstripCoordinator
|
from .coordinator import NinstripConfigEntry, NinstripCoordinator
|
||||||
from .entity import NinstripSacnEntity
|
from .entity import NinstripEntity, NinstripSacnEntity
|
||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
@@ -17,13 +25,62 @@ PARALLEL_UPDATES = 0
|
|||||||
_KANAELE_VORGABE = 3
|
_KANAELE_VORGABE = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class NinstripZeitDescription(NumberEntityDescription):
|
||||||
|
"""Ein Millisekundenfeld aus device/state."""
|
||||||
|
|
||||||
|
entity_category: EntityCategory = EntityCategory.CONFIG
|
||||||
|
device_class: NumberDeviceClass = NumberDeviceClass.DURATION
|
||||||
|
native_unit_of_measurement: str = UnitOfTime.MILLISECONDS
|
||||||
|
native_min_value: float = 0
|
||||||
|
native_step: float = 50
|
||||||
|
mode: NumberMode = NumberMode.BOX
|
||||||
|
|
||||||
|
|
||||||
|
# Grenzen wie in der Firmware (nl_api_device.c). Feldnamen wie in ninlight.
|
||||||
|
ZEITEN = (
|
||||||
|
NinstripZeitDescription(key="fade_in", translation_key="fade_in", native_max_value=MAX_FADE_MS),
|
||||||
|
NinstripZeitDescription(
|
||||||
|
key="fade_out", translation_key="fade_out", native_max_value=MAX_FADE_MS
|
||||||
|
),
|
||||||
|
NinstripZeitDescription(
|
||||||
|
key="transition_default",
|
||||||
|
translation_key="transition_default",
|
||||||
|
native_max_value=MAX_TRANSITION_MS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: NinstripConfigEntry,
|
entry: NinstripConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
async_add_entities([NinstripSacnUniverse(coordinator), NinstripSacnStartSlot(coordinator)])
|
entitaeten: list[NumberEntity] = [NinstripZeit(coordinator, d) for d in ZEITEN]
|
||||||
|
entitaeten.append(NinstripSacnUniverse(coordinator))
|
||||||
|
entitaeten.append(NinstripSacnStartSlot(coordinator))
|
||||||
|
async_add_entities(entitaeten)
|
||||||
|
|
||||||
|
|
||||||
|
class NinstripZeit(NinstripEntity, NumberEntity):
|
||||||
|
"""Ein Millisekundenfeld: Einblenden, Ausblenden, Vorgabe fuer Uebergaenge."""
|
||||||
|
|
||||||
|
entity_description: NinstripZeitDescription
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, coordinator: NinstripCoordinator, description: NinstripZeitDescription
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, description.key)
|
||||||
|
self.entity_description = description
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> float | None:
|
||||||
|
wert = self.coordinator.data.state.get(self.entity_description.key)
|
||||||
|
return float(wert) if isinstance(wert, (int, float)) else None
|
||||||
|
|
||||||
|
async def async_set_native_value(self, value: float) -> None:
|
||||||
|
await self.coordinator.async_set_state(**{self.entity_description.key: int(value)})
|
||||||
|
|
||||||
|
|
||||||
class NinstripSacnUniverse(NinstripSacnEntity, NumberEntity):
|
class NinstripSacnUniverse(NinstripSacnEntity, NumberEntity):
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ NONE_OPTION = "—"
|
|||||||
# Was das Geraet 2,5 s nach dem letzten sACN-Paket tut. Werte wie ha-ninlight.
|
# Was das Geraet 2,5 s nach dem letzten sACN-Paket tut. Werte wie ha-ninlight.
|
||||||
ON_TIMEOUT_OPTIONS = ["release", "hold"]
|
ON_TIMEOUT_OPTIONS = ["release", "hold"]
|
||||||
|
|
||||||
|
# Was nach einer Stromunterbrechung passiert (nl_settings.c: last, on, off).
|
||||||
|
POWER_ON_OPTIONS = ["last", "on", "off"]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -25,10 +28,33 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[NinstripPresetSelect(coordinator), NinstripSacnOnTimeoutSelect(coordinator)]
|
[
|
||||||
|
NinstripPresetSelect(coordinator),
|
||||||
|
NinstripPowerOnSelect(coordinator),
|
||||||
|
NinstripSacnOnTimeoutSelect(coordinator),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NinstripPowerOnSelect(NinstripEntity, SelectEntity):
|
||||||
|
"""Was der Strip tut, wenn der Strom wiederkommt."""
|
||||||
|
|
||||||
|
_attr_entity_category = EntityCategory.CONFIG
|
||||||
|
_attr_translation_key = "power_on_behaviour"
|
||||||
|
_attr_options = POWER_ON_OPTIONS
|
||||||
|
|
||||||
|
def __init__(self, coordinator: NinstripCoordinator) -> None:
|
||||||
|
super().__init__(coordinator, "power_on_behaviour")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_option(self) -> str | None:
|
||||||
|
wert = self.coordinator.data.state.get("power_on_behaviour")
|
||||||
|
return wert if wert in POWER_ON_OPTIONS else None
|
||||||
|
|
||||||
|
async def async_select_option(self, option: str) -> None:
|
||||||
|
await self.coordinator.async_set_state(power_on_behaviour=option)
|
||||||
|
|
||||||
|
|
||||||
class NinstripPresetSelect(NinstripEntity, SelectEntity):
|
class NinstripPresetSelect(NinstripEntity, SelectEntity):
|
||||||
"""Waehlt eine der belegten Szenen aus."""
|
"""Waehlt eine der belegten Szenen aus."""
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
@@ -18,12 +19,18 @@ from homeassistant.const import (
|
|||||||
UnitOfInformation,
|
UnitOfInformation,
|
||||||
UnitOfTime,
|
UnitOfTime,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .coordinator import NinstripConfigEntry, NinstripCoordinator, NinstripData
|
from .coordinator import NinstripConfigEntry, NinstripCoordinator, NinstripData
|
||||||
from .entity import NinstripEntity
|
from .entity import NinstripEntity
|
||||||
|
|
||||||
|
# Die Laufzeit springt bei jeder Abfrage um ein paar Sekunden; daraus eine
|
||||||
|
# Startzeit zu rechnen ergaebe sonst dauernd einen neuen Zeitstempel. Wie
|
||||||
|
# ha-ninlight.
|
||||||
|
BOOT_TIME_TOLERANCE = timedelta(seconds=30)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class NinstripSensorDescription(SensorEntityDescription):
|
class NinstripSensorDescription(SensorEntityDescription):
|
||||||
@@ -91,6 +98,15 @@ SENSORS: tuple[NinstripSensorDescription, ...] = (
|
|||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
value=lambda d: _strip(d, "power_scale"),
|
value=lambda d: _strip(d, "power_scale"),
|
||||||
),
|
),
|
||||||
|
# Was bei der letzten Aktualisierung schiefging - leer, solange nichts
|
||||||
|
# schiefging. Aus der Ferne der einzige Hinweis darauf.
|
||||||
|
NinstripSensorDescription(
|
||||||
|
key="last_ota_error",
|
||||||
|
translation_key="last_ota_error",
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value=lambda d: d.info.get("last_ota_error"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,7 +116,9 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
async_add_entities(NinstripSensor(coordinator, d) for d in SENSORS)
|
entitaeten: list[SensorEntity] = [NinstripSensor(coordinator, d) for d in SENSORS]
|
||||||
|
entitaeten.append(NinstripBootTimeSensor(coordinator))
|
||||||
|
async_add_entities(entitaeten)
|
||||||
|
|
||||||
|
|
||||||
class NinstripSensor(NinstripEntity, SensorEntity):
|
class NinstripSensor(NinstripEntity, SensorEntity):
|
||||||
@@ -115,3 +133,37 @@ class NinstripSensor(NinstripEntity, SensorEntity):
|
|||||||
@property
|
@property
|
||||||
def native_value(self) -> Any:
|
def native_value(self) -> Any:
|
||||||
return self.entity_description.value(self.coordinator.data)
|
return self.entity_description.value(self.coordinator.data)
|
||||||
|
|
||||||
|
|
||||||
|
class NinstripBootTimeSensor(NinstripEntity, SensorEntity):
|
||||||
|
"""Wann der Strip zuletzt gestartet ist - ein Zeitstempel statt einer
|
||||||
|
Laufzeit, die jede Abfrage weiterlaeuft."""
|
||||||
|
|
||||||
|
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||||
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
_attr_entity_registry_enabled_default = False
|
||||||
|
_attr_translation_key = "boot_time"
|
||||||
|
|
||||||
|
def __init__(self, coordinator: NinstripCoordinator) -> None:
|
||||||
|
super().__init__(coordinator, "boot_time")
|
||||||
|
self._boot: datetime | None = None
|
||||||
|
self._boot_aktualisieren()
|
||||||
|
|
||||||
|
def _boot_aktualisieren(self) -> None:
|
||||||
|
uptime = self.coordinator.data.info.get("uptime")
|
||||||
|
if not isinstance(uptime, (int, float)):
|
||||||
|
return
|
||||||
|
boot = dt_util.utcnow() - timedelta(seconds=uptime)
|
||||||
|
# Nur uebernehmen, wenn er wirklich abweicht - sonst wandert der
|
||||||
|
# Zeitstempel bei jeder Abfrage um die Messungenauigkeit.
|
||||||
|
if self._boot is None or abs(boot - self._boot) > BOOT_TIME_TOLERANCE:
|
||||||
|
self._boot = boot
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
self._boot_aktualisieren()
|
||||||
|
super()._handle_coordinator_update()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> datetime | None:
|
||||||
|
return self._boot
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"token": "API-Token"
|
"token": "API-Token"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Gerät ändern",
|
||||||
|
"description": "Adresse und API-Token des Geräts {host}. Die MAC wird gegengeprüft — ein anderes Gerät wird abgelehnt.",
|
||||||
|
"data": {
|
||||||
|
"host": "Adresse",
|
||||||
|
"token": "API-Token"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -32,7 +40,9 @@
|
|||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Dieses Gerät ist bereits eingerichtet",
|
"already_configured": "Dieses Gerät ist bereits eingerichtet",
|
||||||
"reauth_successful": "Token erneuert"
|
"reauth_successful": "Token erneuert",
|
||||||
|
"wrong_device": "Dort antwortet ein anderes Gerät",
|
||||||
|
"reconfigure_successful": "Gerät geändert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -64,6 +74,14 @@
|
|||||||
"release": "Zurück zum vorherigen Bild",
|
"release": "Zurück zum vorherigen Bild",
|
||||||
"hold": "Letztes Bild halten"
|
"hold": "Letztes Bild halten"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"power_on_behaviour": {
|
||||||
|
"name": "Einschaltverhalten",
|
||||||
|
"state": {
|
||||||
|
"last": "Wie zuvor",
|
||||||
|
"on": "An",
|
||||||
|
"off": "Aus"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
@@ -86,11 +104,20 @@
|
|||||||
},
|
},
|
||||||
"power_scale": {
|
"power_scale": {
|
||||||
"name": "Leistungsbegrenzung"
|
"name": "Leistungsbegrenzung"
|
||||||
|
},
|
||||||
|
"last_ota_error": {
|
||||||
|
"name": "Letzter Update-Fehler"
|
||||||
|
},
|
||||||
|
"boot_time": {
|
||||||
|
"name": "Gestartet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
"sacn_enabled": {
|
"sacn_enabled": {
|
||||||
"name": "sACN-Empfang"
|
"name": "sACN-Empfang"
|
||||||
|
},
|
||||||
|
"wake_on_change": {
|
||||||
|
"name": "Bei Änderung einschalten"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"number": {
|
"number": {
|
||||||
@@ -99,6 +126,15 @@
|
|||||||
},
|
},
|
||||||
"sacn_start_slot": {
|
"sacn_start_slot": {
|
||||||
"name": "sACN-Startslot"
|
"name": "sACN-Startslot"
|
||||||
|
},
|
||||||
|
"fade_in": {
|
||||||
|
"name": "Einblendzeit"
|
||||||
|
},
|
||||||
|
"fade_out": {
|
||||||
|
"name": "Ausblendzeit"
|
||||||
|
},
|
||||||
|
"transition_default": {
|
||||||
|
"name": "Übergangszeit (Vorgabe)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
@@ -157,6 +193,10 @@
|
|||||||
"wifi_guard": {
|
"wifi_guard": {
|
||||||
"title": "Firmware für {name} wird nicht angeboten",
|
"title": "Firmware für {name} wird nicht angeboten",
|
||||||
"description": "Im Firmware-Ordner liegt {version} für {device_type}, aber ohne WLAN-Zugangsdaten für {ssid}. Das Gerät kennt sein WLAN nur aus der Firmware und verlöre nach dieser Aktualisierung seine Netzverbindung. Deshalb wird sie nicht angeboten. Wer das Gerät stattdessen über das Onboarding ins WLAN gebracht hat, bekommt das Update ohne diesen Vorbehalt."
|
"description": "Im Firmware-Ordner liegt {version} für {device_type}, aber ohne WLAN-Zugangsdaten für {ssid}. Das Gerät kennt sein WLAN nur aus der Firmware und verlöre nach dieser Aktualisierung seine Netzverbindung. Deshalb wird sie nicht angeboten. Wer das Gerät stattdessen über das Onboarding ins WLAN gebracht hat, bekommt das Update ohne diesen Vorbehalt."
|
||||||
|
},
|
||||||
|
"default_token": {
|
||||||
|
"title": "{name} nutzt das öffentliche Werks-Token",
|
||||||
|
"description": "Mit dem öffentlichen Werks-Token kann jeder im selben Netz dieses Gerät umkonfigurieren, neu starten, neu flashen oder auf Werk zurücksetzen — dabei verliert es seine WLAN-Daten und fällt in den Onboarding-AP zurück. Schalten und Dimmen steht ohnehin jedem im Netz offen, solange `auth_all` aus ist. Setze am Gerät ein eigenes Token und trage es über „Neu konfigurieren\" ein."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""sACN-Schalter eines ninstrip-Geraets."""
|
"""Schalter eines ninstrip-Geraets: Aufwecken bei Aenderung und sACN."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from .coordinator import NinstripConfigEntry, NinstripCoordinator
|
from .coordinator import NinstripConfigEntry, NinstripCoordinator
|
||||||
from .entity import NinstripSacnEntity
|
from .entity import NinstripEntity, NinstripSacnEntity
|
||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
@@ -20,7 +20,30 @@ async def async_setup_entry(
|
|||||||
entry: NinstripConfigEntry,
|
entry: NinstripConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
async_add_entities([NinstripSacnSwitch(entry.runtime_data)])
|
async_add_entities(
|
||||||
|
[NinstripWakeSwitch(entry.runtime_data), NinstripSacnSwitch(entry.runtime_data)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NinstripWakeSwitch(NinstripEntity, SwitchEntity):
|
||||||
|
"""Schaltet den Strip ein, sobald Farbe, Farbtemperatur oder Helligkeit
|
||||||
|
geaendert werden - statt die Aenderung nur zu merken."""
|
||||||
|
|
||||||
|
_attr_entity_category = EntityCategory.CONFIG
|
||||||
|
_attr_translation_key = "wake_on_change"
|
||||||
|
|
||||||
|
def __init__(self, coordinator: NinstripCoordinator) -> None:
|
||||||
|
super().__init__(coordinator, "wake_on_change")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
return bool(self.coordinator.data.state.get("wake_on_change"))
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
await self.coordinator.async_set_state(wake_on_change=True)
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
await self.coordinator.async_set_state(wake_on_change=False)
|
||||||
|
|
||||||
|
|
||||||
class NinstripSacnSwitch(NinstripSacnEntity, SwitchEntity):
|
class NinstripSacnSwitch(NinstripSacnEntity, SwitchEntity):
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"token": "API-Token"
|
"token": "API-Token"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Gerät ändern",
|
||||||
|
"description": "Adresse und API-Token des Geräts {host}. Die MAC wird gegengeprüft — ein anderes Gerät wird abgelehnt.",
|
||||||
|
"data": {
|
||||||
|
"host": "Adresse",
|
||||||
|
"token": "API-Token"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -32,7 +40,9 @@
|
|||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Dieses Gerät ist bereits eingerichtet",
|
"already_configured": "Dieses Gerät ist bereits eingerichtet",
|
||||||
"reauth_successful": "Token erneuert"
|
"reauth_successful": "Token erneuert",
|
||||||
|
"wrong_device": "Dort antwortet ein anderes Gerät",
|
||||||
|
"reconfigure_successful": "Gerät geändert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -64,6 +74,14 @@
|
|||||||
"release": "Zurück zum vorherigen Bild",
|
"release": "Zurück zum vorherigen Bild",
|
||||||
"hold": "Letztes Bild halten"
|
"hold": "Letztes Bild halten"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"power_on_behaviour": {
|
||||||
|
"name": "Einschaltverhalten",
|
||||||
|
"state": {
|
||||||
|
"last": "Wie zuvor",
|
||||||
|
"on": "An",
|
||||||
|
"off": "Aus"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
@@ -86,11 +104,20 @@
|
|||||||
},
|
},
|
||||||
"power_scale": {
|
"power_scale": {
|
||||||
"name": "Leistungsbegrenzung"
|
"name": "Leistungsbegrenzung"
|
||||||
|
},
|
||||||
|
"last_ota_error": {
|
||||||
|
"name": "Letzter Update-Fehler"
|
||||||
|
},
|
||||||
|
"boot_time": {
|
||||||
|
"name": "Gestartet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
"sacn_enabled": {
|
"sacn_enabled": {
|
||||||
"name": "sACN-Empfang"
|
"name": "sACN-Empfang"
|
||||||
|
},
|
||||||
|
"wake_on_change": {
|
||||||
|
"name": "Bei Änderung einschalten"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"number": {
|
"number": {
|
||||||
@@ -99,6 +126,15 @@
|
|||||||
},
|
},
|
||||||
"sacn_start_slot": {
|
"sacn_start_slot": {
|
||||||
"name": "sACN-Startslot"
|
"name": "sACN-Startslot"
|
||||||
|
},
|
||||||
|
"fade_in": {
|
||||||
|
"name": "Einblendzeit"
|
||||||
|
},
|
||||||
|
"fade_out": {
|
||||||
|
"name": "Ausblendzeit"
|
||||||
|
},
|
||||||
|
"transition_default": {
|
||||||
|
"name": "Übergangszeit (Vorgabe)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
@@ -157,6 +193,10 @@
|
|||||||
"wifi_guard": {
|
"wifi_guard": {
|
||||||
"title": "Firmware für {name} wird nicht angeboten",
|
"title": "Firmware für {name} wird nicht angeboten",
|
||||||
"description": "Im Firmware-Ordner liegt {version} für {device_type}, aber ohne WLAN-Zugangsdaten für {ssid}. Das Gerät kennt sein WLAN nur aus der Firmware und verlöre nach dieser Aktualisierung seine Netzverbindung. Deshalb wird sie nicht angeboten. Wer das Gerät stattdessen über das Onboarding ins WLAN gebracht hat, bekommt das Update ohne diesen Vorbehalt."
|
"description": "Im Firmware-Ordner liegt {version} für {device_type}, aber ohne WLAN-Zugangsdaten für {ssid}. Das Gerät kennt sein WLAN nur aus der Firmware und verlöre nach dieser Aktualisierung seine Netzverbindung. Deshalb wird sie nicht angeboten. Wer das Gerät stattdessen über das Onboarding ins WLAN gebracht hat, bekommt das Update ohne diesen Vorbehalt."
|
||||||
|
},
|
||||||
|
"default_token": {
|
||||||
|
"title": "{name} nutzt das öffentliche Werks-Token",
|
||||||
|
"description": "Mit dem öffentlichen Werks-Token kann jeder im selben Netz dieses Gerät umkonfigurieren, neu starten, neu flashen oder auf Werk zurücksetzen — dabei verliert es seine WLAN-Daten und fällt in den Onboarding-AP zurück. Schalten und Dimmen steht ohnehin jedem im Netz offen, solange `auth_all` aus ist. Setze am Gerät ein eigenes Token und trage es über „Neu konfigurieren\" ein."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"token": "API token"
|
"token": "API token"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Change device",
|
||||||
|
"description": "Address and API token of device {host}. The MAC is checked — a different device is rejected.",
|
||||||
|
"data": {
|
||||||
|
"host": "Address",
|
||||||
|
"token": "API token"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -32,7 +40,9 @@
|
|||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "This device is already configured",
|
"already_configured": "This device is already configured",
|
||||||
"reauth_successful": "Token renewed"
|
"reauth_successful": "Token renewed",
|
||||||
|
"wrong_device": "A different device answers there",
|
||||||
|
"reconfigure_successful": "Device changed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -64,6 +74,14 @@
|
|||||||
"release": "Return to previous image",
|
"release": "Return to previous image",
|
||||||
"hold": "Hold last image"
|
"hold": "Hold last image"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"power_on_behaviour": {
|
||||||
|
"name": "Power-on behaviour",
|
||||||
|
"state": {
|
||||||
|
"last": "As before",
|
||||||
|
"on": "On",
|
||||||
|
"off": "Off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
@@ -86,11 +104,20 @@
|
|||||||
},
|
},
|
||||||
"power_scale": {
|
"power_scale": {
|
||||||
"name": "Power limiting"
|
"name": "Power limiting"
|
||||||
|
},
|
||||||
|
"last_ota_error": {
|
||||||
|
"name": "Last update error"
|
||||||
|
},
|
||||||
|
"boot_time": {
|
||||||
|
"name": "Started"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
"sacn_enabled": {
|
"sacn_enabled": {
|
||||||
"name": "sACN receiver"
|
"name": "sACN receiver"
|
||||||
|
},
|
||||||
|
"wake_on_change": {
|
||||||
|
"name": "Turn on when changed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"number": {
|
"number": {
|
||||||
@@ -99,6 +126,15 @@
|
|||||||
},
|
},
|
||||||
"sacn_start_slot": {
|
"sacn_start_slot": {
|
||||||
"name": "sACN start slot"
|
"name": "sACN start slot"
|
||||||
|
},
|
||||||
|
"fade_in": {
|
||||||
|
"name": "Fade-in time"
|
||||||
|
},
|
||||||
|
"fade_out": {
|
||||||
|
"name": "Fade-out time"
|
||||||
|
},
|
||||||
|
"transition_default": {
|
||||||
|
"name": "Default transition time"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
@@ -157,6 +193,10 @@
|
|||||||
"wifi_guard": {
|
"wifi_guard": {
|
||||||
"title": "Firmware for {name} not offered",
|
"title": "Firmware for {name} not offered",
|
||||||
"description": "The firmware folder contains {version} for {device_type}, but without Wi-Fi credentials for {ssid}. The device only knows its Wi-Fi from its firmware and would lose its network connection after this update, so the update is not offered. A device that was put on the network through onboarding instead gets the update without this reservation."
|
"description": "The firmware folder contains {version} for {device_type}, but without Wi-Fi credentials for {ssid}. The device only knows its Wi-Fi from its firmware and would lose its network connection after this update, so the update is not offered. A device that was put on the network through onboarding instead gets the update without this reservation."
|
||||||
|
},
|
||||||
|
"default_token": {
|
||||||
|
"title": "{name} uses the public default token",
|
||||||
|
"description": "With the public default token, anyone on your network can reconfigure, restart, reflash or factory-reset this device, losing its Wi-Fi settings and falling back to its onboarding access point. Switching and dimming are open to anyone on the network anyway while `auth_all` is off. Set your own token on the device and enter it via \"Reconfigure\"."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,3 +267,32 @@ async def test_einstellungen_weisen_unbrauchbare_adressen_ab(
|
|||||||
)
|
)
|
||||||
assert ergebnis["type"] is FlowResultType.FORM
|
assert ergebnis["type"] is FlowResultType.FORM
|
||||||
assert ergebnis["errors"] == {CONF_DEVICE_BASE_URL: fehler}
|
assert ergebnis["errors"] == {CONF_DEVICE_BASE_URL: fehler}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_neu_konfigurieren_aendert_adresse_und_token(
|
||||||
|
hass: HomeAssistant, mock_client: MagicMock
|
||||||
|
) -> None:
|
||||||
|
eintrag = bestehender_eintrag(hass)
|
||||||
|
ergebnis = await eintrag.start_reconfigure_flow(hass)
|
||||||
|
assert ergebnis["step_id"] == "reconfigure"
|
||||||
|
ergebnis = await hass.config_entries.flow.async_configure(
|
||||||
|
ergebnis["flow_id"], {CONF_HOST: " 192.168.2.99 ", CONF_TOKEN: NEUES_TOKEN}
|
||||||
|
)
|
||||||
|
assert ergebnis["type"] is FlowResultType.ABORT
|
||||||
|
assert ergebnis["reason"] == "reconfigure_successful"
|
||||||
|
assert eintrag.data == {CONF_HOST: "192.168.2.99", CONF_TOKEN: NEUES_TOKEN}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_neu_konfigurieren_lehnt_ein_anderes_geraet_ab(
|
||||||
|
hass: HomeAssistant, mock_client: MagicMock, strip_info: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Sonst biegt ein Tippfehler in der Adresse still den falschen Eintrag um."""
|
||||||
|
eintrag = bestehender_eintrag(hass)
|
||||||
|
strip_info["mac"] = "b0:81:84:49:ff:ff"
|
||||||
|
ergebnis = await eintrag.start_reconfigure_flow(hass)
|
||||||
|
ergebnis = await hass.config_entries.flow.async_configure(
|
||||||
|
ergebnis["flow_id"], {CONF_HOST: "192.168.2.99", CONF_TOKEN: TOKEN}
|
||||||
|
)
|
||||||
|
assert ergebnis["type"] is FlowResultType.ABORT
|
||||||
|
assert ergebnis["reason"] == "wrong_device"
|
||||||
|
assert eintrag.data[CONF_HOST] == HOST_K1Y
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Diagnosedaten - und was darin nicht stehen darf."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
|
from custom_components.ninstrip.diagnostics import async_get_config_entry_diagnostics
|
||||||
|
from custom_components.ninstrip.shared import DATA_SHARED, async_scan_catalog
|
||||||
|
from tests_unit.images import build_image
|
||||||
|
from tests_unit.samples import HOST_K1Y, MAC_K1Y, TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def ablegen(verzeichnis: Path, name: str, device_type: str, version: str) -> None:
|
||||||
|
"""Blockierendes Schreiben ausserhalb der Ereignisschleife - sonst meldet
|
||||||
|
ruff ASYNC240."""
|
||||||
|
verzeichnis.mkdir(parents=True, exist_ok=True)
|
||||||
|
(verzeichnis / name).write_bytes(build_image(device_type, version))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_geheimnisse_stehen_nicht_drin(
|
||||||
|
hass: HomeAssistant, init_integration: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
daten = await async_get_config_entry_diagnostics(hass, init_integration)
|
||||||
|
text = repr(daten)
|
||||||
|
for geheim in (TOKEN, HOST_K1Y, MAC_K1Y, "ninIOT"):
|
||||||
|
assert geheim not in text, geheim
|
||||||
|
assert daten["state"]["brightness"] == 100
|
||||||
|
assert daten["info"]["fw_version"] == "0.1.0-12-g71a57"
|
||||||
|
assert daten["segments"]["segments"][0]["mode"] == "white"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nur_abbilder_der_eigenen_platine(
|
||||||
|
hass: HomeAssistant, init_integration: MockConfigEntry, firmware_dir: Path
|
||||||
|
) -> None:
|
||||||
|
await hass.async_add_executor_job(
|
||||||
|
ablegen, firmware_dir, "meins.img", "ninstrip-k1y", "0.1.1-rc1"
|
||||||
|
)
|
||||||
|
await hass.async_add_executor_job(ablegen, firmware_dir, "fremd.img", "ninstrip-rgbc3", "0.2.0")
|
||||||
|
await async_scan_catalog(hass, hass.data[DATA_SHARED])
|
||||||
|
daten = await async_get_config_entry_diagnostics(hass, init_integration)
|
||||||
|
assert [i["version"] for i in daten["firmware_images"]] == ["0.1.1-rc1"]
|
||||||
+35
-1
@@ -5,13 +5,20 @@ from __future__ import annotations
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.const import CONF_TOKEN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
from custom_components.ninstrip.api import NinstripAuthError, NinstripConnectionError
|
from custom_components.ninstrip.api import NinstripAuthError, NinstripConnectionError
|
||||||
from custom_components.ninstrip.const import CONF_DEVICE_BASE_URL, CONF_SCAN_INTERVAL, DOMAIN
|
from custom_components.ninstrip.const import (
|
||||||
|
CONF_DEVICE_BASE_URL,
|
||||||
|
CONF_SCAN_INTERVAL,
|
||||||
|
DEFAULT_TOKEN,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
from tests_unit.samples import MAC_K1Y
|
from tests_unit.samples import MAC_K1Y
|
||||||
|
|
||||||
|
|
||||||
@@ -84,3 +91,30 @@ async def test_einstellungen_lassen_sich_am_laufenden_eintrag_sichern(
|
|||||||
CONF_SCAN_INTERVAL: 45,
|
CONF_SCAN_INTERVAL: 45,
|
||||||
CONF_DEVICE_BASE_URL: "http://192.168.1.131:8124",
|
CONF_DEVICE_BASE_URL: "http://192.168.1.131:8124",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_werks_token_gibt_einen_hinweis(
|
||||||
|
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Wer im selben Netz ist, kann ein Geraet mit Werks-Token vollstaendig
|
||||||
|
uebernehmen - das soll man sehen, nicht raten muessen."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
mock_config_entry, data={**mock_config_entry.data, CONF_TOKEN: DEFAULT_TOKEN}
|
||||||
|
)
|
||||||
|
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
hinweis = ir.async_get(hass).async_get_issue(
|
||||||
|
DOMAIN, f"default_token_{mock_config_entry.entry_id}"
|
||||||
|
)
|
||||||
|
assert hinweis is not None
|
||||||
|
assert hinweis.severity is ir.IssueSeverity.WARNING
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eigenes_token_gibt_keinen_hinweis(
|
||||||
|
hass: HomeAssistant, init_integration: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
assert (
|
||||||
|
ir.async_get(hass).async_get_issue(DOMAIN, f"default_token_{init_integration.entry_id}")
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from freezegun.api import FrozenDateTimeFactory
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
@@ -72,3 +73,41 @@ async def test_ohne_wifi_block_bleibt_die_adresse_leer(
|
|||||||
strip_info.pop("wifi")
|
strip_info.pop("wifi")
|
||||||
await abfragen(hass, freezer)
|
await abfragen(hass, freezer)
|
||||||
assert hass.states.get(entity_id_for(hass, "sensor", "ip_address")).state == "unknown"
|
assert hass.states.get(entity_id_for(hass, "sensor", "ip_address")).state == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_startzeit_und_ota_fehler_sind_diagnose_und_aus(
|
||||||
|
hass: HomeAssistant, init_integration: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
for key in ("boot_time", "last_ota_error"):
|
||||||
|
eintrag = registry.async_get(entity_id_for(hass, "sensor", key))
|
||||||
|
assert eintrag.disabled_by is er.RegistryEntryDisabler.INTEGRATION, key
|
||||||
|
|
||||||
|
|
||||||
|
async def test_startzeit_wandert_nicht_bei_jeder_abfrage(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
init_integration: MockConfigEntry,
|
||||||
|
strip_info: dict[str, Any],
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
) -> None:
|
||||||
|
"""Die Laufzeit misst das Geraet grob; ohne Toleranz ergaebe jede Abfrage
|
||||||
|
einen neuen Zeitstempel und HA schriebe dauernd Verlauf."""
|
||||||
|
from custom_components.ninstrip.sensor import NinstripBootTimeSensor
|
||||||
|
|
||||||
|
sensor = NinstripBootTimeSensor(init_integration.runtime_data)
|
||||||
|
erst = sensor.native_value
|
||||||
|
assert erst is not None
|
||||||
|
|
||||||
|
# Zwei Sekunden spaeter meldet das Geraet zwei Sekunden mehr Laufzeit -
|
||||||
|
# derselbe Start, also derselbe Zeitstempel.
|
||||||
|
freezer.tick(timedelta(seconds=2))
|
||||||
|
strip_info["uptime"] += 2
|
||||||
|
await init_integration.runtime_data.async_refresh()
|
||||||
|
sensor._boot_aktualisieren()
|
||||||
|
assert sensor.native_value == erst
|
||||||
|
|
||||||
|
# Ein Neustart dagegen muss durchschlagen.
|
||||||
|
strip_info["uptime"] = 5
|
||||||
|
await init_integration.runtime_data.async_refresh()
|
||||||
|
sensor._boot_aktualisieren()
|
||||||
|
assert sensor.native_value != erst
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Die Felder aus device/state, die ninlight schon lange hat: Zeiten,
|
||||||
|
Einschaltverhalten, Aufwecken - und die Uebergangszeit beim Schalten."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
|
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
import pytest
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
|
from tests.helpers import abfragen, entity_id_for
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("key", "wert", "grenze"),
|
||||||
|
[("fade_in", 500, 10_000), ("fade_out", 500, 10_000), ("transition_default", 300, 60_000)],
|
||||||
|
)
|
||||||
|
async def test_zeiten_zeigen_und_setzen(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
init_integration: MockConfigEntry,
|
||||||
|
mock_client: MagicMock,
|
||||||
|
key: str,
|
||||||
|
wert: int,
|
||||||
|
grenze: int,
|
||||||
|
) -> None:
|
||||||
|
entity_id = entity_id_for(hass, "number", key)
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state.state == f"{float(wert)}"
|
||||||
|
# Die Obergrenzen sind die der Firmware (nl_api_device.c); alles darueber
|
||||||
|
# wiese sie mit 400 ab.
|
||||||
|
assert state.attributes["max"] == grenze
|
||||||
|
assert state.attributes["unit_of_measurement"] == "ms"
|
||||||
|
await hass.services.async_call(
|
||||||
|
"number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 1200}, blocking=True
|
||||||
|
)
|
||||||
|
mock_client.set_state.assert_awaited_once_with(**{key: 1200})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["fade_in", "fade_out", "transition_default"])
|
||||||
|
async def test_zeiten_ueber_der_grenze_werden_abgelehnt(
|
||||||
|
hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock, key: str
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ServiceValidationError):
|
||||||
|
await hass.services.async_call(
|
||||||
|
"number",
|
||||||
|
"set_value",
|
||||||
|
{ATTR_ENTITY_ID: entity_id_for(hass, "number", key), "value": 60_001},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
mock_client.set_state.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_einschaltverhalten(
|
||||||
|
hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock
|
||||||
|
) -> None:
|
||||||
|
entity_id = entity_id_for(hass, "select", "power_on_behaviour")
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state.state == "last"
|
||||||
|
assert state.attributes["options"] == ["last", "on", "off"]
|
||||||
|
await hass.services.async_call(
|
||||||
|
"select", "select_option", {ATTR_ENTITY_ID: entity_id, "option": "off"}, blocking=True
|
||||||
|
)
|
||||||
|
mock_client.set_state.assert_awaited_once_with(power_on_behaviour="off")
|
||||||
|
assert hass.states.get(entity_id).state == "off"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unbekanntes_einschaltverhalten_zeigt_nichts(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
init_integration: MockConfigEntry,
|
||||||
|
strip_state: dict[str, Any],
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
) -> None:
|
||||||
|
# Gemeint ist das Verhalten, nicht die Zeile Code: Home Assistant selbst
|
||||||
|
# zeigt "unknown", sobald der Wert nicht in den Optionen steht. Die Pruefung
|
||||||
|
# in der Entitaet ist derselbe Riemen doppelt - wie in ha-ninlight.
|
||||||
|
strip_state["power_on_behaviour"] = "irgendwas"
|
||||||
|
await abfragen(hass, freezer)
|
||||||
|
assert hass.states.get(entity_id_for(hass, "select", "power_on_behaviour")).state == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_aufwecken_bei_aenderung(
|
||||||
|
hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock
|
||||||
|
) -> None:
|
||||||
|
entity_id = entity_id_for(hass, "switch", "wake_on_change")
|
||||||
|
assert hass.states.get(entity_id).state == STATE_OFF
|
||||||
|
await hass.services.async_call("switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True)
|
||||||
|
mock_client.set_state.assert_awaited_once_with(wake_on_change=True)
|
||||||
|
assert hass.states.get(entity_id).state == STATE_ON
|
||||||
|
await hass.services.async_call("switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True)
|
||||||
|
mock_client.set_state.assert_awaited_with(wake_on_change=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("sekunden", "millisekunden"), [(0, 0), (2.5, 2500), (0.04, 40), (120, 60_000)]
|
||||||
|
)
|
||||||
|
async def test_uebergangszeit_beim_einschalten(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
init_integration: MockConfigEntry,
|
||||||
|
mock_client: MagicMock,
|
||||||
|
sekunden: float,
|
||||||
|
millisekunden: int,
|
||||||
|
) -> None:
|
||||||
|
"""HA rechnet in Sekunden, das Geraet in Millisekunden - und laengeres als
|
||||||
|
60 s weist es mit 400 ab, deshalb wird gekappt statt durchgereicht."""
|
||||||
|
await hass.services.async_call(
|
||||||
|
"light",
|
||||||
|
"turn_on",
|
||||||
|
{ATTR_ENTITY_ID: entity_id_for(hass, "light", "light"), "transition": sekunden},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
mock_client.set_state.assert_awaited_once_with(on=True, transition=millisekunden)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_uebergangszeit_beim_ausschalten(
|
||||||
|
hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock
|
||||||
|
) -> None:
|
||||||
|
await hass.services.async_call(
|
||||||
|
"light",
|
||||||
|
"turn_off",
|
||||||
|
{ATTR_ENTITY_ID: entity_id_for(hass, "light", "light"), "transition": 1.5},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
mock_client.set_state.assert_awaited_once_with(on=False, transition=1500)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ohne_uebergang_wird_kein_feld_geschickt(
|
||||||
|
hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Sonst ueberschriebe jeder Schaltbefehl die Vorgabe des Geraets."""
|
||||||
|
await hass.services.async_call(
|
||||||
|
"light", "turn_off", {ATTR_ENTITY_ID: entity_id_for(hass, "light", "light")}, blocking=True
|
||||||
|
)
|
||||||
|
mock_client.set_state.assert_awaited_once_with(on=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_die_lichtentitaet_meldet_uebergaenge(
|
||||||
|
hass: HomeAssistant, init_integration: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
state = hass.states.get(entity_id_for(hass, "light", "light"))
|
||||||
|
assert state.attributes["supported_features"] == 32 # LightEntityFeature.TRANSITION
|
||||||
Reference in New Issue
Block a user