318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""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,
|
|
SHORT_SILENCE_THRESHOLD_S,
|
|
TRIM_BUFFER_S,
|
|
)
|
|
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)
|
|
|
|
def get_trim_suggestions(
|
|
self,
|
|
data: list[tuple[str, float]],
|
|
recording_start: datetime | None = None,
|
|
recording_end: datetime | None = None,
|
|
) -> tuple[float, float, float]:
|
|
"""Analyze data to propose trims.
|
|
|
|
Args:
|
|
data: List of (iso_timestamp, power)
|
|
recording_start: Actual start time of recording (for head trim relative to start)
|
|
recording_end: Actual end time of recording (for tail trim relative to end)
|
|
|
|
Returns: (head_trim_seconds, tail_trim_seconds, median_dt)
|
|
"""
|
|
if not data:
|
|
# No data found - return full recording duration as trim
|
|
if recording_start and recording_end:
|
|
dur = (recording_end - recording_start).total_seconds()
|
|
return 0.0, dur, 0.0
|
|
return 0.0, 0.0, 0.0
|
|
|
|
# Parse timestamps and powers
|
|
parsed: list[tuple[float, float]] = []
|
|
for t_str, p in data:
|
|
t = dt_util.parse_datetime(t_str)
|
|
if t:
|
|
parsed.append((t.timestamp(), p))
|
|
|
|
if not parsed:
|
|
return 0.0, 0.0, 0.0
|
|
|
|
data_start_ts = parsed[0][0]
|
|
data_end_ts = parsed[-1][0]
|
|
|
|
# Use provided bounds or fallback to data bounds
|
|
rec_start_ts = recording_start.timestamp() if recording_start else data_start_ts
|
|
rec_end_ts = recording_end.timestamp() if recording_end else data_end_ts
|
|
|
|
# Ensure bounds cover data
|
|
rec_start_ts = min(rec_start_ts, data_start_ts)
|
|
rec_end_ts = max(rec_end_ts, data_end_ts)
|
|
|
|
threshold = 1.0 # W
|
|
|
|
first_active_idx = -1
|
|
last_active_idx = -1
|
|
|
|
for i, (_, p) in enumerate(parsed):
|
|
if p > threshold:
|
|
if first_active_idx == -1:
|
|
first_active_idx = i
|
|
last_active_idx = i
|
|
|
|
if first_active_idx == -1:
|
|
# No activity found
|
|
total_dur = rec_end_ts - rec_start_ts
|
|
return 0.0, round(total_dur, 1), 0.0
|
|
|
|
head_ts = parsed[first_active_idx][0]
|
|
tail_ts = parsed[last_active_idx][0]
|
|
|
|
if len(parsed) > 1:
|
|
dts = [t - s for (t, _), (s, _) in zip(parsed[1:], parsed[:-1])]
|
|
# Median calculation without numpy
|
|
dts.sort()
|
|
mid = len(dts) // 2
|
|
if len(dts) % 2 == 0:
|
|
median_dt = (dts[mid - 1] + dts[mid]) / 2.0
|
|
else:
|
|
median_dt = dts[mid]
|
|
if median_dt <= 0:
|
|
median_dt = 1.0 # Fallback
|
|
else:
|
|
median_dt = 1.0
|
|
|
|
# 1. Head Trim
|
|
# Time from recording start to first active sample
|
|
raw_head_trim = max(0.0, head_ts - rec_start_ts)
|
|
|
|
# Align to sampling rate (floor to keep buffer)
|
|
# Example: raw=19s, dt=10s -> trim 10s. Buffer=9s.
|
|
# Example: raw=21s, dt=10s -> trim 20s. Buffer=1s.
|
|
# To ensure we don't cut active sample if jitter:
|
|
# We start at rec_start_ts. We want start_time + trim <= head_ts
|
|
# floor ensures this.
|
|
|
|
steps_head = int(raw_head_trim / median_dt)
|
|
# Align trim to sampling rate (floor to keep buffer before active sample)
|
|
|
|
# However, if using the "floor" logic makes it 0, that's fine.
|
|
head_trim = steps_head * median_dt
|
|
|
|
# 2. Tail Trim
|
|
# Time from last active sample to recording end
|
|
# For manual recordings, we want to be conservative because of drying phases.
|
|
raw_tail_trim = max(0.0, rec_end_ts - tail_ts)
|
|
|
|
# If tail silence is less than SHORT_SILENCE_THRESHOLD_S, suggest 0 trim to be safe.
|
|
# Dishwashers often have 5-10 min silent periods that are NOT the end.
|
|
if raw_tail_trim < SHORT_SILENCE_THRESHOLD_S:
|
|
tail_trim = 0.0
|
|
else:
|
|
# If it's very long, suggest trimming but keep a TRIM_BUFFER_S buffer
|
|
tail_trim = max(0.0, raw_tail_trim - TRIM_BUFFER_S)
|
|
steps_tail = int(tail_trim / median_dt)
|
|
tail_trim = steps_tail * median_dt
|
|
|
|
return round(head_trim, 1), round(tail_trim, 1), round(median_dt, 1)
|