diff --git a/custom_components/pytap/config_flow.py b/custom_components/pytap/config_flow.py index 9d648f5..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 @@ -32,8 +33,10 @@ CONF_MODULE_PEAK_POWER, CONF_MODULE_STRING, CONF_MODULES, + CONF_WRITE_INTERVAL, DEFAULT_PEAK_POWER, DEFAULT_PORT, + DEFAULT_WRITE_INTERVAL, DOMAIN, ) @@ -242,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), }, @@ -308,6 +317,36 @@ async def async_step_change_connection( 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.Optional( + CONF_WRITE_INTERVAL, default=current_write_interval + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=300)), + } + ) + + return self.async_show_form( + step_id="change_reporting", + data_schema=schema, + ) + async def async_step_add_module( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: 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..1027147 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, @@ -52,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. @@ -94,6 +110,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 +148,13 @@ 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 + # 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 @@ -318,14 +344,39 @@ 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, 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 + 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, + snapshot, + ) + self._last_ha_update = now + self._ha_update_pending = False elif ( RECONNECT_TIMEOUT > 0 and (time.monotonic() - last_data_time) > RECONNECT_TIMEOUT @@ -341,6 +392,17 @@ def _listen(self) -> None: return _LOGGER.error("Connection error: %s", err) finally: + # 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), + ) + self._ha_update_pending = False with self._source_lock: if self._source is not None: try: @@ -552,6 +614,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 @@ -706,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/custom_components/pytap/strings.json b/custom_components/pytap/strings.json index 90eb38e..bca74b7 100644 --- a/custom_components/pytap/strings.json +++ b/custom_components/pytap/strings.json @@ -129,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": { @@ -165,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 90eb38e..bca74b7 100644 --- a/custom_components/pytap/translations/en.json +++ b/custom_components/pytap/translations/en.json @@ -129,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": { @@ -165,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..01bbd19 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 diff --git a/tests/test_write_interval.py b/tests/test_write_interval.py new file mode 100644 index 0000000..46dbc6a --- /dev/null +++ b/tests/test_write_interval.py @@ -0,0 +1,369 @@ +"""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, _AVERAGED_FIELDS +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.""" + voltage_in = 30.0 + current_in = round(power / voltage_in, 4) + return PowerReportEvent( + gateway_id=1, + node_id=1, + barcode="A-1234567B", + voltage_in=voltage_in, + voltage_out=voltage_in, + current_in=current_in, + 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 + + +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, + }, +] + + +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, + } + entry.entry_id = "test_snapshot_entry" + entry.options = {} + return entry + + +class TestAveragedSnapshot: + """Tests for coordinator._build_averaged_snapshot(). + + 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 _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 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} + ] + + 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: + """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 + ) +