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:
@@ -267,3 +267,32 @@ async def test_einstellungen_weisen_unbrauchbare_adressen_ab(
|
||||
)
|
||||
assert ergebnis["type"] is FlowResultType.FORM
|
||||
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 homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
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 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
|
||||
|
||||
|
||||
@@ -84,3 +91,30 @@ async def test_einstellungen_lassen_sich_am_laufenden_eintrag_sichern(
|
||||
CONF_SCAN_INTERVAL: 45,
|
||||
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 datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
@@ -72,3 +73,41 @@ async def test_ohne_wifi_block_bleibt_die_adresse_leer(
|
||||
strip_info.pop("wifi")
|
||||
await abfragen(hass, freezer)
|
||||
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