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

292 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/>.
"""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