Skip to content

Commit d9960ae

Browse files
authored
Merge pull request #24 from azebro/dev
Add configurable reporting interval and live reporting features
2 parents 9963d1e + 641c805 commit d9960ae

14 files changed

Lines changed: 467 additions & 120 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"features": {
3+
"ghcr.io/devcontainers/features/github-cli:1": {
4+
"version": "1.1.0",
5+
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
6+
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
7+
}
8+
}
9+
}

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Added
6+
7+
- **Configurable reporting mode** — New "Live reporting" toggle (default on) under **Configure → Change reporting settings**. When disabled, readings are cached and delivered as a time-weighted average every *reporting interval* (5–300 s, default 30), reducing CPU and recorder load on installations with many panels. Energy totals are always integrated from raw readings, so only the instantaneous values are averaged.
8+
9+
### Changed
10+
11+
- Reporting interval default changed from 5 to 30 seconds; allowed range tightened from 1–300 to 5–300 seconds.
12+
- Config entry schema bumped to version 5 (adds `live_reporting`, default `True`, via automatic migration).
13+
- The reporting interval is now collected on its own follow-up screen that appears only when live reporting is turned off, so the interval is never shown (effectively greyed out) while live reporting is enabled.
14+
15+
### Fixed
16+
17+
- Live reporting no longer buffers readings internally, preventing unbounded memory growth over time in the default mode.
18+
- Live reporting now publishes an immutable copy of node data to Home Assistant, avoiding rare races where consumers (sensors, diagnostics) could observe the data changing mid-read.
19+
- Averaged interval mode now flushes buffered readings on schedule during quiet periods (e.g. at dusk), instead of waiting for the next reading or a reconnect.
20+
- The version 5 migration clamps any pre-existing reporting interval below 5 seconds up to the new minimum, so the reporting settings form stays submittable after upgrade.
21+
322
## [1.2.0] - 2026-03-07
423

524
### Changed

‎README.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Please note that this integration requires RS485 to TCP converter that needs to
1515
## Features
1616

