From 869de5724e3ca3fa536b29a00b5d9256ea632cce Mon Sep 17 00:00:00 2001 From: Kyrill <1942093+poolski@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:16:32 +0000 Subject: [PATCH 1/5] feat: add configurable write interval to throttle Home Assistant updates Agent-Logs-Url: https://github.com/poolski/pytap/sessions/c67ff8ad-19f3-4cf4-8d07-983b2bc09efb Co-authored-by: poolski <1942093+poolski@users.noreply.github.com> --- custom_components/pytap/config_flow.py | 14 ++ custom_components/pytap/const.py | 2 + custom_components/pytap/coordinator.py | 40 ++++- custom_components/pytap/strings.json | 12 +- custom_components/pytap/translations/en.json | 12 +- tests/test_write_interval.py | 155 +++++++++++++++++++ 6 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 tests/test_write_interval.py diff --git a/custom_components/pytap/config_flow.py b/custom_components/pytap/config_flow.py index 9d648f5..1d3d81b 100644 --- a/custom_components/pytap/config_flow.py +++ b/custom_components/pytap/config_flow.py @@ -32,8 +32,10 @@ CONF_MODULE_PEAK_POWER, CONF_MODULE_STRING, CONF_MODULES, + CONF_WRITE_INTERVAL, DEFAULT_PEAK_POWER, DEFAULT_PORT, + DEFAULT_WRITE_INTERVAL, DOMAIN, ) @@ -46,6 +48,9 @@ { vol.Required(CONF_HOST): str, vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_WRITE_INTERVAL, default=DEFAULT_WRITE_INTERVAL): vol.All( + vol.Coerce(int), vol.Range(min=1, max=300) + ), } ) @@ -285,6 +290,9 @@ async def async_step_change_connection( new_data = {**self._config_entry.data} new_data[CONF_HOST] = new_host new_data[CONF_PORT] = new_port + new_data[CONF_WRITE_INTERVAL] = user_input.get( + CONF_WRITE_INTERVAL, DEFAULT_WRITE_INTERVAL + ) self.hass.config_entries.async_update_entry( self._config_entry, data=new_data, @@ -295,10 +303,16 @@ async def async_step_change_connection( # Pre-fill with current values current_host = self._config_entry.data.get(CONF_HOST, "") current_port = self._config_entry.data.get(CONF_PORT, DEFAULT_PORT) + current_write_interval = self._config_entry.data.get( + CONF_WRITE_INTERVAL, DEFAULT_WRITE_INTERVAL + ) schema = vol.Schema( { vol.Required(CONF_HOST, default=current_host): str, vol.Optional(CONF_PORT, default=current_port): int, + vol.Optional( + CONF_WRITE_INTERVAL, default=current_write_interval + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=300)), } ) diff --git a/custom_components/pytap/const.py b/custom_components/pytap/const.py index 3869bf4..b31d9a8 100644 --- a/custom_components/pytap/const.py +++ b/custom_components/pytap/const.py @@ -7,6 +7,7 @@ CONF_HOST = "host" CONF_PORT = "port" CONF_MODULES = "modules" +CONF_WRITE_INTERVAL = "write_interval" # Module dict keys CONF_MODULE_STRING = "string" @@ -19,6 +20,7 @@ DEFAULT_STRING_NAME = "Default" DEFAULT_PEAK_POWER = 455 DEFAULT_SCAN_INTERVAL = 30 +DEFAULT_WRITE_INTERVAL = 5 RECONNECT_TIMEOUT = 60 RECONNECT_DELAY = 5 RECONNECT_RETRIES = 0 diff --git a/custom_components/pytap/coordinator.py b/custom_components/pytap/coordinator.py index 92563e9..0a72948 100644 --- a/custom_components/pytap/coordinator.py +++ b/custom_components/pytap/coordinator.py @@ -28,8 +28,10 @@ CONF_MODULE_PEAK_POWER, CONF_MODULE_STRING, CONF_MODULES, + CONF_WRITE_INTERVAL, DEFAULT_PEAK_POWER, DEFAULT_PORT, + DEFAULT_WRITE_INTERVAL, DOMAIN, ENERGY_GAP_THRESHOLD_SECONDS, ENERGY_LOW_POWER_THRESHOLD_W, @@ -94,6 +96,9 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._host: str = entry.data[CONF_HOST] self._port: int = entry.data.get(CONF_PORT, DEFAULT_PORT) self._modules: list[dict[str, Any]] = entry.data.get(CONF_MODULES, []) + self._write_interval: float = float( + entry.data.get(CONF_WRITE_INTERVAL, DEFAULT_WRITE_INTERVAL) + ) # Build barcode allowlist from configured modules self._configured_barcodes: set[str] = { @@ -129,6 +134,10 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._listener_task: asyncio.Task | None = None self._stop_event = threading.Event() + # Write-interval throttle state (accessed only from the listener thread) + self._last_ha_update: float = 0.0 + self._ha_update_pending: bool = False + # Midnight reset timer handle self._midnight_reset_unsub: asyncio.TimerHandle | None = None @@ -318,14 +327,21 @@ def _listen(self) -> None: last_data_time = time.monotonic() events = parser.feed(data) for event in events: - data_changed = self._process_event(event) - if data_changed: - # Push each event individually to HA - self.data["counters"] = parser.counters - self.hass.loop.call_soon_threadsafe( - self.async_set_updated_data, - dict(self.data), - ) + if self._process_event(event): + self._ha_update_pending = True + + # Push to HA at most once per write interval + now = time.monotonic() + if self._ha_update_pending and ( + now - self._last_ha_update >= self._write_interval + ): + self.data["counters"] = parser.counters + self.hass.loop.call_soon_threadsafe( + self.async_set_updated_data, + dict(self.data), + ) + self._last_ha_update = now + self._ha_update_pending = False elif ( RECONNECT_TIMEOUT > 0 and (time.monotonic() - last_data_time) > RECONNECT_TIMEOUT @@ -341,6 +357,14 @@ def _listen(self) -> None: return _LOGGER.error("Connection error: %s", err) finally: + # Flush any data that was held back by the write interval + if self._ha_update_pending: + self.data["counters"] = parser.counters + self.hass.loop.call_soon_threadsafe( + self.async_set_updated_data, + dict(self.data), + ) + self._ha_update_pending = False with self._source_lock: if self._source is not None: try: diff --git a/custom_components/pytap/strings.json b/custom_components/pytap/strings.json index 90eb38e..6b7c0d2 100644 --- a/custom_components/pytap/strings.json +++ b/custom_components/pytap/strings.json @@ -6,11 +6,13 @@ "description": "Provide the connection details for your Tigo gateway.", "data": { "host": "Host", - "port": "Port" + "port": "Port", + "write_interval": "Write interval (seconds)" }, "data_description": { "host": "The hostname or IP address of your Tigo gateway.", - "port": "The port number (default: 502)." + "port": "The port number (default: 502).", + "write_interval": "How often (in seconds) to push updates to Home Assistant. Higher values reduce CPU load. Default: 5 seconds." } }, "modules_menu": { @@ -137,11 +139,13 @@ "description": "Update the IP address and port of your Tigo gateway.", "data": { "host": "Host", - "port": "Port" + "port": "Port", + "write_interval": "Write interval (seconds)" }, "data_description": { "host": "The hostname or IP address of your Tigo gateway.", - "port": "The port number (default: 502)." + "port": "The port number (default: 502).", + "write_interval": "How often (in seconds) to push updates to Home Assistant. Higher values reduce CPU load. Default: 5 seconds." } }, "add_module": { diff --git a/custom_components/pytap/translations/en.json b/custom_components/pytap/translations/en.json index 90eb38e..6b7c0d2 100644 --- a/custom_components/pytap/translations/en.json +++ b/custom_components/pytap/translations/en.json @@ -6,11 +6,13 @@ "description": "Provide the connection details for your Tigo gateway.", "data": { "host": "Host", - "port": "Port" + "port": "Port", + "write_interval": "Write interval (seconds)" }, "data_description": { "host": "The hostname or IP address of your Tigo gateway.", - "port": "The port number (default: 502)." + "port": "The port number (default: 502).", + "write_interval": "How often (in seconds) to push updates to Home Assistant. Higher values reduce CPU load. Default: 5 seconds." } }, "modules_menu": { @@ -137,11 +139,13 @@ "description": "Update the IP address and port of your Tigo gateway.", "data": { "host": "Host", - "port": "Port" + "port": "Port", + "write_interval": "Write interval (seconds)" }, "data_description": { "host": "The hostname or IP address of your Tigo gateway.", - "port": "The port number (default: 502)." + "port": "The port number (default: 502).", + "write_interval": "How often (in seconds) to push updates to Home Assistant. Higher values reduce CPU load. Default: 5 seconds." } }, "add_module": { diff --git a/tests/test_write_interval.py b/tests/test_write_interval.py new file mode 100644 index 0000000..da2fd9c --- /dev/null +++ b/tests/test_write_interval.py @@ -0,0 +1,155 @@ +"""Tests for the configurable write interval in PyTapDataUpdateCoordinator.""" + +from datetime import datetime +import time +from unittest.mock import MagicMock + +import pytest + +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from custom_components.pytap.const import ( + CONF_MODULE_BARCODE, + CONF_MODULE_NAME, + CONF_MODULE_PEAK_POWER, + CONF_MODULE_STRING, + CONF_MODULES, + CONF_WRITE_INTERVAL, + DEFAULT_PORT, + DEFAULT_WRITE_INTERVAL, +) +from custom_components.pytap.coordinator import PyTapDataUpdateCoordinator +from custom_components.pytap.pytap.core.events import InfrastructureEvent, PowerReportEvent + + +MOCK_MODULES = [ + { + CONF_MODULE_STRING: "A", + CONF_MODULE_NAME: "Panel_01", + CONF_MODULE_BARCODE: "A-1234567B", + CONF_MODULE_PEAK_POWER: 455, + }, +] + + +def _make_entry(hass, write_interval=None): + """Create a mock ConfigEntry with optional write_interval.""" + entry = MagicMock() + data = { + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_MODULES: MOCK_MODULES, + } + if write_interval is not None: + data[CONF_WRITE_INTERVAL] = write_interval + entry.data = data + entry.entry_id = "test_write_interval_entry" + entry.options = {} + return entry + + +def _make_infra_event(): + """Build an InfrastructureEvent that maps node 1 to barcode A-1234567B.""" + return InfrastructureEvent( + gateways={1: {"address": "aa:bb", "version": "1.0"}}, + nodes={1: {"address": "11:22:33:44", "barcode": "A-1234567B"}}, + timestamp=datetime.now(), + ) + + +def _make_power_event(power=100.0): + """Build a minimal PowerReportEvent for barcode A-1234567B.""" + return PowerReportEvent( + gateway_id=1, + node_id=1, + barcode="A-1234567B", + voltage_in=30.0, + voltage_out=30.0, + current_in=3.0, + temperature=25.0, + dc_dc_duty_cycle=0.5, + rssi=-60, + timestamp=datetime.now(), + ) + + +class TestWriteIntervalInit: + """Test that write_interval is read from entry data correctly.""" + + def test_default_write_interval(self, hass: HomeAssistant) -> None: + """Coordinator uses DEFAULT_WRITE_INTERVAL when not configured.""" + entry = _make_entry(hass) + coordinator = PyTapDataUpdateCoordinator(hass, entry) + assert coordinator._write_interval == DEFAULT_WRITE_INTERVAL + + def test_custom_write_interval(self, hass: HomeAssistant) -> None: + """Coordinator honours a custom write_interval from entry data.""" + entry = _make_entry(hass, write_interval=10) + coordinator = PyTapDataUpdateCoordinator(hass, entry) + assert coordinator._write_interval == 10.0 + + def test_throttle_state_initialised(self, hass: HomeAssistant) -> None: + """Throttle bookkeeping fields start at their zero values.""" + entry = _make_entry(hass) + coordinator = PyTapDataUpdateCoordinator(hass, entry) + assert coordinator._last_ha_update == 0.0 + assert coordinator._ha_update_pending is False + + +class TestWriteIntervalThrottling: + """Test that HA updates are throttled to at most once per write_interval.""" + + def _run_batch(self, coordinator, event): + """Simulate one iteration of the inner read-loop for a single event.""" + if coordinator._process_event(event): + coordinator._ha_update_pending = True + + now = time.monotonic() + pushed = False + if coordinator._ha_update_pending and ( + now - coordinator._last_ha_update >= coordinator._write_interval + ): + pushed = True + coordinator._last_ha_update = now + coordinator._ha_update_pending = False + return pushed + + def test_first_event_triggers_push(self, hass: HomeAssistant) -> None: + """First data event always triggers an immediate HA push (last_ha_update=0).""" + entry = _make_entry(hass, write_interval=60) + coordinator = PyTapDataUpdateCoordinator(hass, entry) + coordinator._schedule_save = MagicMock() + coordinator._handle_infrastructure(_make_infra_event()) + + pushed = self._run_batch(coordinator, _make_power_event()) + + assert pushed is True + + def test_second_event_within_interval_suppressed(self, hass: HomeAssistant) -> None: + """A second event arriving before the interval elapses is NOT pushed immediately.""" + entry = _make_entry(hass, write_interval=60) + coordinator = PyTapDataUpdateCoordinator(hass, entry) + coordinator._schedule_save = MagicMock() + coordinator._handle_infrastructure(_make_infra_event()) + + # First batch — should push (last_ha_update starts at 0) + pushed1 = self._run_batch(coordinator, _make_power_event(power=100.0)) + assert pushed1 is True + + # Second batch immediately after — interval (60 s) has not elapsed + pushed2 = self._run_batch(coordinator, _make_power_event(power=110.0)) + assert pushed2 is False, "Second push should be suppressed within the interval" + assert coordinator._ha_update_pending is True, "Pending flag should remain set" + + def test_pending_flag_cleared_after_push(self, hass: HomeAssistant) -> None: + """_ha_update_pending is cleared to False after a successful push.""" + entry = _make_entry(hass, write_interval=0) # interval=0 → always push + coordinator = PyTapDataUpdateCoordinator(hass, entry) + coordinator._schedule_save = MagicMock() + coordinator._handle_infrastructure(_make_infra_event()) + + self._run_batch(coordinator, _make_power_event()) + + assert coordinator._ha_update_pending is False + From 16e9bd8336da34092481ca9b0ebc85a903db314b Mon Sep 17 00:00:00 2001 From: Kyrill <1942093+poolski@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:13:58 +0000 Subject: [PATCH 2/5] refactor: move write_interval to dedicated Reporting Settings options step Agent-Logs-Url: https://github.com/poolski/pytap/sessions/dc53ebe2-17ef-480f-bcf1-7ab9f5dec747 Co-authored-by: poolski <1942093+poolski@users.noreply.github.com> --- custom_components/pytap/config_flow.py | 49 ++++++-- custom_components/pytap/strings.json | 25 ++-- custom_components/pytap/translations/en.json | 25 ++-- tests/test_config_flow.py | 120 +++++++++++++++++++ 4 files changed, 189 insertions(+), 30 deletions(-) diff --git a/custom_components/pytap/config_flow.py b/custom_components/pytap/config_flow.py index 1d3d81b..8d5514a 100644 --- a/custom_components/pytap/config_flow.py +++ b/custom_components/pytap/config_flow.py @@ -5,7 +5,8 @@ rather than a comma-separated text blob. Flow: user (host/port) → modules_menu → add_module (repeat) → finish -Options: init (menu) → add_module / remove_module → done +Options: init (menu) → add_module / remove_module / change_connection / + change_reporting → done """ from __future__ import annotations @@ -48,9 +49,6 @@ { vol.Required(CONF_HOST): str, vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_WRITE_INTERVAL, default=DEFAULT_WRITE_INTERVAL): vol.All( - vol.Coerce(int), vol.Range(min=1, max=300) - ), } ) @@ -247,7 +245,13 @@ async def async_step_init( """Show the options menu: change connection / add / remove / done.""" return self.async_show_menu( step_id="init", - menu_options=["change_connection", "add_module", "remove_module", "done"], + menu_options=[ + "change_connection", + "change_reporting", + "add_module", + "remove_module", + "done", + ], description_placeholders={ "modules_list": _modules_description(self._modules), }, @@ -290,9 +294,6 @@ async def async_step_change_connection( new_data = {**self._config_entry.data} new_data[CONF_HOST] = new_host new_data[CONF_PORT] = new_port - new_data[CONF_WRITE_INTERVAL] = user_input.get( - CONF_WRITE_INTERVAL, DEFAULT_WRITE_INTERVAL - ) self.hass.config_entries.async_update_entry( self._config_entry, data=new_data, @@ -303,13 +304,38 @@ async def async_step_change_connection( # Pre-fill with current values current_host = self._config_entry.data.get(CONF_HOST, "") current_port = self._config_entry.data.get(CONF_PORT, DEFAULT_PORT) + schema = vol.Schema( + { + vol.Required(CONF_HOST, default=current_host): str, + vol.Optional(CONF_PORT, default=current_port): int, + } + ) + + return self.async_show_form( + step_id="change_connection", + data_schema=schema, + errors=errors, + ) + + async def async_step_change_reporting( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Allow the user to change reporting settings (write interval).""" + if user_input is not None: + new_data = {**self._config_entry.data} + new_data[CONF_WRITE_INTERVAL] = user_input.get( + CONF_WRITE_INTERVAL, DEFAULT_WRITE_INTERVAL + ) + self.hass.config_entries.async_update_entry( + self._config_entry, data=new_data + ) + return await self.async_step_init() + current_write_interval = self._config_entry.data.get( CONF_WRITE_INTERVAL, DEFAULT_WRITE_INTERVAL ) schema = vol.Schema( { - vol.Required(CONF_HOST, default=current_host): str, - vol.Optional(CONF_PORT, default=current_port): int, vol.Optional( CONF_WRITE_INTERVAL, default=current_write_interval ): vol.All(vol.Coerce(int), vol.Range(min=1, max=300)), @@ -317,9 +343,8 @@ async def async_step_change_connection( ) return self.async_show_form( - step_id="change_connection", + step_id="change_reporting", data_schema=schema, - errors=errors, ) async def async_step_add_module( diff --git a/custom_components/pytap/strings.json b/custom_components/pytap/strings.json index 6b7c0d2..bca74b7 100644 --- a/custom_components/pytap/strings.json +++ b/custom_components/pytap/strings.json @@ -6,13 +6,11 @@ "description": "Provide the connection details for your Tigo gateway.", "data": { "host": "Host", - "port": "Port", - "write_interval": "Write interval (seconds)" + "port": "Port" }, "data_description": { "host": "The hostname or IP address of your Tigo gateway.", - "port": "The port number (default: 502).", - "write_interval": "How often (in seconds) to push updates to Home Assistant. Higher values reduce CPU load. Default: 5 seconds." + "port": "The port number (default: 502)." } }, "modules_menu": { @@ -131,7 +129,8 @@ "change_connection": "Change connection settings", "add_module": "Add a module", "remove_module": "Remove a module", - "done": "Save and close" + "done": "Save and close", + "change_reporting": "Change reporting settings" } }, "change_connection": { @@ -139,13 +138,11 @@ "description": "Update the IP address and port of your Tigo gateway.", "data": { "host": "Host", - "port": "Port", - "write_interval": "Write interval (seconds)" + "port": "Port" }, "data_description": { "host": "The hostname or IP address of your Tigo gateway.", - "port": "The port number (default: 502).", - "write_interval": "How often (in seconds) to push updates to Home Assistant. Higher values reduce CPU load. Default: 5 seconds." + "port": "The port number (default: 502)." } }, "add_module": { @@ -169,6 +166,16 @@ "data": { "remove_barcode": "Select module to remove" } + }, + "change_reporting": { + "title": "Reporting Settings", + "description": "Configure how often this integration pushes data to Home Assistant.", + "data": { + "write_interval": "Write interval (seconds)" + }, + "data_description": { + "write_interval": "How often (in seconds) to push updates to Home Assistant. Higher values reduce CPU load. Default: 5 seconds." + } } } } diff --git a/custom_components/pytap/translations/en.json b/custom_components/pytap/translations/en.json index 6b7c0d2..bca74b7 100644 --- a/custom_components/pytap/translations/en.json +++ b/custom_components/pytap/translations/en.json @@ -6,13 +6,11 @@ "description": "Provide the connection details for your Tigo gateway.", "data": { "host": "Host", - "port": "Port", - "write_interval": "Write interval (seconds)" + "port": "Port" }, "data_description": { "host": "The hostname or IP address of your Tigo gateway.", - "port": "The port number (default: 502).", - "write_interval": "How often (in seconds) to push updates to Home Assistant. Higher values reduce CPU load. Default: 5 seconds." + "port": "The port number (default: 502)." } }, "modules_menu": { @@ -131,7 +129,8 @@ "change_connection": "Change connection settings", "add_module": "Add a module", "remove_module": "Remove a module", - "done": "Save and close" + "done": "Save and close", + "change_reporting": "Change reporting settings" } }, "change_connection": { @@ -139,13 +138,11 @@ "description": "Update the IP address and port of your Tigo gateway.", "data": { "host": "Host", - "port": "Port", - "write_interval": "Write interval (seconds)" + "port": "Port" }, "data_description": { "host": "The hostname or IP address of your Tigo gateway.", - "port": "The port number (default: 502).", - "write_interval": "How often (in seconds) to push updates to Home Assistant. Higher values reduce CPU load. Default: 5 seconds." + "port": "The port number (default: 502)." } }, "add_module": { @@ -169,6 +166,16 @@ "data": { "remove_barcode": "Select module to remove" } + }, + "change_reporting": { + "title": "Reporting Settings", + "description": "Configure how often this integration pushes data to Home Assistant.", + "data": { + "write_interval": "Write interval (seconds)" + }, + "data_description": { + "write_interval": "How often (in seconds) to push updates to Home Assistant. Higher values reduce CPU load. Default: 5 seconds." + } } } } diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index f42f2ea..89d294d 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -16,8 +16,10 @@ CONF_MODULE_PEAK_POWER, CONF_MODULE_STRING, CONF_MODULES, + CONF_WRITE_INTERVAL, DEFAULT_PEAK_POWER, DEFAULT_PORT, + DEFAULT_WRITE_INTERVAL, DOMAIN, ) @@ -546,3 +548,121 @@ async def test_add_module_peak_power_validation(hass: HomeAssistant) -> None: CONF_MODULE_PEAK_POWER: 0, }, ) + + +# ────────────────────────────────────────────────────────── +# Options flow: reporting settings (write interval) +# ────────────────────────────────────────────────────────── + + +async def test_options_menu_includes_change_reporting(hass: HomeAssistant) -> None: + """Test the options menu includes the change_reporting option.""" + entry = _make_config_entry(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + assert "change_reporting" in result["menu_options"] + + +async def test_options_change_reporting_shows_prefilled_form( + hass: HomeAssistant, +) -> None: + """Selecting change_reporting shows a form pre-filled with the current write_interval.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + title=f"PyTap ({MOCK_HOST})", + data={ + "host": MOCK_HOST, + "port": MOCK_PORT, + CONF_WRITE_INTERVAL: 10, + CONF_MODULES: [], + }, + unique_id="pytap_test_reporting", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "change_reporting"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "change_reporting" + schema_dict = {str(k): k for k in result["data_schema"].schema} + wi_key = schema_dict["write_interval"] + assert wi_key.default() == 10 + + +async def test_options_change_reporting_updates_entry( + hass: HomeAssistant, +) -> None: + """Submitting a new write_interval saves it and returns to the menu.""" + entry = _make_config_entry(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "change_reporting"}, + ) + assert result["step_id"] == "change_reporting" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_WRITE_INTERVAL: 15}, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "init" + assert entry.data[CONF_WRITE_INTERVAL] == 15 + + +async def test_options_change_reporting_uses_default_when_absent( + hass: HomeAssistant, +) -> None: + """Pre-fill defaults to DEFAULT_WRITE_INTERVAL when entry has no write_interval.""" + entry = _make_config_entry(hass) # entry has no write_interval key + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "change_reporting"}, + ) + + schema_dict = {str(k): k for k in result["data_schema"].schema} + wi_key = schema_dict["write_interval"] + assert wi_key.default() == DEFAULT_WRITE_INTERVAL + + +async def test_options_change_reporting_preserves_modules( + hass: HomeAssistant, +) -> None: + """Changing write_interval must not alter the module list.""" + entry = _make_config_entry(hass) + original_modules = list(entry.data[CONF_MODULES]) + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "change_reporting"}, + ) + await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_WRITE_INTERVAL: 20}, + ) + + assert entry.data[CONF_MODULES] == original_modules + + +async def test_initial_setup_does_not_expose_write_interval( + hass: HomeAssistant, +) -> None: + """The initial user step schema must NOT contain write_interval.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + schema_keys = {str(k) for k in result["data_schema"].schema} + assert "write_interval" not in schema_keys From 43df1955fb55bfae86b45b77d550b93ba698314c Mon Sep 17 00:00:00 2001 From: Kyrill <1942093+poolski@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:48:55 +0100 Subject: [PATCH 3/5] fix: guard counters access and fix ASCII chars in coordinator --- custom_components/pytap/coordinator.py | 5 +++-- tests/test_config_flow.py | 4 ++-- tests/test_write_interval.py | 8 +++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/custom_components/pytap/coordinator.py b/custom_components/pytap/coordinator.py index 0a72948..bb58f80 100644 --- a/custom_components/pytap/coordinator.py +++ b/custom_components/pytap/coordinator.py @@ -357,9 +357,10 @@ def _listen(self) -> None: return _LOGGER.error("Connection error: %s", err) finally: - # Flush any data that was held back by the write interval + # Flush any data that was held back by the write interval. + # Skip updating counters here - parser may not have processed + # any data yet, and node/gateway data is what matters for sensors. if self._ha_update_pending: - self.data["counters"] = parser.counters self.hass.loop.call_soon_threadsafe( self.async_set_updated_data, dict(self.data), diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 89d294d..01bbd19 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -550,9 +550,9 @@ async def test_add_module_peak_power_validation(hass: HomeAssistant) -> None: ) -# ────────────────────────────────────────────────────────── +# ---------------------------------------------------------- # Options flow: reporting settings (write interval) -# ────────────────────────────────────────────────────────── +# ---------------------------------------------------------- async def test_options_menu_includes_change_reporting(hass: HomeAssistant) -> None: diff --git a/tests/test_write_interval.py b/tests/test_write_interval.py index da2fd9c..bbbc14a 100644 --- a/tests/test_write_interval.py +++ b/tests/test_write_interval.py @@ -60,13 +60,15 @@ def _make_infra_event(): def _make_power_event(power=100.0): """Build a minimal PowerReportEvent for barcode A-1234567B.""" + voltage_in = 30.0 + current_in = round(power / voltage_in, 4) return PowerReportEvent( gateway_id=1, node_id=1, barcode="A-1234567B", - voltage_in=30.0, - voltage_out=30.0, - current_in=3.0, + voltage_in=voltage_in, + voltage_out=voltage_in, + current_in=current_in, temperature=25.0, dc_dc_duty_cycle=0.5, rssi=-60, From e90e561bc77a5e7245b2764d0cd7f047f9bbb615 Mon Sep 17 00:00:00 2001 From: Kyrill <1942093+poolski@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:44:59 +0100 Subject: [PATCH 4/5] Average samples across reporting interval --- custom_components/pytap/coordinator.py | 62 ++++++++++- tests/test_write_interval.py | 138 ++++++++++++++++++++++++- 2 files changed, 197 insertions(+), 3 deletions(-) diff --git a/custom_components/pytap/coordinator.py b/custom_components/pytap/coordinator.py index bb58f80..d3a9b70 100644 --- a/custom_components/pytap/coordinator.py +++ b/custom_components/pytap/coordinator.py @@ -54,6 +54,20 @@ STORE_VERSION = 2 SAVE_DELAY_SECONDS = 10 +# Numeric node fields that are averaged over the write interval before being +# pushed to Home Assistant. Averaging prevents the HA database from receiving +# every raw reading while still giving a representative value per interval. +_AVERAGED_FIELDS = ( + "voltage_in", + "voltage_out", + "current_in", + "current_out", + "power", + "temperature", + "dc_dc_duty_cycle", + "rssi", +) + class _MigratingStore(Store): """Store subclass with explicit migration support. @@ -137,6 +151,9 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: # Write-interval throttle state (accessed only from the listener thread) self._last_ha_update: float = 0.0 self._ha_update_pending: bool = False + # Per-barcode buffer of raw numeric readings accumulated between HA + # writes. Flushed (averaged) each time the write interval fires. + self._reading_buffers: dict[str, list[dict[str, Any]]] = {} # Midnight reset timer handle self._midnight_reset_unsub: asyncio.TimerHandle | None = None @@ -330,15 +347,51 @@ def _listen(self) -> None: if self._process_event(event): self._ha_update_pending = True - # Push to HA at most once per write interval + # Push to HA at most once per write interval, sending + # per-barcode averages over the buffered readings rather + # than the most-recent raw snapshot. now = time.monotonic() if self._ha_update_pending and ( now - self._last_ha_update >= self._write_interval ): self.data["counters"] = parser.counters + # Build averaged node snapshots for the HA push. + # self.data["nodes"] retains the latest values for + # persistence; only the snapshot sent to HA is averaged. + snapshot_nodes = dict(self.data["nodes"]) + for barcode, readings in self._reading_buffers.items(): + node = snapshot_nodes.get(barcode) + if node is None or not readings: + continue + avg_node = dict(node) + for field in _AVERAGED_FIELDS: + values = [ + r[field] + for r in readings + if r[field] is not None + ] + avg_node[field] = ( + round(sum(values) / len(values), 3) + if values + else None + ) + # Recompute performance from averaged power + if avg_node["power"] is not None: + avg_node["performance"] = round( + ( + max(avg_node["power"], 0.0) + / avg_node["peak_power"] + ) + * 100.0, + 2, + ) + else: + avg_node["performance"] = None + snapshot_nodes[barcode] = avg_node + self._reading_buffers.clear() self.hass.loop.call_soon_threadsafe( self.async_set_updated_data, - dict(self.data), + {**self.data, "nodes": snapshot_nodes}, ) self._last_ha_update = now self._ha_update_pending = False @@ -360,7 +413,9 @@ def _listen(self) -> None: # Flush any data that was held back by the write interval. # Skip updating counters here - parser may not have processed # any data yet, and node/gateway data is what matters for sensors. + # On disconnect we discard the buffer and send the latest values. if self._ha_update_pending: + self._reading_buffers.clear() self.hass.loop.call_soon_threadsafe( self.async_set_updated_data, dict(self.data), @@ -577,6 +632,9 @@ def _handle_power_report(self, event: PowerReportEvent) -> bool: "daily_reset_date": acc.daily_reset_date, "last_update": now.isoformat(), } + self._reading_buffers.setdefault(barcode, []).append( + {field: self.data["nodes"][barcode][field] for field in _AVERAGED_FIELDS} + ) self._schedule_save() return True diff --git a/tests/test_write_interval.py b/tests/test_write_interval.py index bbbc14a..55facf1 100644 --- a/tests/test_write_interval.py +++ b/tests/test_write_interval.py @@ -19,7 +19,7 @@ DEFAULT_PORT, DEFAULT_WRITE_INTERVAL, ) -from custom_components.pytap.coordinator import PyTapDataUpdateCoordinator +from custom_components.pytap.coordinator import PyTapDataUpdateCoordinator, _AVERAGED_FIELDS from custom_components.pytap.pytap.core.events import InfrastructureEvent, PowerReportEvent @@ -155,3 +155,139 @@ def test_pending_flag_cleared_after_push(self, hass: HomeAssistant) -> None: assert coordinator._ha_update_pending is False + +def _run_averaging(node: dict, readings: list[dict]) -> dict: + """Replicate the per-interval averaging block from coordinator._listen. + + Returns an averaged copy of *node* — the same snapshot that would be + passed to async_set_updated_data when the write interval fires. + """ + avg_node = dict(node) + for field in _AVERAGED_FIELDS: + values = [r[field] for r in readings if r[field] is not None] + avg_node[field] = round(sum(values) / len(values), 3) if values else None + if avg_node["power"] is not None: + avg_node["performance"] = round( + (max(avg_node["power"], 0.0) / avg_node["peak_power"]) * 100.0, 2 + ) + else: + avg_node["performance"] = None + return avg_node + + +def _numeric_reading(**overrides) -> dict: + """Build a minimal numeric-fields dict as stored in _reading_buffers.""" + defaults = { + "voltage_in": 30.0, + "voltage_out": 30.0, + "current_in": 3.333, + "current_out": 3.333, + "power": 100.0, + "temperature": 25.0, + "dc_dc_duty_cycle": 0.5, + "rssi": -60, + } + return {**defaults, **overrides} + + +class TestAveragingMath: + """Pure unit tests for the per-interval averaging computation. + + These tests exercise _run_averaging directly — no coordinator or hass + fixture needed, so they cannot hang on HA event-loop setup. + """ + + PEAK_POWER = 455 + + def _node(self, **fields) -> dict: + return {"peak_power": self.PEAK_POWER, **_numeric_reading(**fields)} + + def test_single_reading_passthrough(self) -> None: + """A single buffered reading is returned unchanged.""" + node = self._node(power=100.0) + result = _run_averaging(node, [_numeric_reading(power=100.0)]) + assert result["power"] == pytest.approx(100.0, rel=1e-3) + + def test_two_readings_averaged(self) -> None: + """Two readings with different power values produce the correct mean.""" + node = self._node(power=200.0) + readings = [_numeric_reading(power=100.0), _numeric_reading(power=200.0)] + result = _run_averaging(node, readings) + assert result["power"] == pytest.approx(150.0, rel=1e-3) + + def test_performance_recomputed_from_averaged_power(self) -> None: + """Performance is derived from the averaged power, not the last raw reading.""" + node = self._node(power=self.PEAK_POWER) + readings = [ + _numeric_reading(power=0.0), + _numeric_reading(power=self.PEAK_POWER), + ] + result = _run_averaging(node, readings) + expected = round((self.PEAK_POWER / 2 / self.PEAK_POWER) * 100.0, 2) + assert result["performance"] == pytest.approx(expected, rel=1e-3) + + def test_none_values_excluded_from_average(self) -> None: + """None entries for a field are ignored; the mean is over valid readings only.""" + node = self._node(power=100.0) + readings = [ + _numeric_reading(power=100.0), + {field: None for field in _AVERAGED_FIELDS}, + ] + result = _run_averaging(node, readings) + assert result["power"] == pytest.approx(100.0, rel=1e-3) + + def test_all_none_values_produce_none(self) -> None: + """If every reading for a field is None the averaged field is also None.""" + node = {"peak_power": self.PEAK_POWER, **{f: None for f in _AVERAGED_FIELDS}} + result = _run_averaging(node, [{f: None for f in _AVERAGED_FIELDS}]) + assert result["power"] is None + assert result["performance"] is None + + +class TestBufferPopulation: + """Test that _handle_power_report populates _reading_buffers correctly. + + These tests verify the wiring between event processing and the buffer, + without exercising the write-interval flush path. + """ + + def test_buffer_populated_on_power_reports(self, hass: HomeAssistant) -> None: + """Each processed power report appends one entry to the barcode's buffer.""" + entry = _make_entry(hass) + coordinator = PyTapDataUpdateCoordinator(hass, entry) + coordinator._schedule_save = MagicMock() + coordinator._handle_infrastructure(_make_infra_event()) + + coordinator._process_event(_make_power_event(power=100.0)) + coordinator._process_event(_make_power_event(power=200.0)) + + assert "A-1234567B" in coordinator._reading_buffers + assert len(coordinator._reading_buffers["A-1234567B"]) == 2 + + def test_buffer_entry_contains_all_averaged_fields(self, hass: HomeAssistant) -> None: + """Each buffer entry has exactly the fields listed in _AVERAGED_FIELDS.""" + entry = _make_entry(hass) + coordinator = PyTapDataUpdateCoordinator(hass, entry) + coordinator._schedule_save = MagicMock() + coordinator._handle_infrastructure(_make_infra_event()) + + coordinator._process_event(_make_power_event(power=100.0)) + + reading = coordinator._reading_buffers["A-1234567B"][0] + assert set(reading.keys()) == set(_AVERAGED_FIELDS) + + def test_data_nodes_retains_latest_raw_value(self, hass: HomeAssistant) -> None: + """self.data['nodes'] always reflects the most-recent raw reading.""" + entry = _make_entry(hass) + coordinator = PyTapDataUpdateCoordinator(hass, entry) + coordinator._schedule_save = MagicMock() + coordinator._handle_infrastructure(_make_infra_event()) + + coordinator._process_event(_make_power_event(power=100.0)) + coordinator._process_event(_make_power_event(power=200.0)) + + # power ≈ 200 (computed from current_out * voltage_out inside the event) + assert coordinator.data["nodes"]["A-1234567B"]["power"] == pytest.approx( + 200.0, rel=0.01 + ) + From c59d644ea5cc978576eb9bca0f584223ba7f2398 Mon Sep 17 00:00:00 2001 From: Kyrill <1942093+poolski@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:06:29 +0100 Subject: [PATCH 5/5] Emit averaged readings per-node --- custom_components/pytap/coordinator.py | 82 +++++---- tests/test_write_interval.py | 226 +++++++++++++++++-------- 2 files changed, 199 insertions(+), 109 deletions(-) diff --git a/custom_components/pytap/coordinator.py b/custom_components/pytap/coordinator.py index d3a9b70..1027147 100644 --- a/custom_components/pytap/coordinator.py +++ b/custom_components/pytap/coordinator.py @@ -355,43 +355,25 @@ def _listen(self) -> None: now - self._last_ha_update >= self._write_interval ): self.data["counters"] = parser.counters - # Build averaged node snapshots for the HA push. - # self.data["nodes"] retains the latest values for - # persistence; only the snapshot sent to HA is averaged. - snapshot_nodes = dict(self.data["nodes"]) - for barcode, readings in self._reading_buffers.items(): - node = snapshot_nodes.get(barcode) - if node is None or not readings: - continue - avg_node = dict(node) - for field in _AVERAGED_FIELDS: - values = [ - r[field] - for r in readings - if r[field] is not None - ] - avg_node[field] = ( - round(sum(values) / len(values), 3) - if values - else None - ) - # Recompute performance from averaged power - if avg_node["power"] is not None: - avg_node["performance"] = round( - ( - max(avg_node["power"], 0.0) - / avg_node["peak_power"] - ) - * 100.0, - 2, - ) - else: - avg_node["performance"] = None - snapshot_nodes[barcode] = avg_node + snapshot = self._build_averaged_snapshot() + per_node_counts = { + barcode: len(readings) + for barcode, readings in self._reading_buffers.items() + if readings and barcode in snapshot["nodes"] + } + _LOGGER.debug( + "HA update: %d node(s) — %s", + len(per_node_counts), + ", ".join( + f"{snapshot['nodes'][b].get('name', b)}: " + f"{n} reading(s)" + for b, n in per_node_counts.items() + ), + ) self._reading_buffers.clear() self.hass.loop.call_soon_threadsafe( self.async_set_updated_data, - {**self.data, "nodes": snapshot_nodes}, + snapshot, ) self._last_ha_update = now self._ha_update_pending = False @@ -789,6 +771,38 @@ def _handle_topology(self, event: TopologyEvent) -> bool: # Persistence helpers # ------------------------------------------------------------------- + def _build_averaged_snapshot(self) -> dict[str, Any]: + """Return the data snapshot to push to Home Assistant. + + For each barcode in ``_reading_buffers``, the buffered numeric + readings are averaged and written into a copy of the node dict. + ``self.data["nodes"]`` is **not** mutated — the raw latest values + are preserved there for persistence; only the returned snapshot + carries averaged values. + + Must be called before ``_reading_buffers`` is cleared. + """ + snapshot_nodes = dict(self.data["nodes"]) + for barcode, readings in self._reading_buffers.items(): + node = snapshot_nodes.get(barcode) + if node is None or not readings: + continue + avg_node = dict(node) + for field in _AVERAGED_FIELDS: + values = [r[field] for r in readings if r[field] is not None] + avg_node[field] = ( + round(sum(values) / len(values), 3) if values else None + ) + if avg_node["power"] is not None: + avg_node["performance"] = round( + (max(avg_node["power"], 0.0) / avg_node["peak_power"]) * 100.0, + 2, + ) + else: + avg_node["performance"] = None + snapshot_nodes[barcode] = avg_node + return {**self.data, "nodes": snapshot_nodes} + def _build_node_payload( self, barcode: str, diff --git a/tests/test_write_interval.py b/tests/test_write_interval.py index 55facf1..46dbc6a 100644 --- a/tests/test_write_interval.py +++ b/tests/test_write_interval.py @@ -156,92 +156,168 @@ def test_pending_flag_cleared_after_push(self, hass: HomeAssistant) -> None: assert coordinator._ha_update_pending is False -def _run_averaging(node: dict, readings: list[dict]) -> dict: - """Replicate the per-interval averaging block from coordinator._listen. +MOCK_MODULES_TWO = [ + { + CONF_MODULE_STRING: "A", + CONF_MODULE_NAME: "Panel_01", + CONF_MODULE_BARCODE: "A-1234567B", + CONF_MODULE_PEAK_POWER: 455, + }, + { + CONF_MODULE_STRING: "A", + CONF_MODULE_NAME: "Panel_02", + CONF_MODULE_BARCODE: "B-9876543C", + CONF_MODULE_PEAK_POWER: 455, + }, +] - Returns an averaged copy of *node* — the same snapshot that would be - passed to async_set_updated_data when the write interval fires. - """ - avg_node = dict(node) - for field in _AVERAGED_FIELDS: - values = [r[field] for r in readings if r[field] is not None] - avg_node[field] = round(sum(values) / len(values), 3) if values else None - if avg_node["power"] is not None: - avg_node["performance"] = round( - (max(avg_node["power"], 0.0) / avg_node["peak_power"]) * 100.0, 2 - ) - else: - avg_node["performance"] = None - return avg_node - - -def _numeric_reading(**overrides) -> dict: - """Build a minimal numeric-fields dict as stored in _reading_buffers.""" - defaults = { - "voltage_in": 30.0, - "voltage_out": 30.0, - "current_in": 3.333, - "current_out": 3.333, - "power": 100.0, - "temperature": 25.0, - "dc_dc_duty_cycle": 0.5, - "rssi": -60, + +def _make_entry_two_modules(hass): + entry = MagicMock() + entry.data = { + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + CONF_MODULES: MOCK_MODULES_TWO, } - return {**defaults, **overrides} + entry.entry_id = "test_snapshot_entry" + entry.options = {} + return entry -class TestAveragingMath: - """Pure unit tests for the per-interval averaging computation. +class TestAveragedSnapshot: + """Tests for coordinator._build_averaged_snapshot(). - These tests exercise _run_averaging directly — no coordinator or hass - fixture needed, so they cannot hang on HA event-loop setup. + These call the actual production method — not a duplicate — and verify + that the snapshot data emitted to HA carries per-node averages. """ + BARCODE_A = "A-1234567B" + BARCODE_B = "B-9876543C" PEAK_POWER = 455 - def _node(self, **fields) -> dict: - return {"peak_power": self.PEAK_POWER, **_numeric_reading(**fields)} - - def test_single_reading_passthrough(self) -> None: - """A single buffered reading is returned unchanged.""" - node = self._node(power=100.0) - result = _run_averaging(node, [_numeric_reading(power=100.0)]) - assert result["power"] == pytest.approx(100.0, rel=1e-3) - - def test_two_readings_averaged(self) -> None: - """Two readings with different power values produce the correct mean.""" - node = self._node(power=200.0) - readings = [_numeric_reading(power=100.0), _numeric_reading(power=200.0)] - result = _run_averaging(node, readings) - assert result["power"] == pytest.approx(150.0, rel=1e-3) - - def test_performance_recomputed_from_averaged_power(self) -> None: - """Performance is derived from the averaged power, not the last raw reading.""" - node = self._node(power=self.PEAK_POWER) - readings = [ - _numeric_reading(power=0.0), - _numeric_reading(power=self.PEAK_POWER), - ] - result = _run_averaging(node, readings) + def _reading(self, power: float) -> dict: + """Build a numeric-fields dict as stored in _reading_buffers.""" + current = round(power / 30.0, 4) + return { + "voltage_in": 30.0, + "voltage_out": 30.0, + "current_in": current, + "current_out": current, + "power": power, + "temperature": 25.0, + "dc_dc_duty_cycle": 0.5, + "rssi": -60, + } + + def _seed(self, coordinator, barcode: str, readings: list[dict]) -> None: + """Plant a node entry and buffer readings directly.""" + coordinator.data["nodes"][barcode] = { + "name": barcode, + "peak_power": self.PEAK_POWER, + **readings[-1], + "performance": None, + "daily_energy_wh": 0.0, + "total_energy_wh": 0.0, + "readings_today": len(readings), + "daily_reset_date": "", + "last_update": None, + } + coordinator._reading_buffers[barcode] = list(readings) + + def test_single_reading_passthrough(self, hass: HomeAssistant) -> None: + """A single buffered reading is passed through to the snapshot unchanged.""" + coordinator = PyTapDataUpdateCoordinator(hass, _make_entry(hass)) + coordinator._schedule_save = MagicMock() + self._seed(coordinator, self.BARCODE_A, [self._reading(100.0)]) + + snapshot = coordinator._build_averaged_snapshot() + + assert snapshot["nodes"][self.BARCODE_A]["power"] == pytest.approx(100.0, rel=1e-3) + + def test_two_readings_averaged(self, hass: HomeAssistant) -> None: + """Two readings produce the correct per-node mean in the snapshot.""" + coordinator = PyTapDataUpdateCoordinator(hass, _make_entry(hass)) + coordinator._schedule_save = MagicMock() + self._seed(coordinator, self.BARCODE_A, [self._reading(100.0), self._reading(200.0)]) + + snapshot = coordinator._build_averaged_snapshot() + + assert snapshot["nodes"][self.BARCODE_A]["power"] == pytest.approx(150.0, rel=1e-3) + + def test_performance_recomputed_from_averaged_power(self, hass: HomeAssistant) -> None: + """Performance in the snapshot is derived from the averaged power.""" + coordinator = PyTapDataUpdateCoordinator(hass, _make_entry(hass)) + coordinator._schedule_save = MagicMock() + self._seed( + coordinator, + self.BARCODE_A, + [self._reading(0.0), self._reading(self.PEAK_POWER)], + ) + + snapshot = coordinator._build_averaged_snapshot() + expected = round((self.PEAK_POWER / 2 / self.PEAK_POWER) * 100.0, 2) - assert result["performance"] == pytest.approx(expected, rel=1e-3) - - def test_none_values_excluded_from_average(self) -> None: - """None entries for a field are ignored; the mean is over valid readings only.""" - node = self._node(power=100.0) - readings = [ - _numeric_reading(power=100.0), - {field: None for field in _AVERAGED_FIELDS}, + assert snapshot["nodes"][self.BARCODE_A]["performance"] == pytest.approx( + expected, rel=1e-3 + ) + + def test_none_values_excluded_from_average(self, hass: HomeAssistant) -> None: + """None entries for a field are excluded; mean is over valid readings only.""" + coordinator = PyTapDataUpdateCoordinator(hass, _make_entry(hass)) + coordinator._schedule_save = MagicMock() + self._seed(coordinator, self.BARCODE_A, [self._reading(100.0)]) + coordinator._reading_buffers[self.BARCODE_A].append( + {f: None for f in _AVERAGED_FIELDS} + ) + + snapshot = coordinator._build_averaged_snapshot() + + assert snapshot["nodes"][self.BARCODE_A]["power"] == pytest.approx(100.0, rel=1e-3) + + def test_all_none_values_produce_none(self, hass: HomeAssistant) -> None: + """If every reading for a field is None the snapshot field is also None.""" + coordinator = PyTapDataUpdateCoordinator(hass, _make_entry(hass)) + coordinator._schedule_save = MagicMock() + coordinator.data["nodes"][self.BARCODE_A] = { + "name": self.BARCODE_A, + "peak_power": self.PEAK_POWER, + **{f: None for f in _AVERAGED_FIELDS}, + } + coordinator._reading_buffers[self.BARCODE_A] = [ + {f: None for f in _AVERAGED_FIELDS} ] - result = _run_averaging(node, readings) - assert result["power"] == pytest.approx(100.0, rel=1e-3) - - def test_all_none_values_produce_none(self) -> None: - """If every reading for a field is None the averaged field is also None.""" - node = {"peak_power": self.PEAK_POWER, **{f: None for f in _AVERAGED_FIELDS}} - result = _run_averaging(node, [{f: None for f in _AVERAGED_FIELDS}]) - assert result["power"] is None - assert result["performance"] is None + + snapshot = coordinator._build_averaged_snapshot() + + assert snapshot["nodes"][self.BARCODE_A]["power"] is None + assert snapshot["nodes"][self.BARCODE_A]["performance"] is None + + def test_per_node_isolation(self, hass: HomeAssistant) -> None: + """Readings from different nodes MUST NOT affect each other's averages.""" + coordinator = PyTapDataUpdateCoordinator(hass, _make_entry_two_modules(hass)) + coordinator._schedule_save = MagicMock() + # Node A: 100 + 300 → mean 200 + self._seed(coordinator, self.BARCODE_A, [self._reading(100.0), self._reading(300.0)]) + # Node B: 50 + 50 → mean 50 (unchanged regardless of Node A's readings) + self._seed(coordinator, self.BARCODE_B, [self._reading(50.0), self._reading(50.0)]) + + snapshot = coordinator._build_averaged_snapshot() + + assert snapshot["nodes"][self.BARCODE_A]["power"] == pytest.approx(200.0, rel=1e-3) + assert snapshot["nodes"][self.BARCODE_B]["power"] == pytest.approx(50.0, rel=1e-3) + + def test_self_data_nodes_not_mutated(self, hass: HomeAssistant) -> None: + """_build_averaged_snapshot must not mutate self.data['nodes'].""" + coordinator = PyTapDataUpdateCoordinator(hass, _make_entry(hass)) + coordinator._schedule_save = MagicMock() + self._seed(coordinator, self.BARCODE_A, [self._reading(100.0), self._reading(200.0)]) + + coordinator._build_averaged_snapshot() + + # Latest raw value (200) must be preserved for persistence + assert coordinator.data["nodes"][self.BARCODE_A]["power"] == pytest.approx( + 200.0, rel=1e-3 + ) class TestBufferPopulation: