Files
Home-Assistant/custom_components/ha_washdata/recorder.py
T
2026-07-17 10:12:15 -04:00

223 lines
8.1 KiB
Python

# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Recorder for raw cycle data in WashData."""
from __future__ import annotations
import copy
import logging
from datetime import datetime
from typing import Any, cast
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util
from .const import (
STORAGE_VERSION,
STORAGE_KEY,
)
from .log_utils import DeviceLoggerAdapter
_LOGGER = logging.getLogger(__name__)
STORAGE_KEY_RECORDER = f"{STORAGE_KEY}.recorder"
class RecorderStore(Store[dict[str, Any]]):
"""Store for recorder data with migration support."""
async def _async_migrate_func(
self,
old_major_version: int,
old_minor_version: int,
old_data: dict[str, Any],
) -> dict[str, Any]:
"""Migrate data to the new version."""
_LOGGER.info(
"Migrating recorder storage from v%s to v%s",
old_major_version,
STORAGE_VERSION,
)
# Recorder data schema hasn't changed, simple pass-through is safe
return old_data
class CycleRecorder:
"""Records raw power data without interference from detection logic."""
def __init__(self, hass: HomeAssistant, entry_id: str, device_name: str = "") -> None:
"""Initialize the recorder."""
self._logger = DeviceLoggerAdapter(_LOGGER, device_name)
self.hass = hass
self.entry_id = entry_id
self._store = RecorderStore(hass, STORAGE_VERSION, f"{STORAGE_KEY_RECORDER}.{entry_id}")
# State
self._is_recording = False
self._start_time: datetime | None = None
self._buffer: list[tuple[str, float]] = [] # stored as (iso_str, power) for easy json
self._last_save: datetime | None = None
self._last_run: dict[str, Any] | None = None
@property
def is_recording(self) -> bool:
"""Return True if recording is active."""
return self._is_recording
@property
def start_time(self) -> datetime | None:
"""Return recording start time."""
return self._start_time
@property
def current_duration(self) -> float:
"""Return current recording duration in seconds."""
if self._start_time:
return (dt_util.now() - self._start_time).total_seconds()
return 0.0
async def async_load(self) -> None:
"""Load state from storage."""
data_raw = await self._store.async_load()
data = data_raw if isinstance(data_raw, dict) else {}
# Reset to safe defaults before applying loaded values so stale state
# is never left in place when loaded data omits keys.
self._is_recording = False
self._start_time = None
self._buffer = []
self._last_run = None
if data:
value = data.get("is_recording", False)
self._is_recording = value if isinstance(value, bool) else False
start_iso = data.get("start_time")
if isinstance(start_iso, str) and start_iso:
parsed_time = dt_util.parse_datetime(start_iso)
if parsed_time is not None and getattr(parsed_time, "tzinfo", None) is None:
self._logger.warning(
"Recorder state loaded naive start_time (%s); treating as invalid", start_iso
)
self._start_time = None
else:
self._start_time = parsed_time
if self._is_recording and self._start_time is None:
self._logger.warning(
"Recorder state had is_recording=True with invalid start_time; restoring as not recording"
)
self._is_recording = False
buffer_raw = data.get("buffer", [])
sanitized: list[tuple[str, float]] = []
if isinstance(buffer_raw, list):
for item in buffer_raw:
if not isinstance(item, (list, tuple)) or len(item) != 2:
continue
key, ts = item[0], item[1]
if not isinstance(key, str) or not key:
continue
if not isinstance(ts, (int, float)):
continue
sanitized.append((key, float(ts)))
self._buffer = sanitized
last_run_raw = data.get("last_run")
self._last_run = (
copy.deepcopy(cast(dict[str, Any], last_run_raw))
if isinstance(last_run_raw, dict)
else None
)
self._logger.info(
"Loaded recorder state: recording=%s, samples=%d, has_last_run=%s",
self._is_recording,
len(self._buffer),
self._last_run is not None,
)
async def stop_recording(self) -> dict[str, Any]:
"""Stop recording and save data for processing."""
if not self._is_recording:
return {}
self._logger.info("Stopping cycle recording. Total samples: %d", len(self._buffer))
self._is_recording = False
# Create output packet
result: dict[str, Any] = {
"start_time": self._start_time.isoformat() if self._start_time else None,
"end_time": dt_util.now().isoformat(),
"data": copy.deepcopy(self._buffer),
}
# Save as last run (persisted)
self._last_run = copy.deepcopy(result)
# Clear active state
self._start_time = None
self._buffer = []
await self._async_save()
return result
@property
def last_run(self) -> dict[str, Any] | None:
"""Return the last recorded cycle data."""
return copy.deepcopy(self._last_run)
async def clear_last_run(self) -> None:
"""Clear the last recorded run."""
self._last_run = None
await self._async_save()
async def _async_save(self) -> None:
"""Save state to storage."""
data: dict[str, Any] = {
"is_recording": self._is_recording,
"start_time": self._start_time.isoformat() if self._start_time else None,
"buffer": self._buffer,
"last_run": self._last_run,
}
await self._store.async_save(data)
self._last_save = dt_util.now()
async def start_recording(self) -> None:
"""Start a new recording."""
if self._is_recording:
self._logger.warning("Recording already in progress")
return
self._logger.info("Starting new cycle recording")
# Previous recordings are kept until explicitly cleared or overwritten
self._is_recording = True
self._start_time = dt_util.now()
self._buffer = []
await self._async_save()
def process_reading(self, power: float) -> None:
"""Process a power reading (synchronous to avoid blocking loop)."""
if not self._is_recording:
return
now = dt_util.now()
# Append to buffer
self._buffer.append((now.isoformat(), float(power)))
# Periodic save every 60s to ensure data persistence
# Better safe than sorry: save if last save was > 1 minute ago
if self._last_save and (now - self._last_save).total_seconds() > 60:
self.hass.add_job(self._async_save)
elif not self._last_save:
self.hass.add_job(self._async_save)