1717
- **Real-time streaming** — Push-based data delivery with sub-second latency (no polling).
18+
- **Configurable reporting** — Choose **live reporting** (default; every reading delivered as it arrives) or a **time-weighted averaged interval** that caches readings and pushes a single representative value every few seconds to reduce CPU and recorder load on large installations.
1819
- **Per-optimizer sensors** — 12 sensor entities per Tigo TS4 module: performance,
1920
power, voltage in/out, current in/out, temperature, DC-DC duty cycle, RSSI,
2021
daily energy, total energy, and readings today.
@@ -120,10 +121,22 @@ Check your Home Assistant logs after the gateway has been running for a while (u
120121
Go to **Settings → Devices & Services → PyTap → Configure** to:
121122

122123
- **Change connection** — Update the gateway IP address and port.
124+
- **Change reporting settings** — Toggle live reporting or set the averaged reporting interval (see [Reporting modes](#reporting-modes)).
123125
- **Add** new optimizer modules (if the barcode was previously seen on the bus, it resolves instantly).
124126
- **Remove** modules you no longer want to track.
125127
- **Save and close** to apply changes.
126128

129+
### Reporting modes
130+
131+
PyTap can deliver readings to Home Assistant in two ways, selectable under **Configure → Change reporting settings**:
132+
133+
- **Live reporting (default)** — Each reading is pushed to Home Assistant as soon as it arrives from the gateway, giving sub-second latency. Best for most installations.
134+
- **Averaged interval** — When live reporting is turned off, readings are cached and a **time-weighted average** is pushed once per *reporting interval* (5–300 seconds, default 30). Weighting each reading by how long it remained the latest value yields a representative average even when readings arrive at uneven intervals. This reduces event-loop, CPU, and recorder/database load on installations with many panels.
135+
136+
> **Setting the interval:** The reporting interval only applies in averaged mode, so it is hidden while **Live reporting** is enabled. To change it, open **Change reporting settings**, untick **Live reporting**, and click **Submit** — a follow-up **Reporting Interval** screen then lets you enter the value. Leaving **Live reporting** ticked skips that screen entirely.
137+
138+
Energy totals (daily and lifetime) are always integrated from the raw readings, so the reporting mode only affects the instantaneous values (power, voltage, current, temperature) shown on dashboards — never the accumulated energy figures.
139+
127140
### Diagnostics Download
128141

129142
PyTap diagnostics are available from the integration page:
@@ -157,7 +170,7 @@ PyTap Coordinator
157170
└── Sensor entities update in real time
158171
```
159172

160-
PyTap uses a background listener thread that streams data from the gateway, parses protocol frames, and dispatches events to the Home Assistant event loop. Only events matching your configured barcodes create or update sensor entities.
173+
PyTap uses a background listener thread that streams data from the gateway, parses protocol frames, and dispatches events to the Home Assistant event loop. Only events matching your configured barcodes create or update sensor entities. In the default **live reporting** mode each update is forwarded immediately; in **averaged interval** mode updates are buffered and flushed as a time-weighted average every few seconds (see [Reporting modes](#reporting-modes)).
161174

162175
On startup, PyTap restores persisted coordinator state (barcode mappings, energy accumulators, and last node snapshots). Sensor entities also use Home Assistant restore fallback if needed, so a restart during low/no production does not force entities into unavailable when prior data exists.
163176

‎custom_components/pytap/__init__.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@
2020
CONF_MODULE_PEAK_POWER,
2121
CONF_MODULE_STRING,
2222
CONF_MODULES,
23+
CONF_WRITE_INTERVAL,
2324
DEFAULT_LIVE_REPORTING,
2425
DEFAULT_PEAK_POWER,
2526
DEFAULT_STRING_NAME,
27+
DEFAULT_WRITE_INTERVAL,
2628
DOMAIN,
29+
MAX_WRITE_INTERVAL,
30+
MIN_WRITE_INTERVAL,
2731
)
2832
from .coordinator import PyTapDataUpdateCoordinator
2933

@@ -162,6 +166,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
162166
new_data = {**entry.data}
163167
if CONF_LIVE_REPORTING not in new_data:
164168
new_data[CONF_LIVE_REPORTING] = DEFAULT_LIVE_REPORTING
169+
# The v5 reporting form constrains write_interval to
170+
# [MIN_WRITE_INTERVAL, MAX_WRITE_INTERVAL]. Earlier versions allowed
171+
# smaller values, so clamp any stale value to keep the options form
172+
# submittable after upgrade.
173+
if CONF_WRITE_INTERVAL in new_data:
174+
try:
175+
interval = int(new_data[CONF_WRITE_INTERVAL])
176+
except (TypeError, ValueError):
177+
interval = DEFAULT_WRITE_INTERVAL
178+
new_data[CONF_WRITE_INTERVAL] = max(
179+
MIN_WRITE_INTERVAL, min(MAX_WRITE_INTERVAL, interval)
180+
)
165181
hass.config_entries.async_update_entry(entry, data=new_data, version=5)
166182

167183
return True

‎custom_components/pytap/config_flow.py‎

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
DEFAULT_PORT,
4141
DEFAULT_WRITE_INTERVAL,
4242
DOMAIN,
43+
MAX_WRITE_INTERVAL,
44+
MIN_WRITE_INTERVAL,
4345
)
4446

4547
_LOGGER = logging.getLogger(__name__)
@@ -322,48 +324,73 @@ async def async_step_change_connection(
322324
async def async_step_change_reporting(
323325
self, user_input: dict[str, Any] | None = None
324326
) -> ConfigFlowResult:
325-
"""Allow the user to change reporting settings.
327+
"""Toggle live reporting.
326328
327-
Presents a 'Live reporting' checkbox (default ON) that delivers
328-
readings as they arrive. When unchecked, a write_interval field
329-
appears and readings are cached, then pushed as a time-weighted
330-
average every ``write_interval`` seconds.
329+
Live reporting is presented on its own step with just the checkbox.
330+
Home Assistant's static forms cannot grey out a field reactively, so
331+
the reporting interval is collected on a separate ``set_interval``
332+
step that is only reached when live reporting is turned off. When it
333+
is left on, the interval is irrelevant and never shown.
331334
"""
332335
if user_input is not None:
333-
new_data = {**self._config_entry.data}
334336
live = user_input.get(CONF_LIVE_REPORTING, DEFAULT_LIVE_REPORTING)
335-
new_data[CONF_LIVE_REPORTING] = live
336337
if live:
337-
# Live mode — clear any stored interval
338+
# Live mode — clear any stored interval and finish.
339+
new_data = {**self._config_entry.data}
340+
new_data[CONF_LIVE_REPORTING] = True
338341
new_data.pop(CONF_WRITE_INTERVAL, None)
339-
else:
340-
new_data[CONF_WRITE_INTERVAL] = user_input.get(
341-
CONF_WRITE_INTERVAL, DEFAULT_WRITE_INTERVAL
342+
self.hass.config_entries.async_update_entry(
343+
self._config_entry, data=new_data
342344
)
345+
return await self.async_step_init()
346+
# Interval mode — collect the interval on the next step.
347+
return await self.async_step_set_interval()
348+
349+
current_live = self._config_entry.data.get(
350+
CONF_LIVE_REPORTING, DEFAULT_LIVE_REPORTING
351+
)
352+
schema = vol.Schema(
353+
{
354+
vol.Optional(CONF_LIVE_REPORTING, default=current_live): bool,
355+
}
356+
)
357+
358+
return self.async_show_form(
359+
step_id="change_reporting",
360+
data_schema=schema,
361+
)
362+
363+
async def async_step_set_interval(
364+
self, user_input: dict[str, Any] | None = None
365+
) -> ConfigFlowResult:
366+
"""Collect the reporting interval (only reached when live reporting is off)."""
367+
if user_input is not None:
368+
new_data = {**self._config_entry.data}
369+
new_data[CONF_LIVE_REPORTING] = False
370+
new_data[CONF_WRITE_INTERVAL] = user_input.get(
371+
CONF_WRITE_INTERVAL, DEFAULT_WRITE_INTERVAL
372+
)
343373
self.hass.config_entries.async_update_entry(
344374
self._config_entry, data=new_data
345375
)
346376
return await self.async_step_init()
347377

348-
current_live = self._config_entry.data.get(
349-
CONF_LIVE_REPORTING, DEFAULT_LIVE_REPORTING
350-
)
351378
current_write_interval = self._config_entry.data.get(
352379
CONF_WRITE_INTERVAL, DEFAULT_WRITE_INTERVAL
353380
)
354381
schema = vol.Schema(
355382
{
356-
vol.Optional(
357-
CONF_LIVE_REPORTING, default=current_live
358-
): bool,
359383
vol.Optional(
360384
CONF_WRITE_INTERVAL, default=current_write_interval
361-
): vol.All(vol.Coerce(int), vol.Range(min=5, max=300)),
385+
): vol.All(
386+
vol.Coerce(int),
387+
vol.Range(min=MIN_WRITE_INTERVAL, max=MAX_WRITE_INTERVAL),
388+
),
362389
}
363390
)
364391

365392
return self.async_show_form(
366-
step_id="change_reporting",
393+
step_id="set_interval",
367394
data_schema=schema,
368395
)
369396

‎custom_components/pytap/const.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
DEFAULT_SCAN_INTERVAL = 30
2424
DEFAULT_LIVE_REPORTING = True
2525
DEFAULT_WRITE_INTERVAL = 30
26+
MIN_WRITE_INTERVAL = 5
27+
MAX_WRITE_INTERVAL = 300
2628
RECONNECT_TIMEOUT = 60
2729
RECONNECT_DELAY = 5
2830
RECONNECT_RETRIES = 0

‎custom_components/pytap/coordinator.py‎

Lines changed: 86 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,9 @@ def _perform_midnight_reset(self) -> None:
293293
"""Reset all daily accumulators and push updated data to sensors."""
294294
self._midnight_reset_unsub = None
295295
today = dt_util.now().date().isoformat()
296-
_LOGGER.info("Midnight daily reset — resetting daily accumulators for %s", today)
296+
_LOGGER.info(
297+
"Midnight daily reset — resetting daily accumulators for %s", today
298+
)
297299

298300
data_changed = False
299301
for barcode, acc in self._energy_state.items():
@@ -359,57 +361,27 @@ def _listen(self) -> None:
359361

360362
if self._live_reporting:
361363
# Live mode — push every change immediately
362-
if self._ha_update_pending:
363-
self.data["counters"] = parser.counters
364-
self.hass.loop.call_soon_threadsafe(
365-
self.async_set_updated_data,
366-
dict(self.data),
367-
)
368-
self._ha_update_pending = False
364+
self._push_live_update(parser)
369365
else:
370366
# Interval mode — buffer readings and push
371367
# time-weighted averages once per write_interval.
372-
now = time.monotonic()
373-
if self._ha_update_pending and (
374-
now - self._last_ha_update
375-
>= self._write_interval
376-
):
377-
self.data["counters"] = parser.counters
378-
snapshot = self._build_averaged_snapshot(
379-
now
380-
)
381-
per_node_counts = {
382-
barcode: len(readings)
383-
for barcode, readings
384-
in self._reading_buffers.items()
385-
if readings
386-
and barcode in snapshot["nodes"]
387-
}
388-
_LOGGER.debug(
389-
"HA update: %d node(s) — %s",
390-
len(per_node_counts),
391-
", ".join(
392-
f"{snapshot['nodes'][b].get('name', b)}: "
393-
f"{n} reading(s)"
394-
for b, n in per_node_counts.items()
395-
),
396-
)
397-
self._reading_buffers.clear()
398-
self.hass.loop.call_soon_threadsafe(
399-
self.async_set_updated_data,
400-
snapshot,
401-
)
402-
self._last_ha_update = now
403-
self._ha_update_pending = False
404-
elif (
405-
RECONNECT_TIMEOUT > 0
406-
and (time.monotonic() - last_data_time) > RECONNECT_TIMEOUT
407-
):
408-
_LOGGER.warning(
409-
"No data from gateway for %ds, reconnecting",
410-
RECONNECT_TIMEOUT,
411-
)
412-
break
368+
self._maybe_flush_interval(parser, time.monotonic())
369+
else:
370+
# No data this read cycle (socket read timeout).
371+
now = time.monotonic()
372+
if not self._live_reporting:
373+
# Flush buffered readings on schedule so sensors
374+
# keep updating during production lulls.
375+
self._maybe_flush_interval(parser, now)
376+
if (
377+
RECONNECT_TIMEOUT > 0
378+
and (now - last_data_time) > RECONNECT_TIMEOUT
379+
):
380+
_LOGGER.warning(
381+
"No data from gateway for %ds, reconnecting",
382+
RECONNECT_TIMEOUT,
383+
)
384+
break
413385

414386
except Exception as err:
415387
if self._stop_event.is_set():
@@ -429,7 +401,7 @@ def _listen(self) -> None:
429401
else:
430402
self.hass.loop.call_soon_threadsafe(
431403
self.async_set_updated_data,
432-
dict(self.data),
404+
{**self.data, "nodes": dict(self.data["nodes"])},
433405
)
434406
self._ha_update_pending = False
435407
with self._source_lock:
@@ -460,6 +432,58 @@ def _listen(self) -> None:
460432
return
461433
time.sleep(0.1)
462434

435+
def _push_live_update(self, parser: Any) -> None:
436+
"""Push an immediate snapshot to HA (live reporting mode).
437+
438+
Publishes an *immutable* copy of the ``nodes`` mapping so the listener
439+
thread can keep mutating ``self.data["nodes"]`` without HA-side
440+
consumers (sensors, diagnostics) ever iterating a dict that changes
441+
size mid-read. Runs in the executor thread.
442+
"""
443+
if not self._ha_update_pending:
444+
return
445+
self.data["counters"] = parser.counters
446+
self.hass.loop.call_soon_threadsafe(
447+
self.async_set_updated_data,
448+
{**self.data, "nodes": dict(self.data["nodes"])},
449+
)
450+
self._ha_update_pending = False
451+
452+
def _maybe_flush_interval(self, parser: Any, now: float) -> None:
453+
"""Flush buffered readings as a time-weighted average (interval mode).
454+
455+
Pushes a snapshot once the write interval has elapsed since the last
456+
update. Called both when data arrives and during idle read cycles so
457+
buffered readings are not stranded when the stream goes quiet. Runs
458+
in the executor thread.
459+
"""
460+
if not self._ha_update_pending or (
461+
now - self._last_ha_update < self._write_interval
462+
):
463+
return
464+
self.data["counters"] = parser.counters
465+
snapshot = self._build_averaged_snapshot(now)
466+
per_node_counts = {
467+
barcode: len(readings)
468+
for barcode, readings in self._reading_buffers.items()
469+
if readings and barcode in snapshot["nodes"]
470+
}
471+
_LOGGER.debug(
472+
"HA update: %d node(s) — %s",
473+
len(per_node_counts),
474+
", ".join(
475+
f"{snapshot['nodes'][b].get('name', b)}: {n} reading(s)"
476+
for b, n in per_node_counts.items()
477+
),
478+
)
479+
self._reading_buffers.clear()
480+
self.hass.loop.call_soon_threadsafe(
481+
self.async_set_updated_data,
482+
snapshot,
483+
)
484+
self._last_ha_update = now
485+
self._ha_update_pending = False
486+
463487
def _init_mappings_from_parser(self, parser: Any) -> None:
464488
"""Pre-populate barcode/node mappings from the parser's persistent state.
465489
@@ -643,12 +667,19 @@ def _handle_power_report(self, event: PowerReportEvent) -> bool:
643667
"daily_reset_date": acc.daily_reset_date,
644668
"last_update": now.isoformat(),
645669
}
646-
self._reading_buffers.setdefault(barcode, []).append(
647-
(
648-
time.monotonic(),
649-
{field: self.data["nodes"][barcode][field] for field in _AVERAGED_FIELDS},
670+
# Only buffer readings in interval/averaged mode. In live mode the
671+
# raw values are pushed immediately, so buffering would grow without
672+
# bound and never be consumed.
673+
if not self._live_reporting:
674+
self._reading_buffers.setdefault(barcode, []).append(
675+
(
676+
time.monotonic(),
677+
{
678+
field: self.data["nodes"][barcode][field]
679+
for field in _AVERAGED_FIELDS
680+
},
681+
)
650682
)
651-
)
652683
self._schedule_save()
653684
return True
654685

0 commit comments

Comments
 (0)