# 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 . """Unified time/power-data utilities for WashData. Canonical storage format for power_data: ``[[offset_seconds, power], ...]`` where ``offset_seconds`` is a float relative to the cycle's ``start_time``. All helpers in this module accept *any* of the three in-flight formats and normalise them to the canonical form so consumers never need to guess. Formats recognised: - ``(datetime, float)`` – live trace from CycleDetector internals - ``(iso_str, float)`` – legacy on-disk format (pre-offset era) - ``[offset_float, float]`` – current canonical on-disk format """ from __future__ import annotations import logging from datetime import datetime from typing import Any, Literal, cast import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) # Type aliases PowerPoint = list[Any] | tuple[Any, ...] PowerData = list[PowerPoint] def detect_power_data_format( power_data: PowerData, ) -> Literal["offset", "iso", "datetime", "empty", "unknown", "unix_timestamp"]: """Identify which format a power_data list is in. Returns one of: ``"offset"``, ``"iso"``, ``"datetime"``, ``"empty"``, ``"unknown"``, ``"unix_timestamp"``. """ if not power_data: return "empty" ts = None for sample in power_data: if isinstance(sample, (list, tuple)) and len(sample) >= 2 and sample[0] is not None: ts = sample[0] break if ts is None: return "unknown" if isinstance(ts, datetime): return "datetime" if isinstance(ts, str): return "iso" if isinstance(ts, (int, float)): # Values > 1e8 (≈ 3+ years of seconds) are absolute Unix epoch timestamps, # not relative offsets. Treat them differently so we can subtract start_time. if float(ts) > 1e8: return "unix_timestamp" return "offset" return "unknown" def power_data_to_offsets( power_data: PowerData, start_time_iso: str | None = None, ) -> list[list[float]]: """Normalise *any* power_data format to ``[[offset_sec, power], ...]``. Args: power_data: Input list in any recognised format. start_time_iso: ISO-8601 cycle start time string. Required when converting from the ISO-string format so that offsets can be computed. When converting from datetime format, used as the anchor if provided; falls back to the first sample's timestamp otherwise. Ignored for offset format. Returns: List of ``[offset_seconds, power]`` pairs. Empty list on failure. """ if not power_data: return [] fmt = detect_power_data_format(power_data) if fmt == "unix_timestamp": # Absolute Unix epoch floats - subtract cycle start to get relative offsets. base_ts: float | None = None if start_time_iso: try: parsed_start = dt_util.parse_datetime(start_time_iso) if parsed_start is not None: base_ts = parsed_start.timestamp() except (ValueError, OSError) as e: _LOGGER.debug("Failed to parse start_time_iso %s: %s", start_time_iso, e) result: list[list[float]] = [] for item in power_data: try: ts_abs = float(item[0]) p = float(item[1]) if base_ts is None: base_ts = ts_abs # use first reading as anchor offset = round(ts_abs - base_ts, 1) result.append([max(0.0, offset), p]) except (TypeError, ValueError, IndexError): continue return result if fmt == "offset": # Already canonical – return a clean list of [float, float] result: list[list[float]] = [] for item in power_data: try: result.append([float(item[0]), float(item[1])]) except (TypeError, ValueError, IndexError): continue return result if fmt == "datetime": start_ts: float | None = None if start_time_iso: try: parsed_start = dt_util.parse_datetime(start_time_iso) if parsed_start is not None: start_ts = parsed_start.timestamp() except (ValueError, OSError) as e: _LOGGER.debug("Failed to parse datetime %s: %s", start_time_iso, e) result: list[list[float]] = [] for item in power_data: try: ts_raw = item[0] if not isinstance(ts_raw, datetime): continue p = float(item[1]) ts = ts_raw if start_ts is None: start_ts = ts.timestamp() result.append([round(ts.timestamp() - start_ts, 1), p]) except (TypeError, ValueError, AttributeError, IndexError): continue return result if fmt == "iso": # We need start_time to compute offsets base_ts: float | None = None if start_time_iso: try: parsed = dt_util.parse_datetime(start_time_iso) if parsed is None: return [] base_ts = parsed.timestamp() except (ValueError, OSError) as e: _LOGGER.debug("Failed to parse datetime %s: %s", start_time_iso, e) return [] result: list[list[float]] = [] first_ts: float | None = None for item in power_data: try: ts_raw = item[0] if not isinstance(ts_raw, str): continue p = float(item[1]) parsed_ts = dt_util.parse_datetime(ts_raw) if parsed_ts is None: continue t_val = parsed_ts.timestamp() if base_ts is not None: offset = round(t_val - base_ts, 1) else: # Fallback: use first reading as zero reference if first_ts is None: first_ts = t_val _LOGGER.warning( "power_data_to_offsets: start_time_iso missing/invalid; " "shifting timestamps to first sample as zero reference " "(first sample: %s, total samples: %d)", ts_raw, len(power_data), ) offset = round(t_val - first_ts, 1) if offset < 0: _LOGGER.debug( "power_data_to_offsets: clamping negative offset %.1f to 0 " "(power=%.1f, index=%d)", offset, p, len(result), ) result.append([max(0.0, offset), p]) except (TypeError, ValueError, AttributeError, IndexError): continue return result _LOGGER.debug("power_data_to_offsets: unrecognised format, returning empty") return [] def power_data_offsets_to_datetimes( power_data: PowerData, start_time_iso: str, ) -> list[tuple[datetime, float]]: """Convert stored ``[[offset_sec, power], ...]`` to ``[(datetime, power), ...]``. Args: power_data: Offset-format power data. start_time_iso: ISO-8601 cycle start time. Returns: List of ``(datetime, power)`` tuples. Empty list on failure. """ try: start_dt = dt_util.parse_datetime(start_time_iso) if start_dt is None: return [] start_ts = start_dt.timestamp() except Exception: # pylint: disable=broad-exception-caught return [] result: list[tuple[datetime, float]] = [] for item in power_data: try: offset = float(item[0]) p = float(item[1]) ts = datetime.fromtimestamp(start_ts + offset, tz=start_dt.tzinfo) result.append((ts, p)) except (TypeError, ValueError, IndexError): continue return result def migrate_power_data_to_offsets(cycle: dict[str, Any]) -> bool: """Migrate a single cycle's power_data to offset format in-place. Detects if ``power_data`` is still in legacy ISO-string format and converts it. Safe to call on already-converted cycles. Returns: ``True`` if the cycle was modified, ``False`` if no change was needed. """ raw = cycle.get("power_data") if not isinstance(raw, list) or not raw: return False raw_power_data = cast(PowerData, raw) fmt = detect_power_data_format(raw_power_data) if fmt in ("offset", "empty"): return False # Already canonical if fmt not in ("iso", "datetime", "unix_timestamp"): _LOGGER.warning( "migrate_power_data_to_offsets: unknown format '%s', skipping", fmt ) return False start_time_raw = cycle.get("start_time") start_time_iso: str | None = ( str(start_time_raw) if isinstance(start_time_raw, str) and start_time_raw else None ) if fmt == "iso": if not start_time_iso: _LOGGER.warning( "migrate_power_data_to_offsets: missing start_time, skipping" ) return False if dt_util.parse_datetime(start_time_iso) is None: _LOGGER.warning( "migrate_power_data_to_offsets: unparsable start_time '%s', skipping", start_time_iso, ) return False converted = power_data_to_offsets(raw_power_data, start_time_iso) if not converted: _LOGGER.warning( "migrate_power_data_to_offsets: conversion produced empty result, skipping" ) return False cycle["power_data"] = converted return True