Skip to content
Merged

Dev #12

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Please note that this integration requires RS485 to TCP converter that needs to
- **Restart-safe availability** — Last known node readings are persisted and
restored on startup, so sensors stay available with the most recent value
even before the first live frame arrives (for example after a night restart).
- **Energy accumulation** — Trapezoidal Wh integration with daily reset semantics and monotonic lifetime totals.
- **Energy accumulation** — Trapezoidal Wh integration with proactive midnight reset and monotonic lifetime totals. Daily sensors zero at exactly midnight local time.
- **No external dependencies** — The protocol parser library is fully embedded; nothing to install from PyPI.
- **Options flow** — Add or remove optimizer modules at any time without reconfiguring.

Expand Down
56 changes: 54 additions & 2 deletions custom_components/pytap/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@
from __future__ import annotations

import asyncio
from datetime import datetime
from datetime import datetime, timedelta
from datetime import time as dt_time
import logging
import threading
import time
from typing import Any

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
Expand Down Expand Up @@ -128,6 +129,9 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self._listener_task: asyncio.Task | None = None
self._stop_event = threading.Event()

# Midnight reset timer handle
self._midnight_reset_unsub: asyncio.TimerHandle | None = None

# Source handle for cancellation — accessed from both threads
self._source: Any = None
self._source_lock = threading.Lock()
Expand Down Expand Up @@ -199,6 +203,7 @@ async def async_start_listener(self) -> None:
self._async_listen(),
name="pytap_listener",
)
self._schedule_midnight_reset()

async def _async_listen(self) -> None:
"""Async wrapper to run the blocking listener in an executor."""
Expand All @@ -207,6 +212,10 @@ async def _async_listen(self) -> None:
async def async_stop_listener(self) -> None:
"""Stop the background listener task."""
self._stop_event.set()
# Cancel midnight reset timer
if self._midnight_reset_unsub is not None:
self._midnight_reset_unsub.cancel()
self._midnight_reset_unsub = None
# Flush any pending state save
if self._save_task is not None:
self._save_task.cancel()
Expand All @@ -229,6 +238,49 @@ async def async_stop_listener(self) -> None:
pass
self._listener_task = None

def _schedule_midnight_reset(self) -> None:
"""Schedule a callback at the next local midnight to reset daily accumulators."""
now = dt_util.now()
next_midnight = datetime.combine(
now.date() + timedelta(days=1), dt_time.min, tzinfo=now.tzinfo
)
delay = (next_midnight - now).total_seconds()
self._midnight_reset_unsub = self.hass.loop.call_later(
delay, self._perform_midnight_reset
)
_LOGGER.debug(
"Scheduled daily reset in %.0f seconds (at %s)", delay, next_midnight
)

@callback
def _perform_midnight_reset(self) -> None:
"""Reset all daily accumulators and push updated data to sensors."""
self._midnight_reset_unsub = None
today = dt_util.now().date().isoformat()
_LOGGER.info("Midnight daily reset — resetting daily accumulators for %s", today)

data_changed = False
for barcode, acc in self._energy_state.items():
if acc.daily_reset_date == today:
continue
acc.daily_energy_wh = 0.0
acc.readings_today = 0
acc.daily_reset_date = today

node_data = self.data.get("nodes", {}).get(barcode)
if node_data is not None:
node_data["daily_energy_wh"] = 0.0
node_data["readings_today"] = 0
node_data["daily_reset_date"] = today
data_changed = True

if data_changed:
self._schedule_save()
self.async_set_updated_data(dict(self.data))

# Re-schedule for the next midnight
self._schedule_midnight_reset()

def _listen(self) -> None:
"""Blocking listener loop (runs in executor thread).

Expand Down
2 changes: 1 addition & 1 deletion custom_components/pytap/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ async def async_added_to_hass(self) -> None:
node = nodes.get(self._barcode)
# Only short-circuit to coordinator data if this sensor has a non-None value.
if node is not None:
coordinator_value = node.get(self.entity_description.key)
coordinator_value = node.get(self.entity_description.value_key)
if coordinator_value is not None:
self._handle_coordinator_update()
return
Expand Down
Loading