diff --git a/custom_components/ninstrip/api.py b/custom_components/ninstrip/api.py index 842b9eb..0ce1a8f 100644 --- a/custom_components/ninstrip/api.py +++ b/custom_components/ninstrip/api.py @@ -115,6 +115,10 @@ class NinstripClient: """sACN-Einstellungen aendern. Weggelassene Felder behalten ihren Wert.""" await self._request("PUT", "system/settings", {"type": "sacn", **fields}) + async def set_strip(self, **fields: Any) -> None: + """Strip-Einstellungen aendern. Weggelassene Felder behalten ihren Wert.""" + await self._request("PUT", "system/settings", {"type": "strip", **fields}) + async def reboot(self) -> None: await self._request("PUT", "system/command", {"command": {"type": "reboot"}}) diff --git a/custom_components/ninstrip/coordinator.py b/custom_components/ninstrip/coordinator.py index 0ec5de4..a114edf 100644 --- a/custom_components/ninstrip/coordinator.py +++ b/custom_components/ninstrip/coordinator.py @@ -183,6 +183,29 @@ class NinstripCoordinator(DataUpdateCoordinator[NinstripData]): ) ) + async def async_set_strip(self, **fields: Any) -> None: + """Strip-Einstellungen schreiben und die Antwort des Geraets uebernehmen. + + Gleiches Muster wie async_set_sacn: das Geraet antwortet nur mit + {"status": "ok"}, also wird zurueckgelesen statt angenommen. + """ + + async def schreiben_und_lesen(client: NinstripClient) -> dict[str, Any]: + await client.set_strip(**fields) + return await client.get_settings() + + settings = await self.async_call(schreiben_und_lesen) + self._consecutive_poll_failures = 0 + self.async_set_updated_data( + NinstripData( + self.data.state, + self.data.info, + self.data.presets, + self.data.segments, + settings, + ) + ) + async def async_set_all_segments(self, **fields: Any) -> None: """Schreibt dieselben Felder in JEDES Segment und liest zurueck. diff --git a/custom_components/ninstrip/number.py b/custom_components/ninstrip/number.py index 904cba1..ea56fc0 100644 --- a/custom_components/ninstrip/number.py +++ b/custom_components/ninstrip/number.py @@ -10,7 +10,7 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.const import EntityCategory, UnitOfElectricCurrent, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -60,6 +60,7 @@ async def async_setup_entry( entitaeten: list[NumberEntity] = [NinstripZeit(coordinator, d) for d in ZEITEN] entitaeten.append(NinstripSacnUniverse(coordinator)) entitaeten.append(NinstripSacnStartSlot(coordinator)) + entitaeten.append(NinstripStrombudget(coordinator)) async_add_entities(entitaeten) @@ -141,3 +142,28 @@ class NinstripSacnStartSlot(NinstripSacnEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: await self.coordinator.async_set_sacn(start_slot=int(value)) + + +class NinstripStrombudget(NinstripEntity, NumberEntity): + """Was das Netzteil hergibt. Die Firmware regelt daran.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "max_milliamps" + _attr_native_min_value = 100 + # Die Firmware nimmt fuer das Budget hoechstens 65535 mA an (uint16). + _attr_native_max_value = 65535 + _attr_native_step = 100 + _attr_mode = NumberMode.BOX + _attr_native_unit_of_measurement = UnitOfElectricCurrent.MILLIAMPERE + + def __init__(self, coordinator: NinstripCoordinator) -> None: + super().__init__(coordinator, "max_milliamps") + + @property + def native_value(self) -> float | None: + strip = self.coordinator.data.settings.get("strip") + wert = strip.get("max_milliamps") if isinstance(strip, dict) else None + 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_strip(max_milliamps=int(value)) diff --git a/custom_components/ninstrip/sensor.py b/custom_components/ninstrip/sensor.py index 5fdfd85..7532a2d 100644 --- a/custom_components/ninstrip/sensor.py +++ b/custom_components/ninstrip/sensor.py @@ -16,7 +16,9 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfElectricCurrent, UnitOfInformation, + UnitOfPower, UnitOfTime, ) from homeassistant.core import HomeAssistant, callback @@ -98,6 +100,27 @@ SENSORS: tuple[NinstripSensorDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, value=lambda d: _strip(d, "power_scale"), ), + # 0 mA heisst entweder "zieht gerade nichts" oder "misst gar nicht" - + # aeltere Firmware ohne Shunt meldet ebenfalls 0. Von aussen nicht zu + # unterscheiden, und keine Heuristik dagegen: Schwaeche der Geraete-API. + NinstripSensorDescription( + key="current", + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda d: _strip(d, "current_ma"), + ), + NinstripSensorDescription( + key="power", + translation_key="power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda d: _strip(d, "power_w"), + ), # Was bei der letzten Aktualisierung schiefging - leer, solange nichts # schiefging. Aus der Ferne der einzige Hinweis darauf. NinstripSensorDescription( diff --git a/custom_components/ninstrip/strings.json b/custom_components/ninstrip/strings.json index 29e3bb3..ce966ea 100644 --- a/custom_components/ninstrip/strings.json +++ b/custom_components/ninstrip/strings.json @@ -105,6 +105,12 @@ "power_scale": { "name": "Leistungsbegrenzung" }, + "current": { + "name": "Strom" + }, + "power": { + "name": "Leistung" + }, "last_ota_error": { "name": "Letzter Update-Fehler" }, @@ -135,6 +141,9 @@ }, "transition_default": { "name": "Übergangszeit (Vorgabe)" + }, + "max_milliamps": { + "name": "Strombudget" } }, "binary_sensor": { diff --git a/custom_components/ninstrip/translations/de.json b/custom_components/ninstrip/translations/de.json index 29e3bb3..ce966ea 100644 --- a/custom_components/ninstrip/translations/de.json +++ b/custom_components/ninstrip/translations/de.json @@ -105,6 +105,12 @@ "power_scale": { "name": "Leistungsbegrenzung" }, + "current": { + "name": "Strom" + }, + "power": { + "name": "Leistung" + }, "last_ota_error": { "name": "Letzter Update-Fehler" }, @@ -135,6 +141,9 @@ }, "transition_default": { "name": "Übergangszeit (Vorgabe)" + }, + "max_milliamps": { + "name": "Strombudget" } }, "binary_sensor": { diff --git a/custom_components/ninstrip/translations/en.json b/custom_components/ninstrip/translations/en.json index 38ffc50..8688c88 100644 --- a/custom_components/ninstrip/translations/en.json +++ b/custom_components/ninstrip/translations/en.json @@ -105,6 +105,12 @@ "power_scale": { "name": "Power limiting" }, + "current": { + "name": "Current" + }, + "power": { + "name": "Power" + }, "last_ota_error": { "name": "Last update error" }, @@ -135,6 +141,9 @@ }, "transition_default": { "name": "Default transition time" + }, + "max_milliamps": { + "name": "Current budget" } }, "binary_sensor": { diff --git a/tests/conftest.py b/tests/conftest.py index 5c24663..b8086b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -101,6 +101,13 @@ def mock_client( strip_settings.setdefault("sacn", {}).update(fields) client.set_sacn = AsyncMock(side_effect=set_sacn) + + async def set_strip(**fields: Any) -> None: + # Wie set_sacn: das Geraet merkt sich, was es annimmt, und der + # Aufrufer liest den Block danach zurueck. + strip_settings.setdefault("strip", {}).update(fields) + + client.set_strip = AsyncMock(side_effect=set_strip) client.apply_preset = AsyncMock(return_value=None) client.save_preset = AsyncMock(return_value=None) client.reboot = AsyncMock(return_value=None) diff --git a/tests/test_strombudget.py b/tests/test_strombudget.py new file mode 100644 index 0000000..78721f3 --- /dev/null +++ b/tests/test_strombudget.py @@ -0,0 +1,66 @@ +"""Strom, Leistung und das Budget.""" + +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 +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from tests.helpers import abfragen, entity_id_for + + +async def test_strom_und_leistung(hass: HomeAssistant, init_integration: MockConfigEntry) -> None: + assert hass.states.get(entity_id_for(hass, "sensor", "current")).state == "2500" + # power_w kommt als Kommazahl von der Firmware (2,5 A bei 12 V). + assert hass.states.get(entity_id_for(hass, "sensor", "power")).state == "30.0" + + +async def test_budget_zeigt_den_eingestellten_wert( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + entity_id = entity_id_for(hass, "number", "max_milliamps") + assert hass.states.get(entity_id).state == "1800.0" + assert hass.states.get(entity_id).attributes["max"] == 65535 + + +async def test_budget_setzen( + hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock +) -> None: + await hass.services.async_call( + "number", + "set_value", + {ATTR_ENTITY_ID: entity_id_for(hass, "number", "max_milliamps"), "value": 9000}, + blocking=True, + ) + mock_client.set_strip.assert_awaited_once_with(max_milliamps=9000) + # Nicht geraten, sondern zurueckgelesen: das Geraet antwortet nur mit "ok". + assert mock_client.get_settings.await_count >= 2 + assert hass.states.get(entity_id_for(hass, "number", "max_milliamps")).state == "9000.0" + + +async def test_ohne_strip_block_in_settings_bleibt_das_budget_leer( + hass: HomeAssistant, + init_integration: MockConfigEntry, + strip_settings: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Aeltere Firmware ohne Strommessung meldet unter system/settings keinen strip-Block.""" + strip_settings.pop("strip") + await abfragen(hass, freezer) + assert hass.states.get(entity_id_for(hass, "number", "max_milliamps")).state == "unknown" + + +async def test_ohne_strip_block_in_info_bleiben_strom_und_leistung_leer( + hass: HomeAssistant, + init_integration: MockConfigEntry, + strip_info: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + strip_info.pop("strip") + await abfragen(hass, freezer) + assert hass.states.get(entity_id_for(hass, "sensor", "current")).state == "unknown" + assert hass.states.get(entity_id_for(hass, "sensor", "power")).state == "unknown" diff --git a/tests_unit/samples.py b/tests_unit/samples.py index e41b911..929cb54 100644 --- a/tests_unit/samples.py +++ b/tests_unit/samples.py @@ -45,6 +45,8 @@ INFO_K1Y: dict[str, Any] = { "inverted": True, "power_scale": 100, "max_milliamps": 1800, + "current_ma": 2500, + "power_w": 30.0, }, "render": {"fps": 50, "render_us": 900, "show_us": 1800, "dropped_frames": 0}, "last_ota_error": None,