Initial
This commit is contained in:
@@ -0,0 +1,838 @@
|
||||
"""The WashData integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
SERVICE_SUBMIT_FEEDBACK,
|
||||
CONF_LINKED_DEVICE,
|
||||
CONF_MIN_POWER,
|
||||
CONF_OFF_DELAY,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_POWER_SENSOR,
|
||||
CONF_NOTIFY_SERVICE,
|
||||
CONF_NOTIFY_EVENTS,
|
||||
NOTIFY_EVENT_LIVE,
|
||||
CONF_NOTIFY_START_SERVICES,
|
||||
CONF_NOTIFY_FINISH_SERVICES,
|
||||
CONF_NOTIFY_LIVE_SERVICES,
|
||||
CONF_NOTIFY_ACTIONS,
|
||||
CONF_NOTIFY_PEOPLE,
|
||||
CONF_NOTIFY_ONLY_WHEN_HOME,
|
||||
CONF_NOTIFY_FIRE_EVENTS,
|
||||
CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||||
CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||||
CONF_NOTIFY_TIMEOUT_SECONDS,
|
||||
CONF_NOTIFY_CHANNEL,
|
||||
CONF_NOTIFY_FINISH_CHANNEL,
|
||||
CONF_NOTIFY_REMINDER_MESSAGE,
|
||||
DEFAULT_NOTIFY_ONLY_WHEN_HOME,
|
||||
DEFAULT_NOTIFY_FIRE_EVENTS,
|
||||
DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||||
DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||||
DEFAULT_NOTIFY_TIMEOUT_SECONDS,
|
||||
DEFAULT_NOTIFY_CHANNEL,
|
||||
DEFAULT_NOTIFY_FINISH_CHANNEL,
|
||||
DEFAULT_NOTIFY_REMINDER_MESSAGE,
|
||||
CONF_PROGRESS_RESET_DELAY,
|
||||
CONF_LEARNING_CONFIDENCE,
|
||||
CONF_DURATION_TOLERANCE,
|
||||
CONF_AUTO_LABEL_CONFIDENCE,
|
||||
DEFAULT_PROGRESS_RESET_DELAY,
|
||||
DEFAULT_LEARNING_CONFIDENCE,
|
||||
DEFAULT_DURATION_TOLERANCE,
|
||||
DEFAULT_AUTO_LABEL_CONFIDENCE,
|
||||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||||
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT,
|
||||
CONF_SMOOTHING_WINDOW,
|
||||
CONF_PROFILE_DURATION_TOLERANCE,
|
||||
CONF_INTERRUPTED_MIN_SECONDS,
|
||||
CONF_ABRUPT_DROP_WATTS,
|
||||
CONF_ABRUPT_DROP_RATIO,
|
||||
CONF_ABRUPT_HIGH_LOAD_FACTOR,
|
||||
DEFAULT_SMOOTHING_WINDOW,
|
||||
DEFAULT_PROFILE_DURATION_TOLERANCE,
|
||||
DEFAULT_INTERRUPTED_MIN_SECONDS,
|
||||
DEFAULT_ABRUPT_DROP_WATTS,
|
||||
DEFAULT_ABRUPT_DROP_RATIO,
|
||||
DEFAULT_ABRUPT_HIGH_LOAD_FACTOR,
|
||||
CONF_PROFILE_MATCH_INTERVAL,
|
||||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
CONF_MAX_PAST_CYCLES,
|
||||
CONF_MAX_FULL_TRACES_PER_PROFILE,
|
||||
CONF_MAX_FULL_TRACES_UNLABELED,
|
||||
CONF_WATCHDOG_INTERVAL,
|
||||
CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
|
||||
CONF_COMPLETION_MIN_SECONDS,
|
||||
CONF_NOTIFY_BEFORE_END_MINUTES,
|
||||
DEFAULT_PROFILE_MATCH_INTERVAL,
|
||||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
DEFAULT_MAX_PAST_CYCLES,
|
||||
DEFAULT_MAX_FULL_TRACES_PER_PROFILE,
|
||||
DEFAULT_MAX_FULL_TRACES_UNLABELED,
|
||||
DEFAULT_WATCHDOG_INTERVAL,
|
||||
DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
|
||||
DEFAULT_COMPLETION_MIN_SECONDS,
|
||||
DEFAULT_NOTIFY_BEFORE_END_MINUTES,
|
||||
DEFAULT_DEVICE_TYPE,
|
||||
DEFAULT_START_DURATION_THRESHOLD,
|
||||
CONF_START_DURATION_THRESHOLD,
|
||||
)
|
||||
from .log_utils import DeviceLoggerAdapter
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.SENSOR,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.SELECT,
|
||||
Platform.BUTTON,
|
||||
]
|
||||
|
||||
|
||||
def _require_str(value: Any, name: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=f"{name}_required",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate config entry to the latest version while preserving settings."""
|
||||
_log = DeviceLoggerAdapter(_LOGGER, entry.title)
|
||||
version = entry.version or 1
|
||||
minor_version = entry.minor_version or 1
|
||||
|
||||
if version > 3:
|
||||
_log.error(
|
||||
"Refusing to migrate unsupported future schema %s.%s", version, minor_version
|
||||
)
|
||||
return False
|
||||
|
||||
if version == 3 and minor_version >= 5:
|
||||
return True
|
||||
|
||||
data: dict[str, Any] = dict(entry.data)
|
||||
options: dict[str, Any] = dict(entry.options)
|
||||
|
||||
# Preserve core settings from data into options if missing
|
||||
if CONF_MIN_POWER not in options and CONF_MIN_POWER in data:
|
||||
options[CONF_MIN_POWER] = data[CONF_MIN_POWER]
|
||||
if CONF_OFF_DELAY not in options and CONF_OFF_DELAY in data:
|
||||
options[CONF_OFF_DELAY] = data[CONF_OFF_DELAY]
|
||||
if CONF_DEVICE_TYPE not in options and CONF_DEVICE_TYPE in data:
|
||||
options[CONF_DEVICE_TYPE] = data[CONF_DEVICE_TYPE]
|
||||
if CONF_POWER_SENSOR not in options and CONF_POWER_SENSOR in data:
|
||||
options[CONF_POWER_SENSOR] = data[CONF_POWER_SENSOR]
|
||||
if CONF_NOTIFY_SERVICE not in options and CONF_NOTIFY_SERVICE in data:
|
||||
options[CONF_NOTIFY_SERVICE] = data[CONF_NOTIFY_SERVICE]
|
||||
|
||||
# Migrate legacy single CONF_NOTIFY_SERVICE into per-event service lists.
|
||||
# Users who configured a notify service before 0.3.x would otherwise lose
|
||||
# their notification settings entirely on upgrade.
|
||||
legacy_svc = options.get(CONF_NOTIFY_SERVICE) or data.get(CONF_NOTIFY_SERVICE)
|
||||
if legacy_svc and isinstance(legacy_svc, str):
|
||||
# CONF_NOTIFY_EVENTS is a deprecated list of enabled event types.
|
||||
# Only migrate live services when live events were explicitly opted in.
|
||||
legacy_events = options.get(CONF_NOTIFY_EVENTS) or data.get(CONF_NOTIFY_EVENTS) or []
|
||||
if CONF_NOTIFY_START_SERVICES not in options:
|
||||
options[CONF_NOTIFY_START_SERVICES] = [legacy_svc]
|
||||
if CONF_NOTIFY_FINISH_SERVICES not in options:
|
||||
options[CONF_NOTIFY_FINISH_SERVICES] = [legacy_svc]
|
||||
if CONF_NOTIFY_LIVE_SERVICES not in options and NOTIFY_EVENT_LIVE in legacy_events:
|
||||
options[CONF_NOTIFY_LIVE_SERVICES] = [legacy_svc]
|
||||
|
||||
options.setdefault(CONF_PROGRESS_RESET_DELAY, DEFAULT_PROGRESS_RESET_DELAY)
|
||||
options.setdefault(CONF_LEARNING_CONFIDENCE, DEFAULT_LEARNING_CONFIDENCE)
|
||||
options.setdefault(CONF_DURATION_TOLERANCE, DEFAULT_DURATION_TOLERANCE)
|
||||
options.setdefault(CONF_AUTO_LABEL_CONFIDENCE, DEFAULT_AUTO_LABEL_CONFIDENCE)
|
||||
options.setdefault(CONF_NO_UPDATE_ACTIVE_TIMEOUT, DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT)
|
||||
options.setdefault(CONF_SMOOTHING_WINDOW, DEFAULT_SMOOTHING_WINDOW)
|
||||
options.setdefault(
|
||||
CONF_PROFILE_DURATION_TOLERANCE, DEFAULT_PROFILE_DURATION_TOLERANCE
|
||||
)
|
||||
options.setdefault(CONF_INTERRUPTED_MIN_SECONDS, DEFAULT_INTERRUPTED_MIN_SECONDS)
|
||||
options.setdefault(CONF_ABRUPT_DROP_WATTS, DEFAULT_ABRUPT_DROP_WATTS)
|
||||
options.setdefault(CONF_ABRUPT_DROP_RATIO, DEFAULT_ABRUPT_DROP_RATIO)
|
||||
options.setdefault(CONF_ABRUPT_HIGH_LOAD_FACTOR, DEFAULT_ABRUPT_HIGH_LOAD_FACTOR)
|
||||
|
||||
options.setdefault(
|
||||
CONF_DEVICE_TYPE, data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
|
||||
)
|
||||
options.setdefault(CONF_START_DURATION_THRESHOLD, DEFAULT_START_DURATION_THRESHOLD)
|
||||
|
||||
options.setdefault(CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL)
|
||||
options.setdefault(
|
||||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
|
||||
)
|
||||
options.setdefault(
|
||||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO, DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO
|
||||
)
|
||||
options.setdefault(CONF_MAX_PAST_CYCLES, DEFAULT_MAX_PAST_CYCLES)
|
||||
options.setdefault(
|
||||
CONF_MAX_FULL_TRACES_PER_PROFILE, DEFAULT_MAX_FULL_TRACES_PER_PROFILE
|
||||
)
|
||||
options.setdefault(
|
||||
CONF_MAX_FULL_TRACES_UNLABELED, DEFAULT_MAX_FULL_TRACES_UNLABELED
|
||||
)
|
||||
options.setdefault(CONF_WATCHDOG_INTERVAL, DEFAULT_WATCHDOG_INTERVAL)
|
||||
options.setdefault(
|
||||
CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD, DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD
|
||||
)
|
||||
options.setdefault(CONF_COMPLETION_MIN_SECONDS, DEFAULT_COMPLETION_MIN_SECONDS)
|
||||
options.setdefault(
|
||||
CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES
|
||||
)
|
||||
|
||||
# Normalize notification options (added in 0.3.2)
|
||||
options.setdefault(CONF_NOTIFY_ACTIONS, [])
|
||||
options.setdefault(CONF_NOTIFY_PEOPLE, [])
|
||||
options.setdefault(CONF_NOTIFY_ONLY_WHEN_HOME, DEFAULT_NOTIFY_ONLY_WHEN_HOME)
|
||||
options.setdefault(CONF_NOTIFY_FIRE_EVENTS, DEFAULT_NOTIFY_FIRE_EVENTS)
|
||||
options.setdefault(
|
||||
CONF_NOTIFY_LIVE_INTERVAL_SECONDS, DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS
|
||||
)
|
||||
options.setdefault(
|
||||
CONF_NOTIFY_LIVE_OVERRUN_PERCENT, DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT
|
||||
)
|
||||
|
||||
# 3.5: notification delivery overhaul (lifecycle tag, timeout, per-type channels,
|
||||
# distinct reminder message).
|
||||
options.setdefault(CONF_NOTIFY_TIMEOUT_SECONDS, DEFAULT_NOTIFY_TIMEOUT_SECONDS)
|
||||
options.setdefault(CONF_NOTIFY_CHANNEL, DEFAULT_NOTIFY_CHANNEL)
|
||||
options.setdefault(CONF_NOTIFY_FINISH_CHANNEL, DEFAULT_NOTIFY_FINISH_CHANNEL)
|
||||
options.setdefault(CONF_NOTIFY_REMINDER_MESSAGE, DEFAULT_NOTIFY_REMINDER_MESSAGE)
|
||||
|
||||
keys_to_remove = [
|
||||
CONF_MIN_POWER,
|
||||
CONF_OFF_DELAY,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_POWER_SENSOR,
|
||||
CONF_NOTIFY_SERVICE,
|
||||
]
|
||||
for k in keys_to_remove:
|
||||
data.pop(k, None)
|
||||
|
||||
# 3.4: drain-spike delayed-start model replaced by band-based DELAY_WAIT.
|
||||
# Strip the obsolete drain knobs so they don't linger in options and
|
||||
# confuse anyone inspecting entry.options.
|
||||
for k in (
|
||||
"delay_drain_min_power",
|
||||
"delay_drain_max_power",
|
||||
"delay_drain_max_duration",
|
||||
):
|
||||
options.pop(k, None)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=data,
|
||||
options=options,
|
||||
version=3,
|
||||
minor_version=5,
|
||||
)
|
||||
_log.info(
|
||||
"Migrated WashData entry from version %s.%s to 3.5", version, minor_version
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up WashData from a config entry."""
|
||||
_log = DeviceLoggerAdapter(_LOGGER, entry.title)
|
||||
# Guard against duplicate setup during hot-reload
|
||||
if entry.entry_id in hass.data.get(DOMAIN, {}):
|
||||
_log.warning(
|
||||
"Entry %s already set up, skipping duplicate setup", entry.entry_id
|
||||
)
|
||||
return True
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
# Migration: Remove old auto_maintenance switch entity (now in settings)
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
old_switch_id = f"{entry.entry_id}_auto_maintenance"
|
||||
old_entity = ent_reg.async_get_entity_id("switch", DOMAIN, old_switch_id)
|
||||
if old_entity:
|
||||
_log.info(
|
||||
"Removing deprecated auto_maintenance switch entity: %s", old_entity
|
||||
)
|
||||
ent_reg.async_remove(old_entity)
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from .manager import WashDataManager
|
||||
|
||||
manager = WashDataManager(hass, entry)
|
||||
hass.data[DOMAIN][entry.entry_id] = manager
|
||||
|
||||
await manager.async_setup()
|
||||
|
||||
# Check for initial profile from onboarding
|
||||
if "initial_profile" in entry.data:
|
||||
init_prof = entry.data["initial_profile"]
|
||||
name = init_prof.get("name")
|
||||
duration = init_prof.get("avg_duration")
|
||||
if name:
|
||||
try:
|
||||
# Create the profile immediately
|
||||
await manager.profile_store.create_profile_standalone(
|
||||
name, avg_duration=duration
|
||||
)
|
||||
manager._logger.info("Created initial profile '%s' from onboarding", name)
|
||||
|
||||
# Clean up config entry (remove initial_profile to avoid re-creation or cruft)
|
||||
new_data = {
|
||||
k: v for k, v in entry.data.items() if k != "initial_profile"
|
||||
}
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
manager._logger.error("Failed to create initial profile: %s", e)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
_apply_device_link(hass, entry)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||
|
||||
# Register service if not already
|
||||
if not hass.services.has_service(DOMAIN, "label_cycle"):
|
||||
|
||||
async def handle_label_cycle(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
cycle_id = _require_str(call.data.get("cycle_id"), "cycle_id")
|
||||
profile_name = call.data.get("profile_name", "").strip()
|
||||
|
||||
# Find the config entry for this device
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this device")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
|
||||
# Assign existing profile or remove label
|
||||
if profile_name:
|
||||
await manager.profile_store.assign_profile_to_cycle(
|
||||
cycle_id, profile_name
|
||||
)
|
||||
else:
|
||||
await manager.profile_store.assign_profile_to_cycle(cycle_id, None)
|
||||
|
||||
manager.notify_update()
|
||||
|
||||
hass.services.async_register(DOMAIN, "label_cycle", handle_label_cycle)
|
||||
|
||||
# Register create_profile service
|
||||
if not hass.services.has_service(DOMAIN, "create_profile"):
|
||||
|
||||
async def handle_create_profile(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
profile_name = _require_str(call.data.get("profile_name"), "profile_name")
|
||||
reference_cycle_id = call.data.get("reference_cycle_id")
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this device")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
await manager.profile_store.create_profile_standalone(
|
||||
profile_name, reference_cycle_id
|
||||
)
|
||||
manager.notify_update()
|
||||
|
||||
hass.services.async_register(DOMAIN, "create_profile", handle_create_profile)
|
||||
|
||||
# Register delete_profile service
|
||||
if not hass.services.has_service(DOMAIN, "delete_profile"):
|
||||
|
||||
async def handle_delete_profile(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
profile_name = _require_str(call.data.get("profile_name"), "profile_name")
|
||||
unlabel_cycles = call.data.get("unlabel_cycles", True)
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this device")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
await manager.profile_store.delete_profile(profile_name, unlabel_cycles)
|
||||
manager.notify_update()
|
||||
|
||||
hass.services.async_register(DOMAIN, "delete_profile", handle_delete_profile)
|
||||
|
||||
# Register auto_label_cycles service
|
||||
if not hass.services.has_service(DOMAIN, "auto_label_cycles"):
|
||||
|
||||
async def handle_auto_label_cycles(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
confidence_threshold = call.data.get("confidence_threshold", 0.75)
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this device")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
stats = await manager.profile_store.auto_label_cycles(
|
||||
confidence_threshold
|
||||
)
|
||||
manager.notify_update()
|
||||
|
||||
manager._logger.info(
|
||||
"Auto-label complete: %s labeled, %s skipped",
|
||||
stats["labeled"],
|
||||
stats["skipped"],
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, "auto_label_cycles", handle_auto_label_cycles
|
||||
)
|
||||
|
||||
# Register trim_cycle service
|
||||
if not hass.services.has_service(DOMAIN, "trim_cycle"):
|
||||
|
||||
async def handle_trim_cycle(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
cycle_id = _require_str(call.data.get("cycle_id"), "cycle_id")
|
||||
trim_start_s = max(0.0, float(call.data.get("trim_start_s", 0)))
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
)
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_config_entry",
|
||||
)
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_loaded",
|
||||
)
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
store = manager.profile_store
|
||||
|
||||
# Determine trim end - default to full cycle duration if not supplied
|
||||
raw_end = call.data.get("trim_end_s")
|
||||
if raw_end is not None:
|
||||
trim_end_s = max(0.0, float(raw_end))
|
||||
else:
|
||||
p_data = store.get_cycle_power_data(cycle_id)
|
||||
if not p_data:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cycle_not_found_or_no_power",
|
||||
)
|
||||
trim_end_s = max(point[0] for point in p_data)
|
||||
|
||||
if trim_end_s <= trim_start_s:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="trim_invalid_range",
|
||||
)
|
||||
|
||||
ok = await store.trim_cycle_power_data(cycle_id, trim_start_s, trim_end_s)
|
||||
if not ok:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="trim_failed_empty_window",
|
||||
)
|
||||
manager.notify_update()
|
||||
|
||||
hass.services.async_register(DOMAIN, "trim_cycle", handle_trim_cycle)
|
||||
|
||||
# Register custom card via frontend.py - once per HA instance only.
|
||||
if not hass.data.get("ha_washdata_card_registered") and not hass.data.get(
|
||||
"ha_washdata_card_deferred"
|
||||
) and not hass.data.get("ha_washdata_card_registering"):
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from .frontend import (
|
||||
CARD_REGISTERED,
|
||||
CARD_DEFERRED,
|
||||
WashDataCardRegistration,
|
||||
)
|
||||
|
||||
card_reg = WashDataCardRegistration(hass)
|
||||
hass.data["ha_washdata_card_registering"] = True
|
||||
try:
|
||||
register_result = await card_reg.async_register()
|
||||
except Exception as err: # pylint: disable=broad-exception-caught
|
||||
hass.data["ha_washdata_card_registering"] = False
|
||||
_log.warning("Card registration failed, will retry on next setup: %s", err)
|
||||
else:
|
||||
hass.data["ha_washdata_card_registering"] = False
|
||||
if register_result == CARD_REGISTERED:
|
||||
hass.data["ha_washdata_card_deferred"] = False
|
||||
hass.data["ha_washdata_card_registered"] = True
|
||||
elif register_result == CARD_DEFERRED:
|
||||
hass.data["ha_washdata_card_deferred"] = True
|
||||
hass.data["ha_washdata_card_registered"] = False
|
||||
else:
|
||||
hass.data["ha_washdata_card_deferred"] = False
|
||||
hass.data["ha_washdata_card_registered"] = False
|
||||
_log.warning("Card registration failed and was not deferred")
|
||||
|
||||
# Register feedback service
|
||||
if not hass.services.has_service(
|
||||
DOMAIN, SERVICE_SUBMIT_FEEDBACK.rsplit(".", maxsplit=1)[-1]
|
||||
):
|
||||
|
||||
async def handle_submit_feedback(call: ServiceCall) -> None:
|
||||
entry_id_raw = call.data.get("entry_id")
|
||||
device_id_raw = call.data.get("device_id")
|
||||
|
||||
entry_id: str | None = (
|
||||
entry_id_raw if isinstance(entry_id_raw, str) and entry_id_raw else None
|
||||
)
|
||||
if entry_id is None:
|
||||
# Prefer device_id for user-facing workflows.
|
||||
device_id = _require_str(device_id_raw, "device_id")
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
|
||||
if not entry_id:
|
||||
raise ValueError("entry_id or device_id is required")
|
||||
|
||||
cycle_id = _require_str(call.data.get("cycle_id"), "cycle_id")
|
||||
user_confirmed = call.data.get("user_confirmed", False)
|
||||
corrected_profile = call.data.get("corrected_profile")
|
||||
corrected_duration = call.data.get("corrected_duration") # in seconds
|
||||
notes = call.data.get("notes", "")
|
||||
dismiss = call.data.get("dismiss", False)
|
||||
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this entry")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
success = await manager.learning_manager.async_submit_cycle_feedback(
|
||||
cycle_id=cycle_id,
|
||||
user_confirmed=user_confirmed,
|
||||
corrected_profile=corrected_profile,
|
||||
corrected_duration=corrected_duration,
|
||||
notes=notes,
|
||||
dismiss=dismiss,
|
||||
)
|
||||
manager.notify_update()
|
||||
|
||||
if success:
|
||||
# Best-effort dismiss the feedback notification if it exists.
|
||||
try:
|
||||
notification_id = f"ha_washdata_feedback_{entry_id}_{cycle_id}"
|
||||
persistent_notification.async_dismiss(hass, notification_id)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
pass
|
||||
|
||||
manager._logger.info("Cycle feedback submitted for %s", cycle_id)
|
||||
else:
|
||||
manager._logger.warning("Failed to submit feedback for cycle %s", cycle_id)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SUBMIT_FEEDBACK.rsplit(".", maxsplit=1)[-1],
|
||||
handle_submit_feedback,
|
||||
)
|
||||
|
||||
# Export store to file (per entry/device)
|
||||
if not hass.services.has_service(DOMAIN, "export_config"):
|
||||
|
||||
async def handle_export_config(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
file_path = call.data.get("path")
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this device")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None:
|
||||
raise ValueError(f"Config entry not found: {entry_id}")
|
||||
payload = manager.profile_store.export_data(
|
||||
entry_data=dict(entry.data),
|
||||
entry_options=dict(entry.options),
|
||||
)
|
||||
|
||||
target = (
|
||||
Path(file_path)
|
||||
if file_path
|
||||
else Path(hass.config.path(f"ha_washdata_export_{entry_id}.json"))
|
||||
)
|
||||
target = target.resolve()
|
||||
|
||||
# Write export
|
||||
target.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
manager._logger.info("Exported ha_washdata entry %s to %s", entry_id, target)
|
||||
|
||||
hass.services.async_register(DOMAIN, "export_config", handle_export_config)
|
||||
|
||||
# Import store from file into the target entry/device
|
||||
if not hass.services.has_service(DOMAIN, "import_config"):
|
||||
|
||||
async def handle_import_config(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
file_path = call.data.get("path")
|
||||
|
||||
if not file_path:
|
||||
raise ValueError("path is required for import")
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this device")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None:
|
||||
raise ValueError(f"Config entry not found: {entry_id}")
|
||||
|
||||
source = Path(file_path).resolve()
|
||||
if not source.exists():
|
||||
raise ValueError(f"File not found: {source}")
|
||||
|
||||
try:
|
||||
payload = json.loads(source.read_text(encoding="utf-8"))
|
||||
except Exception as err: # noqa: BLE001
|
||||
raise ValueError(f"Failed to read import file: {err}") from err
|
||||
|
||||
config_updates = await manager.profile_store.async_import_data(payload)
|
||||
|
||||
# Apply imported settings to config entry if present
|
||||
entry_data = config_updates.get("entry_data", {})
|
||||
entry_options = config_updates.get("entry_options", {})
|
||||
|
||||
if entry_data or entry_options:
|
||||
new_data: dict[str, Any] = dict(entry.data)
|
||||
new_options: dict[str, Any] = dict(entry.options)
|
||||
|
||||
# Only update min_power/off_delay from data (don't overwrite power_sensor/name)
|
||||
for key in [CONF_MIN_POWER, CONF_OFF_DELAY]:
|
||||
if key in entry_data:
|
||||
new_data[key] = entry_data[key]
|
||||
|
||||
# Update all options from import
|
||||
new_options.update(entry_options)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=new_data,
|
||||
options=new_options,
|
||||
)
|
||||
manager._logger.info("Applied imported settings to config entry %s", entry_id)
|
||||
|
||||
manager._logger.info("Imported ha_washdata entry %s from %s", entry_id, source)
|
||||
|
||||
hass.services.async_register(DOMAIN, "import_config", handle_import_config)
|
||||
|
||||
# Register recorder services
|
||||
if not hass.services.has_service(DOMAIN, "record_start"):
|
||||
async def handle_record_start(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id or entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
await manager.async_start_recording()
|
||||
|
||||
hass.services.async_register(DOMAIN, "record_start", handle_record_start)
|
||||
|
||||
if not hass.services.has_service(DOMAIN, "record_stop"):
|
||||
async def handle_record_stop(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id or entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
await manager.async_stop_recording()
|
||||
|
||||
hass.services.async_register(DOMAIN, "record_stop", handle_record_stop)
|
||||
|
||||
# Register pause/resume services
|
||||
if not hass.services.has_service(DOMAIN, "pause_cycle"):
|
||||
async def handle_pause_cycle(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
)
|
||||
entry_id = next(
|
||||
(eid for eid in device.config_entries if eid in hass.data.get(DOMAIN, {})),
|
||||
None,
|
||||
)
|
||||
if not entry_id:
|
||||
if any(eid for eid in device.config_entries):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_loaded",
|
||||
)
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_config_entry",
|
||||
)
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
await manager.async_pause_cycle()
|
||||
|
||||
hass.services.async_register(DOMAIN, "pause_cycle", handle_pause_cycle)
|
||||
|
||||
if not hass.services.has_service(DOMAIN, "resume_cycle"):
|
||||
async def handle_resume_cycle(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
)
|
||||
entry_id = next(
|
||||
(eid for eid in device.config_entries if eid in hass.data.get(DOMAIN, {})),
|
||||
None,
|
||||
)
|
||||
if not entry_id:
|
||||
if any(eid for eid in device.config_entries):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_loaded",
|
||||
)
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_config_entry",
|
||||
)
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
await manager.async_resume_cycle()
|
||||
|
||||
hass.services.async_register(DOMAIN, "resume_cycle", handle_resume_cycle)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _apply_device_link(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Sync the WashData device's via_device link with the configured option.
|
||||
|
||||
When CONF_LINKED_DEVICE points at an existing device (e.g. the smart plug or
|
||||
appliance), the WashData device is shown as "Connected via <device>" in the
|
||||
HA device registry. Clearing the option removes the link. Stale targets that
|
||||
no longer exist are treated as "no link" so the registry never references a
|
||||
deleted device.
|
||||
"""
|
||||
registry = dr.async_get(hass)
|
||||
washdata_device = registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)})
|
||||
if washdata_device is None:
|
||||
return
|
||||
|
||||
linked_device_id = entry.options.get(CONF_LINKED_DEVICE) or None
|
||||
if linked_device_id and registry.async_get(linked_device_id) is None:
|
||||
linked_device_id = None
|
||||
|
||||
if washdata_device.via_device_id != linked_device_id:
|
||||
registry.async_update_device(
|
||||
washdata_device.id, via_device_id=linked_device_id
|
||||
)
|
||||
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload config entry - update settings without interrupting running cycles."""
|
||||
manager = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if manager:
|
||||
# Update configuration without interrupting detector
|
||||
await manager.async_reload_config(entry)
|
||||
# Options changes (e.g. linked device) reload in place without
|
||||
# recreating entities, so apply the device link explicitly here.
|
||||
_apply_device_link(hass, entry)
|
||||
else:
|
||||
# Full reload if manager not found
|
||||
await async_unload_entry(hass, entry)
|
||||
await async_setup_entry(hass, entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
manager = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
await manager.async_shutdown()
|
||||
|
||||
return unload_ok
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,540 @@
|
||||
"""Analysis module for heavy CPU tasks (offloaded to executor)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
ALIGNMENT_CONTEXT_BUFFER = 50
|
||||
|
||||
def find_best_alignment(
|
||||
current_power: list[float] | np.ndarray,
|
||||
sample_power: list[float] | np.ndarray,
|
||||
dt: float = 1.0 # pylint: disable=unused-argument
|
||||
) -> tuple[float, dict[str, float], int]:
|
||||
"""Find Best Alignment using Coarse-to-Fine Search (CPU Bound)."""
|
||||
|
||||
curr = np.array(current_power)
|
||||
ref = np.array(sample_power)
|
||||
|
||||
n_curr = len(curr)
|
||||
n_ref = len(ref)
|
||||
|
||||
# 1. Coarse Alignment (Cross-Correlation)
|
||||
# Downsample for speed if arrays are large
|
||||
ds_factor = 1
|
||||
if n_curr > 200:
|
||||
ds_factor = int(n_curr / 100)
|
||||
|
||||
if ds_factor > 1:
|
||||
c_coarse = curr[::ds_factor]
|
||||
r_coarse = ref[::ds_factor]
|
||||
else:
|
||||
c_coarse = curr
|
||||
r_coarse = ref
|
||||
|
||||
# Standardize
|
||||
if np.std(c_coarse) > 1e-6:
|
||||
c_norm = (c_coarse - np.mean(c_coarse)) / np.std(c_coarse)
|
||||
else:
|
||||
c_norm = c_coarse
|
||||
|
||||
if np.std(r_coarse) > 1e-6:
|
||||
r_norm = (r_coarse - np.mean(r_coarse)) / np.std(r_coarse)
|
||||
else:
|
||||
r_norm = r_coarse
|
||||
|
||||
# Cross correlation
|
||||
correlation = np.correlate(c_norm, r_norm, mode="full")
|
||||
lags = np.arange(-len(r_norm) + 1, len(c_norm))
|
||||
|
||||
best_idx = int(np.argmax(correlation))
|
||||
best_lag_coarse = lags[best_idx]
|
||||
|
||||
best_offset = best_lag_coarse * ds_factor
|
||||
|
||||
# 2. Fine Refinement
|
||||
window = 10 * ds_factor
|
||||
min_off = max(-len(ref) + 1, best_offset - window)
|
||||
max_off = min(len(curr), best_offset + window)
|
||||
|
||||
best_mae = float("inf")
|
||||
final_offset = best_offset
|
||||
|
||||
for off in range(int(min_off), int(max_off) + 1):
|
||||
# intersection
|
||||
c_start = max(0, off)
|
||||
c_end = min(n_curr, n_ref + off)
|
||||
|
||||
r_start = max(0, -off)
|
||||
r_end = min(n_ref, n_curr - off)
|
||||
|
||||
if (c_end - c_start) < 10:
|
||||
continue
|
||||
|
||||
c_seg = curr[c_start:c_end]
|
||||
r_seg = ref[r_start:r_end]
|
||||
|
||||
mae = np.mean(np.abs(c_seg - r_seg))
|
||||
if mae < best_mae:
|
||||
best_mae = mae
|
||||
final_offset = off
|
||||
|
||||
# Calculate Final Score metrics
|
||||
off = final_offset
|
||||
c_start = max(0, off)
|
||||
c_end = min(n_curr, n_ref + off)
|
||||
r_start = max(0, -off)
|
||||
r_end = min(n_ref, n_curr - off)
|
||||
|
||||
if (c_end - c_start) < 5:
|
||||
return 0.0, {"mae": float(best_mae)}, final_offset
|
||||
|
||||
c_final = curr[c_start:c_end]
|
||||
r_final = ref[r_start:r_end]
|
||||
|
||||
mae = np.mean(np.abs(c_final - r_final))
|
||||
|
||||
# Correlation
|
||||
if np.std(c_final) > 1e-6 and np.std(r_final) > 1e-6:
|
||||
corr = np.corrcoef(c_final, r_final)[0, 1]
|
||||
else:
|
||||
corr = 0.0
|
||||
|
||||
mae_score = 100.0 / (100.0 + mae)
|
||||
score = (0.6 * max(0, corr)) + (0.4 * mae_score)
|
||||
|
||||
return float(score), {"mae": float(mae), "corr": float(corr)}, final_offset
|
||||
|
||||
def compute_dtw_lite(
|
||||
x: np.ndarray, y: np.ndarray, band_width_ratio: float = 0.1
|
||||
) -> float:
|
||||
"""
|
||||
Compute DTW distance with Sakoe-Chiba band constraint.
|
||||
Optimized 1D DP implementation. O(N*W).
|
||||
"""
|
||||
n, m = len(x), len(y)
|
||||
if n == 0 or m == 0:
|
||||
return float("inf")
|
||||
|
||||
# Band width
|
||||
w = max(1, int(min(n, m) * band_width_ratio))
|
||||
|
||||
# Use two rows to save memory and improve cache locality
|
||||
prev_row = np.full(m + 1, float("inf"))
|
||||
curr_row = np.full(m + 1, float("inf"))
|
||||
prev_row[0] = 0
|
||||
|
||||
for i in range(1, n + 1):
|
||||
center = int(i * (m / n))
|
||||
start_j = max(1, center - w)
|
||||
end_j = min(m, center + w + 1)
|
||||
|
||||
curr_row.fill(float("inf"))
|
||||
|
||||
# Pre-calculate costs for the current window to reduce Python overhead
|
||||
# x is 0-indexed, so x[i-1]
|
||||
val_x = x[i - 1]
|
||||
|
||||
for j in range(start_j, end_j + 1):
|
||||
cost = abs(float(val_x - y[j - 1]))
|
||||
|
||||
# Standard DTW recursion
|
||||
# curr_row[j] = cost + min(insertion, deletion, match)
|
||||
# insertion: prev_row[j]
|
||||
# deletion: curr_row[j-1]
|
||||
# match: prev_row[j-1]
|
||||
|
||||
# Use a slightly faster min implementation if possible
|
||||
m1 = prev_row[j]
|
||||
m2 = curr_row[j - 1]
|
||||
m3 = prev_row[j - 1]
|
||||
|
||||
if m1 < m2:
|
||||
if m1 < m3:
|
||||
best_prev = m1
|
||||
else:
|
||||
best_prev = m3
|
||||
else:
|
||||
if m2 < m3:
|
||||
best_prev = m2
|
||||
else:
|
||||
best_prev = m3
|
||||
|
||||
curr_row[j] = cost + best_prev
|
||||
|
||||
# Swap rows
|
||||
prev_row[:] = curr_row[:]
|
||||
|
||||
return float(prev_row[m])
|
||||
|
||||
def compute_matches_worker(
|
||||
current_power: list[float],
|
||||
current_duration: float,
|
||||
snapshots: list[dict[str, Any]],
|
||||
config: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Worker function to compute matches against snapshots."""
|
||||
candidates: list[dict[str, Any]] = []
|
||||
|
||||
min_duration_ratio = config.get("min_duration_ratio", 0.07)
|
||||
max_duration_ratio = config.get("max_duration_ratio", 1.3)
|
||||
dtw_bandwidth = config.get("dtw_bandwidth", 0.1)
|
||||
|
||||
curr_arr = np.array(current_power)
|
||||
|
||||
for item in snapshots:
|
||||
name = item["name"]
|
||||
profile_duration = item["avg_duration"]
|
||||
sample_power = item["sample_power"]
|
||||
|
||||
# Duration Check
|
||||
if profile_duration > 0:
|
||||
ratio = current_duration / profile_duration
|
||||
if ratio < min_duration_ratio or ratio > max_duration_ratio:
|
||||
continue
|
||||
|
||||
# Core Similarity
|
||||
score, metrics, offset = find_best_alignment(
|
||||
current_power, sample_power, 1.0
|
||||
)
|
||||
|
||||
if score > 0.1:
|
||||
candidates.append({
|
||||
"name": name,
|
||||
"score": score,
|
||||
"metrics": metrics,
|
||||
"profile_duration": profile_duration,
|
||||
"current": current_power,
|
||||
"sample": sample_power,
|
||||
"offset": offset
|
||||
})
|
||||
|
||||
candidates.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
# Stage 3: DTW Refinement on Top 3
|
||||
if dtw_bandwidth > 0.0 and len(candidates) > 0:
|
||||
to_refine = candidates[:3]
|
||||
|
||||
for cand in to_refine:
|
||||
sample_arr = np.array(cand["sample"])
|
||||
|
||||
dtw_dist = compute_dtw_lite(
|
||||
curr_arr,
|
||||
sample_arr,
|
||||
band_width_ratio=dtw_bandwidth,
|
||||
)
|
||||
|
||||
n_points = len(curr_arr)
|
||||
if n_points > 0:
|
||||
norm_dist = dtw_dist / n_points
|
||||
else:
|
||||
norm_dist = 999.0
|
||||
|
||||
dtw_score = 1.0 / (1.0 + norm_dist / 50.0)
|
||||
|
||||
cand["original_score"] = float(cand["score"])
|
||||
cand["score"] = float(0.5 * cand["score"] + 0.5 * dtw_score)
|
||||
cand["dtw_dist"] = float(norm_dist)
|
||||
|
||||
candidates.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
return candidates
|
||||
|
||||
def compute_dtw_path(
|
||||
x: np.ndarray, y: np.ndarray, band_width_ratio: float = 0.1
|
||||
) -> list[tuple[int, int]]:
|
||||
"""
|
||||
Compute DTW path with Sakoe-Chiba constraint.
|
||||
Returns list of (x_index, y_index) tuples mapping X to Y.
|
||||
"""
|
||||
n, m = len(x), len(y)
|
||||
if n == 0 or m == 0:
|
||||
return []
|
||||
|
||||
w = max(1, int(min(n, m) * band_width_ratio))
|
||||
cost_matrix = np.full((n + 1, m + 1), float("inf"))
|
||||
cost_matrix[0, 0] = 0
|
||||
|
||||
# Cost Matrix
|
||||
for i in range(1, n + 1):
|
||||
center = i * (m / n)
|
||||
start_j = max(1, int(center - w))
|
||||
end_j = min(m, int(center + w) + 1)
|
||||
|
||||
for j in range(start_j, end_j + 1):
|
||||
cost = abs(float(x[i - 1] - y[j - 1]))
|
||||
cost_matrix[i, j] = cost + min(
|
||||
cost_matrix[i - 1, j], cost_matrix[i, j - 1], cost_matrix[i - 1, j - 1]
|
||||
)
|
||||
|
||||
# Backtracking
|
||||
if np.isinf(cost_matrix[n, m]):
|
||||
# Endpoint is unreachable (e.g. Sakoe-Chiba band excluded it); no valid path.
|
||||
return []
|
||||
|
||||
path: list[tuple[int, int]] = []
|
||||
i, j = n, m
|
||||
|
||||
while i > 0 or j > 0:
|
||||
# Record current zero-based coordinate before stepping back.
|
||||
path.append((max(i - 1, 0), max(j - 1, 0)))
|
||||
|
||||
if i == 0:
|
||||
j -= 1
|
||||
elif j == 0:
|
||||
i -= 1
|
||||
else:
|
||||
candidates_cost = [
|
||||
(cost_matrix[i - 1, j], 0), # deletion (i-1)
|
||||
(cost_matrix[i, j - 1], 1), # insertion (j-1)
|
||||
(cost_matrix[i - 1, j - 1], 2) # match (both)
|
||||
]
|
||||
candidates_cost.sort(key=lambda item: item[0])
|
||||
best_move = candidates_cost[0][1]
|
||||
if best_move == 0:
|
||||
i -= 1
|
||||
elif best_move == 1:
|
||||
j -= 1
|
||||
else:
|
||||
i -= 1
|
||||
j -= 1
|
||||
|
||||
path.reverse()
|
||||
|
||||
return path
|
||||
|
||||
def compute_envelope_worker(
|
||||
raw_cycles_data: list[tuple[list[float], list[float], Optional[float]]] | list[tuple[list[float], list[float]]],
|
||||
dtw_bandwidth: float
|
||||
) -> tuple[list[float], list[float], list[float], list[float], list[float], float] | None:
|
||||
"""
|
||||
Compute statistical envelope.
|
||||
Args:
|
||||
raw_cycles_data: list of (offsets, power_values, duration) tuples.
|
||||
Duration may be None and is used to compute target_duration.
|
||||
dtw_bandwidth: ratio.
|
||||
Returns:
|
||||
(time_grid, min_curve, max_curve, avg_curve, std_curve, target_duration) or None.
|
||||
"""
|
||||
if not raw_cycles_data:
|
||||
return None
|
||||
normalized_curves: list[tuple[np.ndarray, np.ndarray, float]] = []
|
||||
sampling_rates: list[float] = []
|
||||
|
||||
# 1. Pre-process input
|
||||
for curve in raw_cycles_data:
|
||||
# Unpack curve tuple: (offsets, values) or (offsets, values, duration)
|
||||
# Backward compatible with 2-tuple (offsets, values) format
|
||||
try:
|
||||
offsets_list, values_list, *rest = curve
|
||||
curve_duration = rest[0] if rest else None
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if not offsets_list or not values_list:
|
||||
continue
|
||||
|
||||
if len(offsets_list) != len(values_list):
|
||||
min_len = min(len(offsets_list), len(values_list))
|
||||
if min_len < 3:
|
||||
continue
|
||||
offsets_list = offsets_list[:min_len]
|
||||
values_list = values_list[:min_len]
|
||||
|
||||
if len(offsets_list) < 3 or len(values_list) < 3:
|
||||
continue
|
||||
|
||||
try:
|
||||
offsets = np.asarray(offsets_list, dtype=float)
|
||||
values = np.asarray(values_list, dtype=float)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
# Drop paired entries where either coordinate is non-finite.
|
||||
finite_mask = np.isfinite(offsets) & np.isfinite(values)
|
||||
offsets = offsets[finite_mask]
|
||||
values = values[finite_mask]
|
||||
if len(offsets) < 3:
|
||||
continue
|
||||
|
||||
if not np.all(np.diff(offsets) > 0):
|
||||
continue
|
||||
|
||||
try:
|
||||
dur = float(curve_duration) if curve_duration is not None else float(offsets[-1])
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
|
||||
# Validate duration is positive and finite before appending.
|
||||
if not (dur > 0 and np.isfinite(dur)):
|
||||
continue
|
||||
|
||||
normalized_curves.append((offsets, values, dur))
|
||||
|
||||
if len(offsets) > 1:
|
||||
intervals = np.diff(offsets)
|
||||
positive_intervals = intervals[intervals > 0]
|
||||
if positive_intervals.size > 0:
|
||||
sr = float(np.median(positive_intervals))
|
||||
if np.isfinite(sr):
|
||||
sampling_rates.append(sr)
|
||||
if not normalized_curves:
|
||||
return None
|
||||
|
||||
# 2. Reference Selection (Median Duration)
|
||||
# Input is now (offsets, values, duration)
|
||||
max_times = [float(dur) for _, _, dur in normalized_curves]
|
||||
median_dur = float(np.median(max_times))
|
||||
ref_idx = int(np.argmin([abs(t - median_dur) for t in max_times]))
|
||||
|
||||
target_duration = max_times[ref_idx]
|
||||
avg_sample_rate = float(np.median(sampling_rates)) if sampling_rates else 2.0
|
||||
|
||||
# Ensure target_duration is valid for calculations
|
||||
if not (target_duration > 0 and np.isfinite(target_duration)):
|
||||
target_duration = 1.0 # Safe default
|
||||
|
||||
align_dt = avg_sample_rate
|
||||
num_points = max(50, int(target_duration / align_dt))
|
||||
time_grid = np.linspace(0.0, target_duration, num_points)
|
||||
|
||||
ref_offsets, ref_values, _ = normalized_curves[ref_idx]
|
||||
ref_array = np.interp(time_grid, ref_offsets, ref_values)
|
||||
|
||||
# 3. Resample & DTW
|
||||
resampled: list[np.ndarray] = []
|
||||
|
||||
for i, (offsets, values, dur) in enumerate(normalized_curves):
|
||||
if i == ref_idx:
|
||||
resampled.append(ref_array)
|
||||
continue
|
||||
this_dur = dur
|
||||
this_num_points = max(10, int(this_dur / align_dt))
|
||||
this_grid = np.linspace(0.0, this_dur, this_num_points)
|
||||
this_array = np.interp(this_grid, offsets, values)
|
||||
|
||||
path = compute_dtw_path(this_array, ref_array, band_width_ratio=dtw_bandwidth)
|
||||
|
||||
if not path:
|
||||
resampled.append(np.interp(time_grid, offsets, values))
|
||||
continue
|
||||
path_arr = np.array(path)
|
||||
cand_indices = path_arr[:, 0]
|
||||
ref_indices = path_arr[:, 1]
|
||||
|
||||
# Interpolate map
|
||||
# Map ref indices (time_grid indices) to cand indices (this_grid indices)
|
||||
# We assume monotonicity and filter duplicates by taking mean
|
||||
|
||||
# Simplified: Use numpy interp of indicies
|
||||
# ref_indices are 0..N_ref
|
||||
# cand_indices are 0..N_cand
|
||||
# We need mapping: for ref_idx in 0..num_points, what is cand_idx?
|
||||
|
||||
# Since ref_indices in path are not strictly increasing (duplicates),
|
||||
# we can't use them as 'x' for interp directly if strictness required.
|
||||
# But we can sort/unique them.
|
||||
|
||||
# Sort by ref_index? Path is already sorted roughly.
|
||||
# Handle duplicates: average candidate indices for same ref index.
|
||||
unique_ref, inverse = np.unique(ref_indices, return_inverse=True)
|
||||
# Computing mean candidate index for each unique ref index
|
||||
# This is slow in python loop.
|
||||
# Vectorized:
|
||||
# np.bincount?
|
||||
mean_cand_indices = np.zeros_like(unique_ref, dtype=float)
|
||||
np.add.at(mean_cand_indices, inverse, cand_indices)
|
||||
counts = np.bincount(inverse)
|
||||
mean_cand_indices /= counts
|
||||
|
||||
# Now we have unique_ref -> mean_cand_indices
|
||||
# Interpolate to full time_grid (0..num_points-1)
|
||||
mapped_cand_indices = np.interp(
|
||||
np.arange(num_points),
|
||||
unique_ref,
|
||||
mean_cand_indices,
|
||||
left=0,
|
||||
right=len(this_array)-1
|
||||
)
|
||||
|
||||
# Now get values
|
||||
mapped_times = mapped_cand_indices * (this_dur / (len(this_array)-1))
|
||||
warped_values = np.interp(mapped_times, this_grid, this_array)
|
||||
resampled.append(warped_values)
|
||||
|
||||
# 4. Compute Stats
|
||||
stacked = np.vstack(resampled)
|
||||
min_curve = np.min(stacked, axis=0)
|
||||
max_curve = np.max(stacked, axis=0)
|
||||
avg_curve = np.mean(stacked, axis=0)
|
||||
std_curve = np.std(stacked, axis=0)
|
||||
|
||||
return (
|
||||
time_grid.tolist(),
|
||||
min_curve.tolist(),
|
||||
max_curve.tolist(),
|
||||
avg_curve.tolist(),
|
||||
std_curve.tolist(),
|
||||
float(target_duration)
|
||||
)
|
||||
|
||||
def verify_profile_alignment_worker(
|
||||
current_power: list[float],
|
||||
envelope_avg_curve: list[float],
|
||||
envelope_time_grid: list[float],
|
||||
dtw_bandwidth: float
|
||||
) -> tuple[float, float, float]:
|
||||
"""
|
||||
Verify alignment of current trace against profile envelope.
|
||||
Returns: (mapped_envelope_time, mapped_envelope_power, overlap_score)
|
||||
"""
|
||||
if not current_power or not envelope_avg_curve:
|
||||
return 0.0, 9999.0, 0.0
|
||||
|
||||
curr = np.array(current_power)
|
||||
ref = np.array(envelope_avg_curve)
|
||||
|
||||
# 1. Coarse Alignment
|
||||
score, _, offset = find_best_alignment(curr, ref, 1.0)
|
||||
|
||||
# 2. Extract aligned segments
|
||||
# Determine the mapping window.
|
||||
|
||||
# Symmetric context window: pad equally left and right of the coarse alignment.
|
||||
half = ALIGNMENT_CONTEXT_BUFFER // 2
|
||||
start_ref = max(0, offset - half)
|
||||
end_ref = min(len(ref), offset + len(curr) + half)
|
||||
|
||||
if end_ref <= start_ref:
|
||||
return 0.0, 9999.0, 0.0
|
||||
|
||||
ref_seg = ref[start_ref:end_ref]
|
||||
curr_seg = curr
|
||||
|
||||
if offset < 0:
|
||||
curr_seg = curr[-offset:]
|
||||
|
||||
path = compute_dtw_path(curr_seg, ref_seg, band_width_ratio=dtw_bandwidth)
|
||||
|
||||
if not path:
|
||||
# Fallback to linear mapping based on offset
|
||||
mapped_idx = min(len(ref)-1, offset + len(curr) - 1)
|
||||
mapped_idx = max(0, mapped_idx)
|
||||
else:
|
||||
# Map the final point of the current trace to the reference index
|
||||
last_pair = path[-1]
|
||||
ref_seg_idx = last_pair[1]
|
||||
mapped_idx = start_ref + ref_seg_idx
|
||||
|
||||
# Ensure sequences are non-empty before indexing
|
||||
if not envelope_time_grid or len(ref) == 0:
|
||||
return 0.0, 9999.0, 0.0
|
||||
mapped_idx = min(mapped_idx, len(envelope_time_grid) - 1, len(ref) - 1)
|
||||
|
||||
mapped_time = float(envelope_time_grid[mapped_idx])
|
||||
mapped_power = float(ref[mapped_idx])
|
||||
|
||||
return mapped_time, mapped_power, float(score)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Binary sensor for WashData."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
STATE_RUNNING,
|
||||
SIGNAL_WASHER_UPDATE,
|
||||
CONF_EXPOSE_DEBUG_ENTITIES,
|
||||
)
|
||||
from .manager import WashDataManager
|
||||
from .sensor import cleanup_orphaned_diagnostic_entities
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the binary sensor."""
|
||||
manager: WashDataManager = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = [WasherRunningBinarySensor(manager, entry)]
|
||||
|
||||
if entry.options.get(CONF_EXPOSE_DEBUG_ENTITIES):
|
||||
entities.append(WasherAmbiguitySensor(manager, entry))
|
||||
|
||||
async_add_entities(entities)
|
||||
cleanup_orphaned_diagnostic_entities(hass, manager, entry)
|
||||
|
||||
|
||||
class WasherRunningBinarySensor(BinarySensorEntity):
|
||||
"""Binary sensor indicating if washer is running."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
_attr_translation_key = "running"
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
self._manager = manager
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_running"
|
||||
self._attr_device_info = {
|
||||
"identifiers": {(DOMAIN, entry.entry_id)},
|
||||
"name": entry.title,
|
||||
"manufacturer": "WashData",
|
||||
}
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self._manager.check_state() == STATE_RUNNING
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
SIGNAL_WASHER_UPDATE.format(self._entry.entry_id),
|
||||
self._update_callback,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_callback(self) -> None:
|
||||
"""Update the sensor."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class WasherAmbiguitySensor(WasherRunningBinarySensor):
|
||||
"""Binary sensor indicating if current profiling is ambiguous."""
|
||||
|
||||
_attr_translation_key = "match_ambiguity"
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(manager, entry)
|
||||
self._attr_unique_id = f"{entry.entry_id}_ambiguity"
|
||||
self._attr_icon = "mdi:alert-circle-outline"
|
||||
self._attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool: # type: ignore[override]
|
||||
"""Return true if match is ambiguous."""
|
||||
return self._manager.match_ambiguity
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]: # type: ignore[override]
|
||||
"""Return ambiguous candidate info."""
|
||||
details = self._manager.last_match_details
|
||||
return {"margin": details.get("ambiguity_margin", 0.0) if details else 0.0}
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Button platform for WashData."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING, SIGNAL_WASHER_UPDATE
|
||||
from .manager import WashDataManager
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the WashData button."""
|
||||
manager: WashDataManager = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
async_add_entities([
|
||||
WashDataTerminateButton(manager, entry),
|
||||
WashDataPauseCycleButton(manager, entry),
|
||||
WashDataResumeCycleButton(manager, entry),
|
||||
])
|
||||
|
||||
|
||||
class WashDataTerminateButton(ButtonEntity):
|
||||
"""Button to force terminate the current cycle."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "force_end_cycle"
|
||||
_attr_icon = "mdi:stop-circle-outline"
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the button."""
|
||||
self._manager = manager
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_force_end"
|
||||
self._attr_device_info = {
|
||||
"identifiers": {(DOMAIN, entry.entry_id)},
|
||||
"name": entry.title,
|
||||
"manufacturer": "WashData",
|
||||
}
|
||||
|
||||
def press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self._manager.async_terminate_cycle()
|
||||
|
||||
|
||||
class WashDataPauseCycleButton(ButtonEntity):
|
||||
"""Button to pause the current cycle (user-triggered)."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "pause_cycle"
|
||||
_attr_icon = "mdi:pause-circle-outline"
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the button."""
|
||||
self._manager = manager
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_pause_cycle"
|
||||
self._attr_device_info = {
|
||||
"identifiers": {(DOMAIN, entry.entry_id)},
|
||||
"name": entry.title,
|
||||
"manufacturer": "WashData",
|
||||
}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
SIGNAL_WASHER_UPDATE.format(self._entry.entry_id),
|
||||
self._update_callback,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_callback(self) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Only available when a cycle is active and not already user-paused."""
|
||||
return (
|
||||
self._manager.check_state() in (STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING)
|
||||
and not self._manager.is_user_paused
|
||||
)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self._manager.async_pause_cycle()
|
||||
|
||||
|
||||
class WashDataResumeCycleButton(ButtonEntity):
|
||||
"""Button to resume a user-paused cycle."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "resume_cycle"
|
||||
_attr_icon = "mdi:play-circle-outline"
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the button."""
|
||||
self._manager = manager
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_resume_cycle"
|
||||
self._attr_device_info = {
|
||||
"identifiers": {(DOMAIN, entry.entry_id)},
|
||||
"name": entry.title,
|
||||
"manufacturer": "WashData",
|
||||
}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
SIGNAL_WASHER_UPDATE.format(self._entry.entry_id),
|
||||
self._update_callback,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_callback(self) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Only available when the cycle is user-paused."""
|
||||
return self._manager.is_user_paused
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self._manager.async_resume_cycle()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
||||
"""Constants for the WashData integration."""
|
||||
|
||||
DOMAIN = "ha_washdata"
|
||||
|
||||
# Configuration keys
|
||||
CONF_POWER_SENSOR = "power_sensor"
|
||||
CONF_NAME = "name"
|
||||
CONF_MIN_POWER = "min_power"
|
||||
CONF_OFF_DELAY = "off_delay"
|
||||
CONF_NOTIFY_SERVICE = "notify_service" # Deprecated - kept for migration only
|
||||
CONF_NOTIFY_ACTIONS = "notify_actions"
|
||||
CONF_NOTIFY_PEOPLE = "notify_people"
|
||||
CONF_NOTIFY_ONLY_WHEN_HOME = "notify_only_when_home"
|
||||
CONF_NOTIFY_FIRE_EVENTS = "notify_fire_events"
|
||||
CONF_NOTIFY_EVENTS = "notify_events" # Deprecated - kept for migration only
|
||||
CONF_NOTIFY_START_SERVICES = "notify_start_services"
|
||||
CONF_NOTIFY_FINISH_SERVICES = "notify_finish_services"
|
||||
CONF_NOTIFY_LIVE_SERVICES = "notify_live_services"
|
||||
CONF_NO_UPDATE_ACTIVE_TIMEOUT = "no_update_active_timeout"
|
||||
CONF_LOW_POWER_NO_UPDATE_TIMEOUT = "low_power_no_update_timeout"
|
||||
CONF_SMOOTHING_WINDOW = "smoothing_window"
|
||||
CONF_SAMPLING_INTERVAL = "sampling_interval"
|
||||
CONF_START_DURATION_THRESHOLD = (
|
||||
"start_duration_threshold" # Debounce for start detection
|
||||
)
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
CONF_PROFILE_DURATION_TOLERANCE = "profile_duration_tolerance"
|
||||
|
||||
|
||||
CONF_INTERRUPTED_MIN_SECONDS = "interrupted_min_seconds" # Internal use only
|
||||
CONF_PROGRESS_RESET_DELAY = "progress_reset_delay"
|
||||
CONF_LEARNING_CONFIDENCE = "learning_confidence"
|
||||
CONF_DURATION_TOLERANCE = "duration_tolerance"
|
||||
CONF_AUTO_LABEL_CONFIDENCE = "auto_label_confidence"
|
||||
CONF_AUTO_MAINTENANCE = "auto_maintenance"
|
||||
CONF_PROFILE_MATCH_INTERVAL = "profile_match_interval"
|
||||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO = "profile_match_min_duration_ratio"
|
||||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO = "profile_match_max_duration_ratio"
|
||||
CONF_MAX_PAST_CYCLES = "max_past_cycles"
|
||||
CONF_MAX_FULL_TRACES_PER_PROFILE = "max_full_traces_per_profile"
|
||||
CONF_MAX_FULL_TRACES_UNLABELED = "max_full_traces_unlabeled"
|
||||
CONF_WATCHDOG_INTERVAL = "watchdog_interval" # Derived from sampling_interval
|
||||
CONF_MATCH_PERSISTENCE = "match_persistence"
|
||||
CONF_COMPLETION_MIN_SECONDS = "completion_min_seconds"
|
||||
CONF_NOTIFY_BEFORE_END_MINUTES = "notify_before_end_minutes"
|
||||
CONF_APPLY_SUGGESTIONS = "apply_suggestions"
|
||||
CONF_RUNNING_DEAD_ZONE = "running_dead_zone" # Seconds after start to ignore power dips
|
||||
CONF_END_REPEAT_COUNT = "end_repeat_count" # Number of times end condition must be met
|
||||
CONF_SHOW_ADVANCED = "show_advanced" # Toggle advanced settings
|
||||
CONF_MIN_OFF_GAP = "min_off_gap" # Minimum gap to separate cycles (seconds)
|
||||
CONF_START_ENERGY_THRESHOLD = "start_energy_threshold" # Wh required to confirm start
|
||||
CONF_END_ENERGY_THRESHOLD = "end_energy_threshold" # Wh allowed during end candidates
|
||||
CONF_START_THRESHOLD_W = "start_threshold_w" # Custom power threshold for STARTING
|
||||
CONF_STOP_THRESHOLD_W = (
|
||||
"stop_threshold_w" # Custom power threshold for ENDING (hysteresis)
|
||||
)
|
||||
CONF_EXPOSE_DEBUG_ENTITIES = "expose_debug_entities" # Expose detailed debug sensors
|
||||
CONF_SAVE_DEBUG_TRACES = (
|
||||
"save_debug_traces" # Improve historical cycle data with rich debug info
|
||||
)
|
||||
# Cycle interruption detection settings (not exposed in UI, but used internally)
|
||||
CONF_ABRUPT_DROP_WATTS = "abrupt_drop_watts" # Power cliff threshold for interrupted status
|
||||
CONF_ABRUPT_DROP_RATIO = "abrupt_drop_ratio" # Relative drop ratio for interrupted status
|
||||
CONF_ABRUPT_HIGH_LOAD_FACTOR = "abrupt_high_load_factor" # High load factor threshold
|
||||
CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD = "auto_tune_noise_events_threshold" # Noise events before auto-tune
|
||||
CONF_EXTERNAL_END_TRIGGER_ENABLED = "external_end_trigger_enabled" # Enable external cycle end trigger
|
||||
CONF_EXTERNAL_END_TRIGGER = "external_end_trigger" # Binary sensor entity for external cycle end
|
||||
CONF_EXTERNAL_END_TRIGGER_INVERTED = "external_end_trigger_inverted" # Invert external trigger logic (trigger on OFF)
|
||||
CONF_ANTI_WRINKLE_ENABLED = "anti_wrinkle_enabled" # Dryer anti-wrinkle shielding
|
||||
CONF_ANTI_WRINKLE_MAX_POWER = "anti_wrinkle_max_power" # W threshold for anti-wrinkle spikes
|
||||
CONF_ANTI_WRINKLE_MAX_DURATION = "anti_wrinkle_max_duration" # Seconds to treat as anti-wrinkle
|
||||
CONF_ANTI_WRINKLE_EXIT_POWER = "anti_wrinkle_exit_power" # W threshold for true-off exit
|
||||
CONF_DELAY_START_DETECT_ENABLED = "delay_start_detect_enabled" # Enable delayed-start detection
|
||||
CONF_DELAY_CONFIRM_SECONDS = "delay_confirm_seconds" # Seconds power must stay in standby band before DELAY_WAIT engages
|
||||
CONF_DELAY_TIMEOUT_HOURS = "delay_timeout_hours" # Safety timeout (hours) while waiting to start
|
||||
|
||||
# Deprecated since 0.4.5: drain-spike model replaced by band-based DELAY_WAIT.
|
||||
# Kept only so older Store/options blobs don't raise KeyError during migration.
|
||||
CONF_DELAY_DRAIN_MIN_POWER = "delay_drain_min_power"
|
||||
CONF_DELAY_DRAIN_MAX_POWER = "delay_drain_max_power"
|
||||
CONF_DELAY_DRAIN_MAX_DURATION = "delay_drain_max_duration"
|
||||
|
||||
|
||||
NOTIFY_EVENT_START = "cycle_start"
|
||||
NOTIFY_EVENT_FINISH = "cycle_finish"
|
||||
NOTIFY_EVENT_LIVE = "cycle_live"
|
||||
NOTIFY_EVENT_CLEAN = "cycle_clean" # Laundry still inside after cycle ends
|
||||
|
||||
CONF_NOTIFY_TITLE = "notify_title"
|
||||
CONF_NOTIFY_ICON = "notify_icon"
|
||||
CONF_NOTIFY_START_MESSAGE = "notify_start_message"
|
||||
CONF_NOTIFY_FINISH_MESSAGE = "notify_finish_message"
|
||||
CONF_NOTIFY_PRE_COMPLETE_MESSAGE = "notify_pre_complete_message"
|
||||
CONF_NOTIFY_LIVE_INTERVAL_SECONDS = "notify_live_interval_seconds"
|
||||
CONF_NOTIFY_LIVE_OVERRUN_PERCENT = "notify_live_overrun_percent"
|
||||
CONF_NOTIFY_LIVE_CHRONOMETER = "notify_live_chronometer"
|
||||
CONF_NOTIFY_REMINDER_MESSAGE = "notify_reminder_message" # Distinct one-time pre-end alert
|
||||
CONF_NOTIFY_TIMEOUT_SECONDS = "notify_timeout_seconds" # Auto-dismiss after N seconds (0 = never)
|
||||
CONF_NOTIFY_CHANNEL = "notify_channel" # Android channel for status/live/reminder
|
||||
CONF_NOTIFY_FINISH_CHANNEL = "notify_finish_channel" # Distinct Android channel for finished/clean
|
||||
CONF_ENERGY_PRICE_STATIC = "energy_price_static"
|
||||
CONF_ENERGY_PRICE_ENTITY = "energy_price_entity"
|
||||
|
||||
# Door sensor & pause
|
||||
CONF_DOOR_SENSOR_ENTITY = "door_sensor_entity" # Optional binary_sensor for machine door
|
||||
CONF_PAUSE_CUTS_POWER = "pause_cuts_power" # Also turn off switch entity when pausing
|
||||
CONF_SWITCH_ENTITY = "switch_entity" # Optional switch entity toggled on pause/resume
|
||||
CONF_NOTIFY_UNLOAD_DELAY_MINUTES = "notify_unload_delay_minutes" # Minutes before "laundry waiting" nag
|
||||
CONF_NOTIFY_UNLOAD_MESSAGE = "notify_unload_message" # Template for the clean-laundry nag message
|
||||
|
||||
# Optional link to an existing HA device (e.g. the smart plug or appliance).
|
||||
# When set, the WashData device is exposed as "Connected via <device>" through
|
||||
# the device registry's via_device relationship. Stores a device registry id.
|
||||
CONF_LINKED_DEVICE = "linked_device"
|
||||
|
||||
DEFAULT_NOTIFY_TITLE = "WashData: {device}"
|
||||
DEFAULT_NOTIFY_START_MESSAGE = "{device} started."
|
||||
DEFAULT_NOTIFY_FINISH_MESSAGE = "{device} finished. Duration: {duration}m."
|
||||
DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE = "{device}: Less than {minutes} minutes remaining."
|
||||
DEFAULT_NOTIFY_REMINDER_MESSAGE = "{device}: about {minutes} minutes left."
|
||||
DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE = "{device}: No profile matched yet."
|
||||
DEFAULT_NOTIFY_ONLY_WHEN_HOME = False
|
||||
DEFAULT_NOTIFY_FIRE_EVENTS = True
|
||||
DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS = 300
|
||||
DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT = 20
|
||||
DEFAULT_NOTIFY_LIVE_CHRONOMETER = False
|
||||
DEFAULT_NOTIFY_TIMEOUT_SECONDS = 0 # 0 = notifications never auto-dismiss
|
||||
DEFAULT_NOTIFY_CHANNEL = "" # Empty = omit channel (companion app default)
|
||||
DEFAULT_NOTIFY_FINISH_CHANNEL = "" # Empty = reuse status channel
|
||||
DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES = 60 # 1 hour before "still waiting" nag notification
|
||||
DEFAULT_NOTIFY_UNLOAD_MESSAGE = "{device} finished {duration}m ago - laundry is still inside."
|
||||
|
||||
# Defaults
|
||||
DEFAULT_MIN_POWER = 2.0 # Watts
|
||||
DEFAULT_OFF_DELAY = 180 # Seconds (3 minutes, safer for 60s polling)
|
||||
DEFAULT_NAME = "Washing Machine"
|
||||
# Seconds without updates while active before forced stop (publish-on-change sockets)
|
||||
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT = 600 # 10 minutes
|
||||
DEFAULT_SMOOTHING_WINDOW = 2
|
||||
DEFAULT_SAMPLING_INTERVAL = 30.0 # Seconds
|
||||
DEFAULT_START_DURATION_THRESHOLD = 5.0 # Seconds (debounce)
|
||||
DEFAULT_START_ENERGY_THRESHOLD = 0.2 # Wh - Require some energy accumulation before starting
|
||||
DEFAULT_END_ENERGY_THRESHOLD = 0.05 # Wh - Require effectively zero energy to end
|
||||
DEFAULT_DEVICE_TYPE = "washing_machine"
|
||||
DEFAULT_PROFILE_DURATION_TOLERANCE = 0.25
|
||||
|
||||
DEFAULT_INTERRUPTED_MIN_SECONDS = 150 # Internal use only, not exposed
|
||||
|
||||
DEFAULT_PROGRESS_RESET_DELAY = 1800 # Seconds (30 minutes state expiry/unload window)
|
||||
DEFAULT_LEARNING_CONFIDENCE = 0.6 # Minimum confidence to request user verification
|
||||
DEFAULT_DURATION_TOLERANCE = 0.10 # Allow ±10% duration variance before flagging
|
||||
DEFAULT_AUTO_LABEL_CONFIDENCE = 0.9 # High confidence auto-label threshold
|
||||
DEFAULT_AUTO_MAINTENANCE = True # Enable nightly cleanup by default
|
||||
DEFAULT_COMPLETION_MIN_SECONDS = 600 # 10 minutes
|
||||
DEFAULT_NOTIFY_BEFORE_END_MINUTES = 0 # Disabled
|
||||
DEFAULT_PROFILE_MATCH_INTERVAL = (
|
||||
300 # Seconds between profile matching attempts (5 minutes)
|
||||
)
|
||||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO = 0.10 # Allow match after 10% of expected duration
|
||||
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO = (
|
||||
1.3 # Maximum duration ratio (130% of profile) - hidden default
|
||||
)
|
||||
DEFAULT_MAX_PAST_CYCLES = 200
|
||||
DEFAULT_MAX_FULL_TRACES_PER_PROFILE = 20
|
||||
DEFAULT_MAX_FULL_TRACES_UNLABELED = 20
|
||||
DEFAULT_WATCHDOG_INTERVAL = 30 # Derived: 2 * sampling_interval + 1
|
||||
DEFAULT_MATCH_PERSISTENCE = 3
|
||||
DEFAULT_RUNNING_DEAD_ZONE = 3 # Seconds after start to ignore power dips
|
||||
DEFAULT_END_REPEAT_COUNT = 1 # 1 = current behavior (no repeat required)
|
||||
|
||||
# Matching & Termination Stability
|
||||
DEFAULT_MATCH_REVERT_RATIO = 0.4 # Drop from peak score to revert to detecting
|
||||
DEFAULT_DEFER_FINISH_CONFIDENCE = 0.55 # Minimum confidence to defer cycle finish
|
||||
|
||||
# Cycle interruption detection defaults (internal)
|
||||
DEFAULT_ABRUPT_DROP_WATTS = 500.0 # Power cliff detection threshold (W)
|
||||
DEFAULT_ABRUPT_DROP_RATIO = 0.6 # 60% drop considered abrupt
|
||||
DEFAULT_ABRUPT_HIGH_LOAD_FACTOR = 5.0 # High load factor threshold
|
||||
DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD = 3 # Ghost cycles before threshold adjustment
|
||||
|
||||
# Anti-wrinkle defaults (advanced; disabled by default)
|
||||
DEFAULT_ANTI_WRINKLE_ENABLED = False
|
||||
DEFAULT_ANTI_WRINKLE_MAX_POWER = 400.0 # W
|
||||
DEFAULT_ANTI_WRINKLE_MAX_DURATION = 60.0 # s
|
||||
DEFAULT_ANTI_WRINKLE_EXIT_POWER = 0.8 # W
|
||||
|
||||
# Delayed-start detection defaults (disabled by default).
|
||||
#
|
||||
# The detector watches for sustained power between stop_threshold_w and
|
||||
# start_threshold_w (the "standby band"): a machine sitting in that band
|
||||
# for at least DEFAULT_DELAY_CONFIRM_SECONDS is in delayed-start mode, not
|
||||
# off and not running. Short menu-navigation peaks above the band are
|
||||
# ignored because they don't sustain long enough to satisfy the normal
|
||||
# start-duration gate.
|
||||
DEFAULT_DELAY_START_DETECT_ENABLED = False
|
||||
DEFAULT_DELAY_CONFIRM_SECONDS = 60.0 # s — sustained standby before DELAY_WAIT engages
|
||||
DEFAULT_DELAY_TIMEOUT_HOURS = 8.0 # h — give up waiting after this long
|
||||
|
||||
# Pump Monitor settings (pump device type only)
|
||||
CONF_PUMP_STUCK_DURATION = "pump_stuck_duration" # Seconds before a running pump is flagged as stuck
|
||||
DEFAULT_PUMP_STUCK_DURATION = 1800 # 30 min - typical sump pump runs <60 s; 30 min implies motor is jammed
|
||||
EVENT_PUMP_STUCK = "ha_washdata_pump_stuck" # Fired when stuck-pump threshold is exceeded
|
||||
|
||||
# Profile Matching Thresholds
|
||||
CONF_PROFILE_MATCH_THRESHOLD = "profile_match_threshold"
|
||||
CONF_PROFILE_UNMATCH_THRESHOLD = "profile_unmatch_threshold"
|
||||
|
||||
DEFAULT_PROFILE_MATCH_THRESHOLD = 0.4
|
||||
DEFAULT_PROFILE_UNMATCH_THRESHOLD = 0.35
|
||||
|
||||
CONF_DTW_BANDWIDTH = "dtw_bandwidth"
|
||||
DEFAULT_DTW_BANDWIDTH = 0.20 # 20% Sakoe-Chiba constraint
|
||||
|
||||
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS = "suppress_feedback_notifications"
|
||||
DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS = False # Show persistent notifications by default
|
||||
|
||||
# States
|
||||
STATE_OFF = "off"
|
||||
STATE_DELAY_WAIT = "delay_wait"
|
||||
STATE_IDLE = "idle"
|
||||
STATE_STARTING = "starting"
|
||||
STATE_RUNNING = "running"
|
||||
STATE_PAUSED = "paused"
|
||||
STATE_USER_PAUSED = "user_paused"
|
||||
STATE_ENDING = "ending"
|
||||
STATE_FINISHED = "finished"
|
||||
STATE_ANTI_WRINKLE = "anti_wrinkle"
|
||||
STATE_INTERRUPTED = "interrupted"
|
||||
STATE_FORCE_STOPPED = "force_stopped"
|
||||
STATE_RINSE = "rinse"
|
||||
STATE_UNKNOWN = "unknown"
|
||||
STATE_CLEAN = "clean" # Cycle ended but door not yet opened (laundry still inside)
|
||||
|
||||
# Cycle Status (how the cycle ended)
|
||||
CYCLE_STATUS_COMPLETED = "completed" # Natural completion (power dropped)
|
||||
CYCLE_STATUS_INTERRUPTED = (
|
||||
"interrupted" # Abnormal/short run or abrupt power cliff (likely user/power abort)
|
||||
)
|
||||
CYCLE_STATUS_FORCE_STOPPED = "force_stopped" # Watchdog forced end (sensor offline)
|
||||
CYCLE_STATUS_RESUMED = "resumed" # Cycle was restored from storage after restart
|
||||
|
||||
# Device Types
|
||||
DEVICE_TYPE_WASHING_MACHINE = "washing_machine"
|
||||
DEVICE_TYPE_DRYER = "dryer"
|
||||
DEVICE_TYPE_WASHER_DRYER = "washer_dryer"
|
||||
DEVICE_TYPE_DISHWASHER = "dishwasher"
|
||||
DEVICE_TYPE_COFFEE_MACHINE = "coffee_machine"
|
||||
DEVICE_TYPE_EV = "ev"
|
||||
DEVICE_TYPE_AIR_FRYER = "air_fryer"
|
||||
DEVICE_TYPE_HEAT_PUMP = "heat_pump"
|
||||
DEVICE_TYPE_BREAD_MAKER = "bread_maker"
|
||||
DEVICE_TYPE_PUMP = "pump"
|
||||
DEVICE_TYPE_OVEN = "oven"
|
||||
# Generic / unsupported bucket. Ships intentionally generic defaults that are
|
||||
# not tuned for any specific appliance, so the user must configure thresholds,
|
||||
# timeouts, and matching parameters themselves. Also serves as the runtime
|
||||
# fallback when a deprecated device type is hard-removed (see
|
||||
# DEPRECATED_DEVICE_TYPE_FALLBACK below). No curated phase catalog and no
|
||||
# device-type-specific branches in the runtime, so behavior is whatever the
|
||||
# user dials in.
|
||||
DEVICE_TYPE_OTHER = "other"
|
||||
|
||||
DEVICE_TYPES = {
|
||||
DEVICE_TYPE_WASHING_MACHINE: "Washing Machine",
|
||||
DEVICE_TYPE_DRYER: "Dryer",
|
||||
DEVICE_TYPE_WASHER_DRYER: "Washer-Dryer Combo",
|
||||
DEVICE_TYPE_DISHWASHER: "Dishwasher",
|
||||
DEVICE_TYPE_COFFEE_MACHINE: "Coffee Machine",
|
||||
DEVICE_TYPE_EV: "Electric Vehicle",
|
||||
DEVICE_TYPE_AIR_FRYER: "Air Fryer",
|
||||
DEVICE_TYPE_HEAT_PUMP: "Heat Pump",
|
||||
DEVICE_TYPE_BREAD_MAKER: "Bread Maker",
|
||||
DEVICE_TYPE_PUMP: "Pump / Sump Pump",
|
||||
DEVICE_TYPE_OVEN: "Oven",
|
||||
DEVICE_TYPE_OTHER: "Other (Advanced)",
|
||||
}
|
||||
|
||||
# Device types that ship as deprecated. They fail one of WashData's three fit
|
||||
# tests (user-selected discrete program, reproducible power signature, clean
|
||||
# return to OFF) so profile matching and time-remaining estimation produce
|
||||
# noise rather than signal. Kept in DEVICE_TYPES so existing config entries
|
||||
# load unchanged; filtered out of the new-entry picker in the config flow,
|
||||
# shown with a "(deprecated)" suffix when an existing entry already uses one,
|
||||
# and surfaced via a one-shot persistent_notification on integration startup.
|
||||
# Planned hard removal: 0.4.6 (two release cycles after this deprecation).
|
||||
DEPRECATED_DEVICE_TYPES = frozenset({
|
||||
DEVICE_TYPE_COFFEE_MACHINE,
|
||||
DEVICE_TYPE_EV,
|
||||
DEVICE_TYPE_HEAT_PUMP,
|
||||
DEVICE_TYPE_OVEN,
|
||||
})
|
||||
|
||||
# Fallback device_type used at runtime once a deprecated type is hard-removed.
|
||||
# "Other (Advanced)" intentionally ships generic defaults so the integration
|
||||
# does not silently pretend an orphaned entry behaves like a washing machine.
|
||||
# Stored options are preserved as-is, so a user who had hand-tuned thresholds
|
||||
# on the old deprecated type keeps those values; the integration just stops
|
||||
# layering device-specific defaults underneath them.
|
||||
DEPRECATED_DEVICE_TYPE_FALLBACK = DEVICE_TYPE_OTHER
|
||||
|
||||
# Device Type Defaults
|
||||
# Device Type Defaults (Maps)
|
||||
|
||||
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT_BY_DEVICE = {
|
||||
DEVICE_TYPE_DISHWASHER: 14400, # 4 hours (Drying can be long)
|
||||
DEVICE_TYPE_HEAT_PUMP: 14400, # 4 hours (Heat pumps can run a long time with slow updates)
|
||||
DEVICE_TYPE_BREAD_MAKER: 7200, # 2 hours (Proving/Rising is very low-power for extended periods)
|
||||
DEVICE_TYPE_PUMP: DEFAULT_PUMP_STUCK_DURATION + 60, # Must exceed stuck-alarm threshold so the alarm fires before the watchdog
|
||||
DEVICE_TYPE_OVEN: 14400, # 4 hours (Slow roasts and pyrolytic self-clean can run for hours with thermostat-driven silence)
|
||||
}
|
||||
|
||||
DEFAULT_MAX_DEFERRAL_SECONDS = 14400 # 4 hours max safe deferral
|
||||
|
||||
# Issue #43: dishwasher end-of-cycle pump-out handling.
|
||||
#
|
||||
# A dishwasher's wash→drying drain wind-down produces brief power spikes mid
|
||||
# ENDING that, prior to the issue #43 fix, would set _end_spike_seen=True and
|
||||
# pre-arm Smart Termination — so the cycle closed at 99% of expected, BEFORE
|
||||
# the real end-of-cycle pump-out at ~99.5% of expected. The pump-out then
|
||||
# registered as a brand-new cycle.
|
||||
#
|
||||
# Two coordinated thresholds gate the fix. They MUST agree: the wait window
|
||||
# in _should_defer_finish (DISHWASHER_END_SPIKE_WAIT_SECONDS) is the upper
|
||||
# bound for keeping the cycle open without an end spike, and Smart
|
||||
# Termination's own wait branch in STATE_ENDING uses the SAME constant so the
|
||||
# two paths release the cycle at the same moment.
|
||||
#
|
||||
# A spike at < DISHWASHER_END_SPIKE_MIN_PROGRESS of expected duration is
|
||||
# ignored for end-spike tracking (the cycle still stays in ENDING via the
|
||||
# existing long_ending_tail path - this only governs the smart-termination
|
||||
# pre-arming).
|
||||
DISHWASHER_END_SPIKE_MIN_PROGRESS = 0.85
|
||||
# Widened from 300s to 1800s after issue #43 follow-up:
|
||||
# real-world user reports showed Smart Termination misfiring ~4 min before the
|
||||
# end-of-cycle pump-out, and the original 5-min escape hatch wasn't generous
|
||||
# enough to cover that gap before the next reading arrived. 30 min is plenty
|
||||
# to capture even the latest pump-outs while still guaranteeing the cycle
|
||||
# closes eventually for dishwashers that have no pump-out at all.
|
||||
DISHWASHER_END_SPIKE_WAIT_SECONDS = 1800.0
|
||||
|
||||
DEFAULT_OFF_DELAY_BY_DEVICE = {
|
||||
DEVICE_TYPE_DISHWASHER: 1800, # 30 min (Drying)
|
||||
DEVICE_TYPE_COFFEE_MACHINE: 300, # 5 min (Warming/Pause handling)
|
||||
DEVICE_TYPE_HEAT_PUMP: 600, # 10 min (Defrosting pauses)
|
||||
DEVICE_TYPE_BREAD_MAKER: 300, # 5 min (Keep-warm phase after baking)
|
||||
DEVICE_TYPE_PUMP: 20, # 20 s (Pumps cut off sharply; no warm-down phase)
|
||||
DEVICE_TYPE_OVEN: 600, # 10 min (Thermostat off-cycles can be long while holding temp)
|
||||
}
|
||||
|
||||
# Device-specific progress smoothing thresholds (percentage points)
|
||||
# These control how much backward progress is allowed before heavy damping kicks in
|
||||
DEVICE_SMOOTHING_THRESHOLDS = {
|
||||
DEVICE_TYPE_WASHING_MACHINE: 5.0, # Can have repeating phases (rinse cycles)
|
||||
DEVICE_TYPE_DRYER: 3.0, # More linear, less phase repetition
|
||||
DEVICE_TYPE_WASHER_DRYER: 5.0, # Combined washer+dryer, use washer defaults
|
||||
DEVICE_TYPE_DISHWASHER: 5.0, # Similar to washing machine with distinct phases
|
||||
DEVICE_TYPE_COFFEE_MACHINE: 2.0, # Short cycles, rapid transitions, less tolerance
|
||||
DEVICE_TYPE_AIR_FRYER: 2.0, # Constant load with sudden drop
|
||||
DEVICE_TYPE_HEAT_PUMP: 5.0, # Variable load, long periods
|
||||
DEVICE_TYPE_BREAD_MAKER: 5.0, # Large power swings between kneading, proving, baking
|
||||
DEVICE_TYPE_PUMP: 2.0, # Binary on/off spikes; minimal smoothing needed
|
||||
DEVICE_TYPE_OVEN: 5.0, # Bistable thermostat cycling between full heat and 0 W
|
||||
}
|
||||
|
||||
CONF_VERIFICATION_POLL_INTERVAL = "verification_poll_interval" # Internal setting
|
||||
DEFAULT_VERIFICATION_POLL_INTERVAL = 15 # Seconds (rapid checks after delay)
|
||||
|
||||
# Device specific completion thresholds (min run time to be considered a valid "completed" cycle)
|
||||
DEVICE_COMPLETION_THRESHOLDS = {
|
||||
DEVICE_TYPE_WASHING_MACHINE: 600, # 10 min
|
||||
DEVICE_TYPE_DRYER: 600, # 10 min
|
||||
DEVICE_TYPE_WASHER_DRYER: 600, # 10 min (same as washer)
|
||||
DEVICE_TYPE_DISHWASHER: 900, # 15 min
|
||||
DEVICE_TYPE_COFFEE_MACHINE: 60, # 1 min (Filter coffee cycle)
|
||||
DEVICE_TYPE_EV: 600, # 10 min
|
||||
DEVICE_TYPE_AIR_FRYER: 300, # 5 min minimum
|
||||
DEVICE_TYPE_HEAT_PUMP: 900, # 15 min minimum
|
||||
DEVICE_TYPE_BREAD_MAKER: 1800, # 30 min (even express bread takes 30+ min)
|
||||
DEVICE_TYPE_PUMP: 5, # 5 s - pump cycles can be under 30 seconds
|
||||
DEVICE_TYPE_OVEN: 600, # 10 min (covers quick reheats and ignores brief preheating tests)
|
||||
}
|
||||
|
||||
# Default min_off_gap by device type (seconds)
|
||||
# If gap between cycles is larger than this, force new cycle.
|
||||
# If smaller, and we deemed previous as 'ended' but technically could be same,
|
||||
# we might want to handle that (though strict state machine usually suffices if tuned well).
|
||||
# Default min_off_gap by device type (seconds)
|
||||
# Default min_off_gap by device type (seconds)
|
||||
DEFAULT_MIN_OFF_GAP_BY_DEVICE = {
|
||||
DEVICE_TYPE_WASHING_MACHINE: 480, # 8 min (Soak handling)
|
||||
DEVICE_TYPE_DRYER: 300, # 5 min (Cool down gaps?)
|
||||
DEVICE_TYPE_WASHER_DRYER: 600, # 10 min (longer for combined cycles)
|
||||
DEVICE_TYPE_DISHWASHER: 3600, # 1 hour (Drying pauses)
|
||||
DEVICE_TYPE_COFFEE_MACHINE: 120, # 2 min (Session grouping)
|
||||
DEVICE_TYPE_EV: 900, # 15 min (Brief unplug/replug)
|
||||
DEVICE_TYPE_AIR_FRYER: 120, # 2 min (Shaking food)
|
||||
DEVICE_TYPE_HEAT_PUMP: 1800, # 30 min (Defrost cycle / resting gap)
|
||||
DEVICE_TYPE_BREAD_MAKER: 600, # 10 min (Resting between knead/prove keeps same cycle together)
|
||||
DEVICE_TYPE_PUMP: 60, # 1 min (Pumps can cycle every 3-5 min in heavy rain)
|
||||
DEVICE_TYPE_OVEN: 900, # 15 min (Bridge thermostat off-windows so one bake stays a single cycle)
|
||||
}
|
||||
DEFAULT_MIN_OFF_GAP = 60 # Scalar fallback
|
||||
|
||||
# Default start energy threshold by device type (Wh)
|
||||
# Filter noise spikes (1000W * 0.01s = 0.002Wh).
|
||||
# Must be significant enough to imply mechanical work.
|
||||
DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE = {
|
||||
DEVICE_TYPE_WASHING_MACHINE: 0.2, # ~50W for 15s or 200W for 3s
|
||||
DEVICE_TYPE_DRYER: 0.5, # Heater kicks in hard
|
||||
DEVICE_TYPE_WASHER_DRYER: 0.3, # Mix of washer and dryer
|
||||
DEVICE_TYPE_DISHWASHER: 0.2, # Pump/Heater
|
||||
DEVICE_TYPE_COFFEE_MACHINE: 0.05, # Short heater burst
|
||||
DEVICE_TYPE_EV: 0.5, # High power charging
|
||||
DEVICE_TYPE_AIR_FRYER: 0.2, # Heater kicks in
|
||||
DEVICE_TYPE_HEAT_PUMP: 0.2, # Compressor spins up
|
||||
DEVICE_TYPE_BREAD_MAKER: 0.2, # Kneading motor starts (~200W for a few seconds)
|
||||
DEVICE_TYPE_PUMP: 0.003, # ~100W motor for ~0.1 s is enough to confirm a pump cycle
|
||||
DEVICE_TYPE_OVEN: 0.5, # Heating element kicks in hard (~2-3 kW) - high gate filters incidental light/fan draws
|
||||
}
|
||||
# Default sampling interval by device type
|
||||
DEFAULT_SAMPLING_INTERVAL_BY_DEVICE = {
|
||||
# 2s captures the rapid 0<->150W motor/heater oscillation in wet appliances;
|
||||
# the 30s global default discards those spikes and undersamples the cycle.
|
||||
DEVICE_TYPE_WASHING_MACHINE: 2.0,
|
||||
DEVICE_TYPE_WASHER_DRYER: 2.0,
|
||||
DEVICE_TYPE_DISHWASHER: 2.0,
|
||||
DEVICE_TYPE_COFFEE_MACHINE: 10.0, # 10s is sufficient for brew cycles
|
||||
DEVICE_TYPE_PUMP: 10.0, # 10s - pump cycles can be <30 s; 30s default would miss them
|
||||
}
|
||||
|
||||
# Default profile match min duration ratio by device type
|
||||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE = {
|
||||
DEVICE_TYPE_DISHWASHER: 0.10,
|
||||
}
|
||||
|
||||
# Storage
|
||||
STORAGE_VERSION = 5
|
||||
STORAGE_KEY = "ha_washdata"
|
||||
|
||||
# Notification events
|
||||
EVENT_CYCLE_STARTED = "ha_washdata_cycle_started"
|
||||
EVENT_CYCLE_ENDED = "ha_washdata_cycle_ended"
|
||||
|
||||
# Signals
|
||||
SIGNAL_WASHER_UPDATE = "ha_washdata_update_{}"
|
||||
|
||||
# Learning & Feedback
|
||||
|
||||
SERVICE_SUBMIT_FEEDBACK = (
|
||||
"ha_washdata.submit_cycle_feedback" # Service to submit feedback
|
||||
)
|
||||
|
||||
# Recorder
|
||||
STATE_RECORDING = "recording"
|
||||
CONF_RECORD_MODE = "record_mode"
|
||||
SERVICE_RECORD_START = "record_start"
|
||||
SERVICE_RECORD_STOP = "record_stop"
|
||||
|
||||
# Thresholds for trim suggestions
|
||||
SHORT_SILENCE_THRESHOLD_S = 600 # 10 minutes
|
||||
TRIM_BUFFER_S = 60.0 # 1 minute buffer
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
"""Diagnostic ring buffers for WashData - rolling 24-hour window.
|
||||
|
||||
Each WashDataManager owns one DiagBuffer instance that accumulates three
|
||||
independent time-series for the last 24 hours:
|
||||
|
||||
power_trace - every raw power-sensor reading, before throttling
|
||||
state_history - detector state transitions (off/starting/running/...)
|
||||
logs - DEBUG-and-above log lines emitted by this integration
|
||||
|
||||
All buffers are in-memory only (no disk writes) so they impose zero I/O
|
||||
overhead and vanish cleanly on HA restart. The 24-hour window caps memory
|
||||
at a predictable ceiling regardless of sensor polling rate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
_WINDOW = timedelta(hours=24)
|
||||
|
||||
# Hard upper-bound on entries per buffer. At a 1-second sensor interval,
|
||||
# 100 000 power entries ~= 27 hours - enough to always cover the window.
|
||||
_MAX_POWER = 100_000
|
||||
_MAX_LOGS = 5_000
|
||||
_MAX_STATES = 2_000
|
||||
|
||||
_INTEGRATION_LOGGER_NAME = "custom_components.ha_washdata"
|
||||
|
||||
|
||||
def _ts_iso(unix: float) -> str:
|
||||
return datetime.fromtimestamp(unix, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
class _LogHandler(logging.Handler):
|
||||
"""Logging handler that buffers records for a single named device.
|
||||
|
||||
Installed on the integration root logger so it receives all records
|
||||
produced anywhere inside *custom_components.ha_washdata*, then keeps
|
||||
only the ones whose formatted message contains ``[device_name]`` -
|
||||
the prefix injected by :class:`~.log_utils.DeviceLoggerAdapter`.
|
||||
"""
|
||||
|
||||
def __init__(self, device_name: str) -> None:
|
||||
super().__init__()
|
||||
# Match the exact prefix added by DeviceLoggerAdapter
|
||||
self._tag = f"[{device_name}]"
|
||||
# Store (created_float, levelname, message)
|
||||
self._buf: deque[tuple[float, str, str]] = deque(maxlen=_MAX_LOGS)
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
msg = record.getMessage()
|
||||
if self._tag not in msg:
|
||||
return
|
||||
self._buf.append((record.created, record.levelname, msg))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
self.handleError(record)
|
||||
|
||||
def snapshot(self, cutoff: float) -> list[dict[str, Any]]:
|
||||
items = list(self._buf)
|
||||
return [
|
||||
{"ts": _ts_iso(ts), "lvl": lvl, "msg": msg}
|
||||
for ts, lvl, msg in items
|
||||
if ts >= cutoff
|
||||
]
|
||||
|
||||
|
||||
class DiagBuffer:
|
||||
"""Per-device diagnostic ring buffer aggregating power, states, and logs.
|
||||
|
||||
Lifecycle::
|
||||
|
||||
# on manager creation
|
||||
self.diag_buffer = DiagBuffer(config_entry.title)
|
||||
|
||||
# on each raw power reading
|
||||
self.diag_buffer.record_power(watts, timestamp)
|
||||
|
||||
# on each detector state transition
|
||||
self.diag_buffer.record_state(old, new, program, timestamp)
|
||||
|
||||
# on manager shutdown
|
||||
self.diag_buffer.uninstall()
|
||||
|
||||
# in diagnostics.py
|
||||
snapshot = manager.diag_buffer.snapshot()
|
||||
"""
|
||||
|
||||
def __init__(self, device_name: str) -> None:
|
||||
self._device_name = device_name
|
||||
|
||||
# Raw power readings: (unix_ts_float, watts)
|
||||
self._power: deque[tuple[float, float]] = deque(maxlen=_MAX_POWER)
|
||||
|
||||
# State transitions: (unix_ts_float, from_state, to_state, program)
|
||||
self._states: deque[tuple[float, str, str, str]] = deque(maxlen=_MAX_STATES)
|
||||
|
||||
# Log handler - installed on the integration root logger
|
||||
self._log_handler = _LogHandler(device_name)
|
||||
logging.getLogger(_INTEGRATION_LOGGER_NAME).addHandler(self._log_handler)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Recording helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_power(self, watts: float, ts: datetime) -> None:
|
||||
"""Record one raw power-sensor reading (call *before* any throttling)."""
|
||||
self._power.append((ts.timestamp(), watts))
|
||||
|
||||
def record_state(
|
||||
self,
|
||||
from_state: str,
|
||||
to_state: str,
|
||||
program: str,
|
||||
ts: datetime,
|
||||
) -> None:
|
||||
"""Record a detector state transition."""
|
||||
self._states.append((ts.timestamp(), from_state, to_state, program))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Snapshot
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def redacted_snapshot(self) -> dict[str, Any]:
|
||||
"""Like :meth:`snapshot` but with identifying fields removed.
|
||||
|
||||
Strips ``device_name`` from the top-level dict and removes the ``msg``
|
||||
field from each log entry so raw log text (which contains the device
|
||||
name prefix injected by :class:`~.log_utils.DeviceLoggerAdapter`) is
|
||||
not included in exported diagnostics.
|
||||
"""
|
||||
data = self.snapshot()
|
||||
data.pop("device_name", None)
|
||||
data["logs"] = [
|
||||
{k: v for k, v in entry.items() if k != "msg"}
|
||||
for entry in data.get("logs", [])
|
||||
]
|
||||
return data
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
"""Return all three buffers filtered to the last 24 hours.
|
||||
|
||||
Returned structure::
|
||||
|
||||
{
|
||||
"window_hours": 24,
|
||||
"device_name": "...",
|
||||
"power_trace": [[iso_ts, watts], ...],
|
||||
"state_history": [{"ts": ..., "from": ..., "to": ..., "program": ...}, ...],
|
||||
"logs": [{"ts": ..., "lvl": ..., "msg": ...}, ...],
|
||||
}
|
||||
"""
|
||||
cutoff = (dt_util.now() - _WINDOW).timestamp()
|
||||
power_items = list(self._power)
|
||||
state_items = list(self._states)
|
||||
return {
|
||||
"window_hours": 24,
|
||||
"device_name": self._device_name,
|
||||
"power_trace": [
|
||||
[_ts_iso(ts), w]
|
||||
for ts, w in power_items
|
||||
if ts >= cutoff
|
||||
],
|
||||
"state_history": [
|
||||
{"ts": _ts_iso(ts), "from": f, "to": t, "program": prog}
|
||||
for ts, f, t, prog in state_items
|
||||
if ts >= cutoff
|
||||
],
|
||||
"logs": self._log_handler.snapshot(cutoff),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def uninstall(self) -> None:
|
||||
"""Remove the log handler from the integration logger.
|
||||
|
||||
Must be called from ``WashDataManager.async_shutdown()`` to avoid
|
||||
accumulating stale handlers across config-entry reloads.
|
||||
"""
|
||||
logging.getLogger(_INTEGRATION_LOGGER_NAME).removeHandler(self._log_handler)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Diagnostics support for WashData."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .manager import WashDataManager
|
||||
|
||||
# Keys that can identify the user or their home network - redacted in all contexts.
|
||||
_SENSITIVE_KEYS = {
|
||||
"auth",
|
||||
"entry_id",
|
||||
"flow_id",
|
||||
"flow_title",
|
||||
"handler",
|
||||
"name",
|
||||
"source",
|
||||
"title",
|
||||
"unique_id",
|
||||
"user_id",
|
||||
# HA entity / service references that reveal home topology.
|
||||
"notify_service",
|
||||
"notify_start_services",
|
||||
"notify_finish_services",
|
||||
"notify_live_services",
|
||||
"notify_people",
|
||||
"notify_actions",
|
||||
"power_sensor",
|
||||
"external_end_trigger",
|
||||
"door_sensor_entity",
|
||||
"switch_entity",
|
||||
"energy_price_entity",
|
||||
}
|
||||
|
||||
|
||||
def _redact(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
k: "**REDACTED**" if k in _SENSITIVE_KEYS else _redact(v)
|
||||
for k, v in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
return [_redact(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
manager: WashDataManager = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
# Full store export - same payload as the export_config service, but the
|
||||
# entry_data / entry_options pass through the redactor to strip personal keys.
|
||||
exported: dict[str, Any] = manager.profile_store.export_data(
|
||||
entry_data=dict(entry.data),
|
||||
entry_options=dict(entry.options),
|
||||
)
|
||||
|
||||
return {
|
||||
"entry": _redact(entry.as_dict()),
|
||||
"manager_state": {
|
||||
"current_state": manager.check_state(),
|
||||
"current_program": manager.current_program,
|
||||
"time_remaining": manager.time_remaining,
|
||||
"cycle_progress": manager.cycle_progress,
|
||||
"sample_interval_stats": (
|
||||
dict(manager.sample_interval_stats)
|
||||
if isinstance(manager.sample_interval_stats, dict)
|
||||
else {}
|
||||
),
|
||||
"profile_sample_repair_stats": manager.profile_sample_repair_stats,
|
||||
"suggestions": manager.profile_store.get_suggestions(),
|
||||
"feature_flags": {
|
||||
"auto_maintenance": bool(getattr(manager, "_auto_maintenance", False)),
|
||||
"save_debug_traces": bool(getattr(manager, "_save_debug_traces", False)),
|
||||
"notify_fire_events": bool(getattr(manager, "_notify_fire_events", False)),
|
||||
},
|
||||
},
|
||||
"store_export": _redact(exported),
|
||||
# Rolling 24-hour in-memory buffers (redacted: msg fields stripped from logs).
|
||||
# power_trace: [[iso_ts, watts], ...] - every raw sensor reading
|
||||
# state_history: [{ts, from, to, program}, ...] - detector state changes
|
||||
# logs: [{ts, lvl}, ...] - log timestamps and levels (msg removed)
|
||||
"live_diagnostics": manager.diag_buffer.redacted_snapshot(),
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Feature extraction logic for WashData.
|
||||
|
||||
Constraint: NumPy only.
|
||||
Constraint: All computations must be dt-aware.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class PowerEvent:
|
||||
"""Represent a detected power change event."""
|
||||
|
||||
timestamp: float
|
||||
magnitude: float # Absolute change in Watts
|
||||
rate: float # Slope W/s
|
||||
direction: str # "rising" or "falling"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CyclePhase:
|
||||
"""Represent a distinct phase within a cycle."""
|
||||
|
||||
start_ts: float
|
||||
end_ts: float
|
||||
label: str # HEATER, MOTOR, IDLE, etc.
|
||||
avg_power: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class CycleSignature:
|
||||
"""Compact signature for fast matching/rejection."""
|
||||
|
||||
duration: float
|
||||
total_energy: float
|
||||
max_power: float
|
||||
event_density: float # Events per minute
|
||||
time_to_first_high: float # Seconds to first HEATER/HIGH phase
|
||||
high_phase_ratio: float # Duration of high phases / total duration
|
||||
# Distributions (quantiles of power)
|
||||
p05: float
|
||||
p25: float
|
||||
p50: float
|
||||
p75: float
|
||||
p95: float
|
||||
|
||||
|
||||
def detect_events(
|
||||
timestamps: np.ndarray,
|
||||
power: np.ndarray,
|
||||
idle_mad: float,
|
||||
min_event_watts: float = 50.0,
|
||||
) -> list[PowerEvent]:
|
||||
"""Detect significant power events using dp/dt.
|
||||
|
||||
Args:
|
||||
timestamps: Time array (seconds).
|
||||
power: Power array (Watts).
|
||||
idle_mad: Media Absolute Deviation of idle baseline (noise floor).
|
||||
min_event_watts: Absolute floor for an event to be considered.
|
||||
"""
|
||||
if len(power) < 2:
|
||||
return []
|
||||
|
||||
dt = np.diff(timestamps)
|
||||
dp = np.diff(power)
|
||||
|
||||
# Avoid div by zero
|
||||
valid = dt > 0.1
|
||||
rate = np.zeros_like(dp)
|
||||
rate[valid] = dp[valid] / dt[valid]
|
||||
|
||||
# Adaptive threshold
|
||||
# 3-sigma equivalent: 3 * 1.4826 * MAD ~= 4.5 * MAD
|
||||
# But for dp/dt, noise scales differently.
|
||||
# Let's use absolute threshold + noise factor.
|
||||
noise_allowance = max(10.0, 5.0 * idle_mad)
|
||||
|
||||
events: list[PowerEvent] = []
|
||||
|
||||
for i, r in enumerate(rate):
|
||||
if not valid[i]:
|
||||
continue
|
||||
|
||||
mag = abs(dp[i])
|
||||
|
||||
# Criteria: Significant rate AND significant magnitude
|
||||
# We want to ignore small jitter even if rate is high (dt small)
|
||||
if mag > min_event_watts and abs(r) > noise_allowance: # Rate threshold W/s
|
||||
# Basic check: if dt is tiny (1s) and power jump is 50W, rate is 50 W/s.
|
||||
# If dt is 10s and power jump is 50W, rate is 5 W/s.
|
||||
# Real heater on: 2000W in ~2s => 1000 W/s.
|
||||
# Motor tumble: 200W in 1s => 200 W/s.
|
||||
|
||||
direction = "rising" if r > 0 else "falling"
|
||||
events.append(
|
||||
PowerEvent(
|
||||
timestamp=timestamps[i], magnitude=mag, rate=r, direction=direction
|
||||
)
|
||||
)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def segment_phases(timestamps: np.ndarray, power: np.ndarray) -> list[CyclePhase]:
|
||||
"""Segment cycle into phases using quantile-based thresholds.
|
||||
|
||||
Labels:
|
||||
- IDLE: < p10 (or min threshold)
|
||||
- MOTOR: p25 - p75 approx
|
||||
- HEATER/HIGH: > p90
|
||||
|
||||
Refined logic:
|
||||
1. Calculate cycle quantiles.
|
||||
2. Define levels: LOW, MED, HIGH.
|
||||
3. Run-length encoding or simple state machine.
|
||||
"""
|
||||
if len(power) < 10:
|
||||
return []
|
||||
|
||||
# Quantiles
|
||||
q_low = np.percentile(power, 25)
|
||||
q_high = np.percentile(power, 90)
|
||||
|
||||
# Enforce device minimums to avoid "High" label on a 5W phone charger cycle
|
||||
min_high = 500.0
|
||||
min_motor = 50.0
|
||||
|
||||
# Adjust thresholds
|
||||
thresh_high = max(q_high, min_high)
|
||||
thresh_med = max(q_low, min_motor)
|
||||
|
||||
labels: list[str] = []
|
||||
for p in power:
|
||||
if p >= thresh_high:
|
||||
labels.append("HEATER")
|
||||
elif p >= thresh_med:
|
||||
labels.append("MOTOR")
|
||||
else:
|
||||
labels.append("IDLE")
|
||||
|
||||
# Merge consecutive
|
||||
phases: list[CyclePhase] = []
|
||||
if not labels:
|
||||
return []
|
||||
|
||||
current_label: str = labels[0]
|
||||
start_idx = 0
|
||||
|
||||
for i in range(1, len(labels)):
|
||||
if labels[i] != current_label:
|
||||
# End current phase
|
||||
phases.append(
|
||||
CyclePhase(
|
||||
start_ts=timestamps[start_idx],
|
||||
end_ts=timestamps[i - 1],
|
||||
label=current_label,
|
||||
avg_power=float(np.mean(power[start_idx:i])),
|
||||
)
|
||||
)
|
||||
current_label = labels[i]
|
||||
start_idx = i
|
||||
|
||||
# Last one
|
||||
phases.append(
|
||||
CyclePhase(
|
||||
start_ts=timestamps[start_idx],
|
||||
end_ts=timestamps[-1],
|
||||
label=current_label,
|
||||
avg_power=float(np.mean(power[start_idx:])),
|
||||
)
|
||||
)
|
||||
|
||||
return phases
|
||||
|
||||
|
||||
def compute_signature(
|
||||
timestamps: np.ndarray, power: np.ndarray, events: list[PowerEvent] | None = None
|
||||
) -> CycleSignature:
|
||||
"""Compute compact signature for candidate rejection/matching.
|
||||
|
||||
Args:
|
||||
timestamps: Timestamps (seconds)
|
||||
power: Power (Watts)
|
||||
events: Pre-computed events (optional)
|
||||
"""
|
||||
if len(power) == 0:
|
||||
# Return empty/zero signature
|
||||
return CycleSignature(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
||||
|
||||
duration = timestamps[-1] - timestamps[0]
|
||||
|
||||
# Energy approx
|
||||
dt = np.diff(timestamps)
|
||||
# Simple rectangular for speed here, or integrate_wh
|
||||
if len(dt) > 0:
|
||||
p_avg = (power[:-1] + power[1:]) / 2
|
||||
total_energy = np.sum(p_avg * (dt / 3600.0))
|
||||
else:
|
||||
total_energy = 0.0
|
||||
|
||||
max_p = np.max(power)
|
||||
|
||||
# Quantiles
|
||||
qs = np.percentile(power, [5, 25, 50, 75, 95])
|
||||
|
||||
# Time to first HIGH (heater)
|
||||
# Heuristic: first time power > 800W or > 0.8 * max_p
|
||||
thresh_high = max(800.0, 0.8 * max_p)
|
||||
high_indices = np.where(power > thresh_high)[0]
|
||||
if len(high_indices) > 0:
|
||||
time_to_first_high = timestamps[high_indices[0]] - timestamps[0]
|
||||
else:
|
||||
time_to_first_high = duration # No high phase detected
|
||||
|
||||
# High Phase Ratio
|
||||
high_mask = power > thresh_high
|
||||
# Time in high / total time
|
||||
# Check dt where high_mask holds
|
||||
if len(dt) > 0:
|
||||
# Align mask with intervals
|
||||
# mask[i] corresponds to interval i? roughly
|
||||
high_dur = np.sum(dt[high_mask[:-1]])
|
||||
high_phase_ratio = high_dur / duration if duration > 0 else 0
|
||||
else:
|
||||
high_phase_ratio = 0.0
|
||||
|
||||
# Event density
|
||||
if not events:
|
||||
# Compute locally if needed, but ideally passed in
|
||||
pass
|
||||
|
||||
event_count = len(events) if events else 0
|
||||
event_density = (event_count / (duration / 60.0)) if duration > 60 else 0
|
||||
|
||||
return CycleSignature(
|
||||
duration=float(duration),
|
||||
total_energy=float(total_energy),
|
||||
max_power=float(max_p),
|
||||
event_density=float(event_density),
|
||||
time_to_first_high=float(time_to_first_high),
|
||||
high_phase_ratio=float(high_phase_ratio),
|
||||
p05=float(qs[0]),
|
||||
p25=float(qs[1]),
|
||||
p50=float(qs[2]),
|
||||
p75=float(qs[3]),
|
||||
p95=float(qs[4]),
|
||||
)
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Frontend card registration for WashData."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
from homeassistant.core import HomeAssistant, Event
|
||||
from homeassistant.const import EVENT_COMPONENT_LOADED
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
LOCAL_SUBDIR = "ha_washdata"
|
||||
CARD_NAME = "ha-washdata-card.js"
|
||||
INTEGRATION_URL = f"/{LOCAL_SUBDIR}/{CARD_NAME}"
|
||||
CARD_REGISTERED = "registered"
|
||||
CARD_DEFERRED = "deferred"
|
||||
CARD_FAILED = "failed"
|
||||
CardRegisterResult = Literal["registered", "deferred", "failed"]
|
||||
|
||||
|
||||
class LovelaceResourceItem(TypedDict, total=False):
|
||||
"""Known lovelace resource item shape used by this integration."""
|
||||
|
||||
id: str
|
||||
url: str
|
||||
res_type: str
|
||||
|
||||
|
||||
def get_cache_buster() -> str:
|
||||
"""Generate a stable cache buster based on card asset mtime."""
|
||||
try:
|
||||
src = Path(__file__).parent / "www" / CARD_NAME
|
||||
return str(int(os.path.getmtime(src)))
|
||||
except OSError:
|
||||
# Deterministic fallback when file is unavailable.
|
||||
return "1"
|
||||
|
||||
|
||||
def _register_static_path(hass: HomeAssistant, url_path: str, path: str) -> None:
|
||||
"""Register a static path with the HA HTTP component, compatible with multiple HA versions."""
|
||||
try:
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
if hasattr(hass.http, "async_register_static_paths"):
|
||||
|
||||
async def _safe_register():
|
||||
try:
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(url_path, path, True)]
|
||||
)
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
_LOGGER.debug(
|
||||
"Failed to async register static path %s -> %s: %s",
|
||||
url_path,
|
||||
path,
|
||||
exc,
|
||||
)
|
||||
|
||||
hass.async_create_task(_safe_register())
|
||||
return
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
_LOGGER.debug(
|
||||
"Async static path registration not available; falling back to "
|
||||
"sync registration for %s -> %s (%s)",
|
||||
url_path,
|
||||
path,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Fallback for older HA
|
||||
try:
|
||||
http_obj = cast(Any, hass.http)
|
||||
register_static_path = getattr(http_obj, "register_static_path", None)
|
||||
if callable(register_static_path):
|
||||
register_static_path(url_path, path, cache_headers=True)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_LOGGER.debug("Failed to register static path %s -> %s", url_path, path)
|
||||
|
||||
|
||||
async def _init_resource(hass: HomeAssistant, url: str, ver: str) -> bool:
|
||||
"""Safely add or update a Lovelace resource for the given URL."""
|
||||
try:
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.components.lovelace.resources import (
|
||||
ResourceStorageCollection,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_LOGGER.debug(
|
||||
"Lovelace resource helpers unavailable; skipping auto resource init"
|
||||
)
|
||||
return False
|
||||
|
||||
lovelace = hass.data.get("lovelace")
|
||||
if not lovelace:
|
||||
_LOGGER.debug("Lovelace storage not available; skipping auto resource init")
|
||||
return False
|
||||
|
||||
resources = (
|
||||
lovelace.resources if hasattr(lovelace, "resources") else lovelace["resources"]
|
||||
)
|
||||
|
||||
url2 = f"{url}?v={ver}"
|
||||
|
||||
if not isinstance(resources, ResourceStorageCollection):
|
||||
_LOGGER.debug("Add extra JS module (non-storage): %s", url2)
|
||||
add_extra_js_url(hass, url2)
|
||||
return True
|
||||
|
||||
resources_obj = resources
|
||||
await resources_obj.async_get_info()
|
||||
|
||||
for raw_item in resources_obj.async_items():
|
||||
if not isinstance(raw_item, dict):
|
||||
continue
|
||||
|
||||
item = cast(LovelaceResourceItem, raw_item)
|
||||
item_url = item.get("url")
|
||||
if not isinstance(item_url, str) or not item_url.startswith(url):
|
||||
continue
|
||||
|
||||
if item_url == url2 and item.get("res_type") == "module":
|
||||
return True
|
||||
|
||||
item_id = item.get("id")
|
||||
if not isinstance(item_id, str):
|
||||
continue
|
||||
|
||||
_LOGGER.debug("Update lovelace resource to: %s", url2)
|
||||
await resources_obj.async_update_item(
|
||||
item_id, {"res_type": "module", "url": url2}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
_LOGGER.debug("Add new lovelace resource: %s", url2)
|
||||
await resources_obj.async_create_item({"res_type": "module", "url": url2})
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class WashDataCardRegistration:
|
||||
"""Serve ha-washdata-card.js from the integration package."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
self.hass = hass
|
||||
|
||||
def _src_path(self) -> Path:
|
||||
return Path(__file__).parent / "www" / CARD_NAME
|
||||
|
||||
async def async_register(self) -> CardRegisterResult:
|
||||
"""Register card assets/resources and report registration outcome."""
|
||||
src = self._src_path()
|
||||
if not src.exists():
|
||||
_LOGGER.warning("Card file not found: %s", src)
|
||||
return CARD_FAILED
|
||||
|
||||
_register_static_path(self.hass, INTEGRATION_URL, str(src))
|
||||
|
||||
version = get_cache_buster()
|
||||
|
||||
# Try auto-registration of the lovelace resource
|
||||
# If lovelace is not yet loaded, wait for it
|
||||
if not self.hass.data.get("lovelace"):
|
||||
_LOGGER.debug("Lovelace not loaded yet; waiting for component loaded event")
|
||||
|
||||
unsubscribe_on_lovelace_loaded: Any = None
|
||||
|
||||
async def _on_lovelace_loaded(event: Event) -> None:
|
||||
if event.data.get("component") == "lovelace":
|
||||
_LOGGER.debug(
|
||||
"Lovelace component loaded; retrying resource registration"
|
||||
)
|
||||
if unsubscribe_on_lovelace_loaded:
|
||||
unsubscribe_on_lovelace_loaded()
|
||||
try:
|
||||
if await _init_resource(self.hass, INTEGRATION_URL, version):
|
||||
self.hass.data["ha_washdata_card_registered"] = True
|
||||
self.hass.data["ha_washdata_card_deferred"] = False
|
||||
else:
|
||||
self.hass.data["ha_washdata_card_deferred"] = False
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
self.hass.data["ha_washdata_card_deferred"] = False
|
||||
_LOGGER.debug(
|
||||
"Delayed auto-registration of lovelace resource failed for %s",
|
||||
INTEGRATION_URL,
|
||||
)
|
||||
|
||||
unsubscribe_on_lovelace_loaded = self.hass.bus.async_listen(EVENT_COMPONENT_LOADED, _on_lovelace_loaded)
|
||||
|
||||
# Re-check in case lovelace loaded between the initial check and listener registration.
|
||||
if self.hass.data.get("lovelace"):
|
||||
unsubscribe_on_lovelace_loaded()
|
||||
_LOGGER.debug("Lovelace already loaded after deferred listener; registering now")
|
||||
try:
|
||||
if await _init_resource(self.hass, INTEGRATION_URL, version):
|
||||
self.hass.data["ha_washdata_card_registered"] = True
|
||||
self.hass.data["ha_washdata_card_deferred"] = False
|
||||
return CARD_REGISTERED
|
||||
self.hass.data["ha_washdata_card_deferred"] = False
|
||||
return CARD_FAILED
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
self.hass.data["ha_washdata_card_deferred"] = False
|
||||
return CARD_FAILED
|
||||
|
||||
return CARD_DEFERRED
|
||||
|
||||
# Lovelace is already loaded
|
||||
try:
|
||||
registered = await _init_resource(self.hass, INTEGRATION_URL, version)
|
||||
except Exception as err: # pylint: disable=broad-exception-caught
|
||||
_LOGGER.debug(
|
||||
"Auto-registration of lovelace resource failed for %s: %s",
|
||||
INTEGRATION_URL,
|
||||
err,
|
||||
)
|
||||
return CARD_FAILED
|
||||
|
||||
if registered:
|
||||
_LOGGER.debug("Auto-registered lovelace resource for %s", INTEGRATION_URL)
|
||||
return CARD_REGISTERED
|
||||
return CARD_FAILED
|
||||
@@ -0,0 +1,791 @@
|
||||
"""Learning and self-tuning logic for WashData."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional, TYPE_CHECKING, cast
|
||||
|
||||
import numpy as np
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import translation
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_AUTO_LABEL_CONFIDENCE,
|
||||
CONF_DURATION_TOLERANCE,
|
||||
CONF_END_ENERGY_THRESHOLD,
|
||||
CONF_LEARNING_CONFIDENCE,
|
||||
CONF_MIN_OFF_GAP,
|
||||
CONF_MIN_POWER,
|
||||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||||
CONF_OFF_DELAY,
|
||||
CONF_PROFILE_DURATION_TOLERANCE,
|
||||
CONF_PROFILE_MATCH_INTERVAL,
|
||||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
CONF_RUNNING_DEAD_ZONE,
|
||||
CONF_SAMPLING_INTERVAL,
|
||||
CONF_START_THRESHOLD_W,
|
||||
CONF_STOP_THRESHOLD_W,
|
||||
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
||||
CONF_WATCHDOG_INTERVAL,
|
||||
DEFAULT_AUTO_LABEL_CONFIDENCE,
|
||||
DEFAULT_DURATION_TOLERANCE,
|
||||
DEFAULT_LEARNING_CONFIDENCE,
|
||||
DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
||||
DOMAIN,
|
||||
SIGNAL_WASHER_UPDATE,
|
||||
)
|
||||
from .suggestion_engine import SuggestionEngine
|
||||
from .log_utils import DeviceLoggerAdapter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .profile_store import ProfileStore
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StatisticalModel:
|
||||
"""Helper to track running stats for a metric."""
|
||||
|
||||
def __init__(self, max_samples: int = 200) -> None:
|
||||
self._samples: list[float] = []
|
||||
self._max_samples = max_samples
|
||||
self._last_update: datetime | None = None
|
||||
self._stats: dict[str, Any] = {"median": None, "p95": None, "count": 0}
|
||||
|
||||
def add_sample(self, value: float, now: datetime) -> None:
|
||||
"""Add a sample and update stats."""
|
||||
self._samples.append(value)
|
||||
if len(self._samples) > self._max_samples:
|
||||
self._samples = self._samples[-self._max_samples:]
|
||||
self._last_update = now
|
||||
self._compute_stats()
|
||||
|
||||
def _compute_stats(self) -> None:
|
||||
if not self._samples:
|
||||
self._stats = {"median": None, "p95": None, "count": 0}
|
||||
return
|
||||
|
||||
arr = np.array(self._samples)
|
||||
self._stats = {
|
||||
"median": float(np.median(arr)),
|
||||
"p95": float(np.percentile(arr, 95)),
|
||||
"count": int(len(self._samples)),
|
||||
}
|
||||
|
||||
@property
|
||||
def median(self) -> float | None:
|
||||
"""Return the median of samples."""
|
||||
return self._stats.get("median")
|
||||
|
||||
@property
|
||||
def p95(self) -> float | None:
|
||||
"""Return the 95th percentile of samples."""
|
||||
return self._stats.get("p95")
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
"""Return the number of samples."""
|
||||
return self._stats.get("count", 0)
|
||||
|
||||
|
||||
class LearningManager:
|
||||
"""Manages cycle learning, user feedback, and auto-tuning."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry_id: str,
|
||||
profile_store: "ProfileStore",
|
||||
device_type: str | None = None,
|
||||
device_name: str = "",
|
||||
) -> None:
|
||||
"""Initialize the learning manager."""
|
||||
self._logger = DeviceLoggerAdapter(_LOGGER, device_name)
|
||||
self.hass = hass
|
||||
self.entry_id = entry_id
|
||||
self.profile_store = profile_store
|
||||
self.device_type = device_type
|
||||
self.suggestion_engine = SuggestionEngine(
|
||||
hass, entry_id, profile_store, device_type
|
||||
)
|
||||
|
||||
# Operational Stats
|
||||
self._sample_interval_model = StatisticalModel(max_samples=200)
|
||||
self._last_suggestion_update: datetime | None = None
|
||||
self._last_batch_simulation_count: int = 0 # track when to re-run batch
|
||||
|
||||
def _apply_suggestions_and_notify(self, suggestions: dict[str, Any]) -> None:
|
||||
"""Apply suggestions and notify once when they become actionable."""
|
||||
if not suggestions:
|
||||
return
|
||||
|
||||
_actionable_keys = (
|
||||
CONF_MIN_POWER,
|
||||
CONF_OFF_DELAY,
|
||||
CONF_WATCHDOG_INTERVAL,
|
||||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||||
CONF_SAMPLING_INTERVAL,
|
||||
CONF_PROFILE_MATCH_INTERVAL,
|
||||
CONF_AUTO_LABEL_CONFIDENCE,
|
||||
CONF_DURATION_TOLERANCE,
|
||||
CONF_PROFILE_DURATION_TOLERANCE,
|
||||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
CONF_MIN_OFF_GAP,
|
||||
CONF_STOP_THRESHOLD_W,
|
||||
CONF_START_THRESHOLD_W,
|
||||
CONF_END_ENERGY_THRESHOLD,
|
||||
CONF_RUNNING_DEAD_ZONE,
|
||||
)
|
||||
|
||||
# Drop suggestions whose value already matches the current config - so
|
||||
# that applied suggestions don't immediately reappear on the next cycle.
|
||||
entry = self.hass.config_entries.async_get_entry(self.entry_id)
|
||||
current_options: dict[str, Any] = {}
|
||||
if entry:
|
||||
current_options = {**entry.data, **entry.options}
|
||||
|
||||
filtered_suggestions: dict[str, Any] = {}
|
||||
for key, data in suggestions.items():
|
||||
if isinstance(data, dict) and "value" in data:
|
||||
current_val = current_options.get(key)
|
||||
suggested_val = data["value"]
|
||||
if current_val is not None and suggested_val is not None:
|
||||
try:
|
||||
if float(current_val) == float(suggested_val):
|
||||
self.profile_store.delete_suggestion(key)
|
||||
continue # already applied, remove stale entry
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
filtered_suggestions[key] = data
|
||||
|
||||
if not filtered_suggestions:
|
||||
return
|
||||
|
||||
def _count_actionable(s: dict) -> int:
|
||||
return sum(
|
||||
1 for k in _actionable_keys
|
||||
if isinstance(s.get(k), dict) and s[k].get("value") is not None
|
||||
)
|
||||
|
||||
current = self.profile_store.get_suggestions()
|
||||
before_count = _count_actionable(current) if isinstance(current, dict) else 0
|
||||
|
||||
self.suggestion_engine.apply_suggestions(filtered_suggestions)
|
||||
|
||||
updated = self.profile_store.get_suggestions()
|
||||
after_count = _count_actionable(updated) if isinstance(updated, dict) else 0
|
||||
|
||||
if before_count == 0 and after_count > 0:
|
||||
device_title = entry.title if entry else DOMAIN
|
||||
self.hass.async_create_task(
|
||||
self._async_send_suggestions_ready_notification(device_title, after_count)
|
||||
)
|
||||
|
||||
def process_power_reading(
|
||||
self, _power: float, now: datetime, last_reading_time: datetime | None
|
||||
) -> None:
|
||||
"""Ingest power reading metadata for statistical analysis."""
|
||||
if last_reading_time:
|
||||
delta = (now - last_reading_time).total_seconds()
|
||||
# Ignore ultra-small jitter (<0.1s) and massive gaps (>1800s - likely downtime)
|
||||
if 0.1 < delta < 1800:
|
||||
self._sample_interval_model.add_sample(delta, now)
|
||||
|
||||
# Periodically update suggestions based on operational stats
|
||||
if (
|
||||
self._last_suggestion_update is None
|
||||
or (now - self._last_suggestion_update).total_seconds() > 300 # Check every 5 mins
|
||||
):
|
||||
self._update_operational_suggestions(now)
|
||||
|
||||
def process_cycle_end(
|
||||
self,
|
||||
cycle_data: dict[str, Any],
|
||||
detected_profile: str | None = None,
|
||||
confidence: float = 0.0,
|
||||
predicted_duration: float | None = None,
|
||||
match_result: Any | None = None,
|
||||
) -> None:
|
||||
"""Analyze completed cycle for learning.
|
||||
|
||||
Args:
|
||||
cycle_data: Completed cycle data
|
||||
detected_profile: Profile name detected
|
||||
confidence: Match confidence score (0.0-1.0)
|
||||
predicted_duration: Expected duration in seconds
|
||||
match_result: MatchResult from profile_store.async_match_profile() (optional)
|
||||
"""
|
||||
# 1. Trigger background simulation to find optimal parameters for this cycle
|
||||
if cycle_data.get("power_data"):
|
||||
# Offload to executor since simulation can be heavy
|
||||
self.hass.async_create_task(self._async_run_simulation(cycle_data))
|
||||
|
||||
# 2. Check if we should request feedback
|
||||
self._maybe_request_feedback(
|
||||
cycle_data, detected_profile, confidence, predicted_duration, match_result
|
||||
)
|
||||
|
||||
# 3. Update model-based suggestions (durations etc)
|
||||
self._update_model_suggestions(dt_util.now())
|
||||
|
||||
# 4. Run multi-cycle batch simulation when enough new labeled cycles have accumulated
|
||||
self._maybe_run_batch_simulation()
|
||||
|
||||
def _maybe_run_batch_simulation(self) -> None:
|
||||
"""Schedule a batch simulation when enough new labeled cycles have arrived."""
|
||||
_BATCH_MIN = 5
|
||||
_BATCH_RERUN_DELTA = 5 # Re-run every 5 new labeled cycles
|
||||
|
||||
labeled_cycles = [
|
||||
c for c in self.profile_store.get_past_cycles()
|
||||
if isinstance(c, dict)
|
||||
and c.get("profile_name")
|
||||
and c.get("profile_name") != "noise"
|
||||
and c.get("power_data")
|
||||
and c.get("status") in ("completed", "force_stopped")
|
||||
]
|
||||
current_count = len(labeled_cycles)
|
||||
|
||||
if current_count < _BATCH_MIN:
|
||||
return
|
||||
if (current_count - self._last_batch_simulation_count) < _BATCH_RERUN_DELTA:
|
||||
return
|
||||
|
||||
self._last_batch_simulation_count = current_count
|
||||
self.hass.async_create_task(self._async_run_batch_simulation(labeled_cycles, current_count))
|
||||
|
||||
async def _async_run_batch_simulation(self, cycles: list[dict[str, Any]], expected_count: int) -> None:
|
||||
"""Run multi-cycle batch simulation asynchronously."""
|
||||
try:
|
||||
new_suggestions = await self.hass.async_add_executor_job(
|
||||
self.suggestion_engine.run_batch_simulation, cycles
|
||||
)
|
||||
if new_suggestions:
|
||||
self._apply_suggestions_and_notify(new_suggestions)
|
||||
self._logger.debug(
|
||||
"Batch simulation (%d cycles) produced suggestions: %s",
|
||||
len(cycles),
|
||||
list(new_suggestions.keys()),
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
self._logger.error("Batch simulation failed: %s", e)
|
||||
|
||||
async def _async_run_simulation(self, cycle_data: dict[str, Any]) -> None:
|
||||
"""Run simulation asynchronously."""
|
||||
try:
|
||||
# Simulation runner derives optimal thresholds
|
||||
# Offload to executor since simulation can be heavy (CPU bound)
|
||||
new_suggestions = await self.hass.async_add_executor_job(
|
||||
self.suggestion_engine.run_simulation, cycle_data
|
||||
)
|
||||
if new_suggestions:
|
||||
self._apply_suggestions_and_notify(new_suggestions)
|
||||
self._logger.debug("Post-cycle simulation completed with suggestions: %s", new_suggestions.keys())
|
||||
except Exception as e:
|
||||
self._logger.error("Background simulation failed: %s", e)
|
||||
|
||||
def _update_operational_suggestions(self, now: datetime) -> None:
|
||||
"""Generate suggestions for operational parameters (intervals, timeouts)."""
|
||||
if self._sample_interval_model.count < 20:
|
||||
return
|
||||
|
||||
p95 = self._sample_interval_model.p95
|
||||
median = self._sample_interval_model.median
|
||||
|
||||
if p95 is None or median is None:
|
||||
return
|
||||
|
||||
suggestions = self.suggestion_engine.generate_operational_suggestions(p95, median)
|
||||
self._apply_suggestions_and_notify(suggestions)
|
||||
self._last_suggestion_update = now
|
||||
|
||||
def _update_model_suggestions(self, now: datetime) -> None:
|
||||
"""Generate suggestions for model parameters (tolerances, ratios)."""
|
||||
suggestions = self.suggestion_engine.generate_model_suggestions()
|
||||
self._apply_suggestions_and_notify(suggestions)
|
||||
|
||||
async def _async_send_suggestions_ready_notification(
|
||||
self, device_title: str, suggestions_count: int
|
||||
) -> None:
|
||||
"""Send a one-time persistent notification when suggestions become available."""
|
||||
try:
|
||||
notification_id = f"ha_washdata_suggestions_ready_{self.entry_id}"
|
||||
|
||||
translations = await translation.async_get_translations(
|
||||
self.hass, self.hass.config.language, "options", {DOMAIN}
|
||||
)
|
||||
|
||||
default_title = "WashData: Suggested Settings Ready ({device})"
|
||||
default_msg = (
|
||||
"The **Suggested Settings** sensor now reports **{count}** actionable recommendations.\n\n"
|
||||
"To review and apply them: **Settings > Devices & Services > WashData > Configure > "
|
||||
"Advanced Settings > Apply Suggested Values**.\n\n"
|
||||
"Suggestions are optional and shown for review before you save."
|
||||
)
|
||||
|
||||
title_template = translations.get(
|
||||
f"component.{DOMAIN}.options.error.suggestions_ready_notification_title",
|
||||
default_title,
|
||||
)
|
||||
msg_template = translations.get(
|
||||
f"component.{DOMAIN}.options.error.suggestions_ready_notification_message",
|
||||
default_msg,
|
||||
)
|
||||
|
||||
title = title_template.format(device=device_title)
|
||||
message = msg_template.format(count=suggestions_count)
|
||||
|
||||
await self.hass.services.async_call(
|
||||
"persistent_notification",
|
||||
"create",
|
||||
{
|
||||
"message": message,
|
||||
"title": title,
|
||||
"notification_id": notification_id,
|
||||
},
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
self._logger.exception("Failed to create suggestions-ready notification")
|
||||
|
||||
def _set_suggestion(self, key: str, value: Any, reason: str) -> None:
|
||||
"""Persist a suggested setting."""
|
||||
current: Any = self.profile_store.get_suggestions().get(key, {})
|
||||
if isinstance(current, dict):
|
||||
current_dict = cast(dict[str, Any], current)
|
||||
if current_dict.get("value") == value:
|
||||
return # No change
|
||||
|
||||
self.profile_store.set_suggestion(key, value, reason=reason)
|
||||
# We fire a background save task if possible, or rely on next periodic save.
|
||||
# Since learning manager doesn't hold reference to hass task creation easily,
|
||||
# we can just rely on ProfileStore's periodic save or trigger one if referenced.
|
||||
# Ideally ProfileStore handles dirtiness.
|
||||
# But wait, Manager calls save periodically. We should just mark it dirty?
|
||||
# ProfileStore.async_save() is needed.
|
||||
# We'll just trigger it via hass if available.
|
||||
if self.hass:
|
||||
self.hass.async_create_task(self.profile_store.async_save())
|
||||
|
||||
def _maybe_request_feedback(
|
||||
self,
|
||||
cycle_data: dict[str, Any],
|
||||
detected_profile: str | None,
|
||||
confidence: float,
|
||||
predicted_duration: float | None,
|
||||
match_result: Any | None = None,
|
||||
) -> None:
|
||||
"""Check if feedback should be requested for this completed cycle."""
|
||||
if (
|
||||
not predicted_duration
|
||||
or not detected_profile
|
||||
or detected_profile in ("off", "detecting...")
|
||||
):
|
||||
# No match was made, don't request feedback
|
||||
return
|
||||
|
||||
# Get the cycle ID from the cycle_data
|
||||
cycle_id = cycle_data.get("id")
|
||||
if not cycle_id:
|
||||
self._logger.warning("Cycle data missing ID, cannot request feedback")
|
||||
return
|
||||
|
||||
# Get Configured Thresholds
|
||||
entry = self.hass.config_entries.async_get_entry(self.entry_id)
|
||||
if not entry:
|
||||
return
|
||||
|
||||
auto_label_conf = entry.options.get(
|
||||
CONF_AUTO_LABEL_CONFIDENCE, DEFAULT_AUTO_LABEL_CONFIDENCE
|
||||
)
|
||||
learning_conf = entry.options.get(
|
||||
CONF_LEARNING_CONFIDENCE, DEFAULT_LEARNING_CONFIDENCE
|
||||
)
|
||||
duration_tol = entry.options.get(
|
||||
CONF_DURATION_TOLERANCE, DEFAULT_DURATION_TOLERANCE
|
||||
)
|
||||
|
||||
# Auto-label if very high confidence
|
||||
if confidence >= auto_label_conf:
|
||||
labeled = self.auto_label_high_confidence(
|
||||
cycle_id=cycle_id,
|
||||
profile_name=detected_profile,
|
||||
confidence=confidence,
|
||||
confidence_threshold=auto_label_conf,
|
||||
)
|
||||
if labeled:
|
||||
# Rebuild envelope first, then persist (issue #131)
|
||||
self.hass.async_create_task(
|
||||
self._async_rebuild_and_save_profile(detected_profile)
|
||||
)
|
||||
self._logger.debug("Auto-labeled high-confidence cycle %s", cycle_id)
|
||||
return
|
||||
|
||||
# Skip low-confidence matches below learning threshold
|
||||
if confidence < learning_conf:
|
||||
self._logger.debug(
|
||||
"Skipping feedback for low-confidence match (conf=%.2f < %.2f)",
|
||||
confidence,
|
||||
learning_conf,
|
||||
)
|
||||
return
|
||||
|
||||
actual_duration = cycle_data.get("duration", 0)
|
||||
|
||||
# Request feedback via learning manager for moderate confidence
|
||||
self.request_cycle_verification(
|
||||
cycle_id=cycle_id,
|
||||
detected_profile=detected_profile,
|
||||
confidence=confidence,
|
||||
estimated_duration=predicted_duration,
|
||||
actual_duration=actual_duration,
|
||||
duration_tolerance=duration_tol,
|
||||
match_result=match_result,
|
||||
)
|
||||
|
||||
# Persist pending feedback request so it survives restart
|
||||
self.hass.async_create_task(self.profile_store.async_save())
|
||||
|
||||
# Create user-visible notification (skipped when suppressed via option).
|
||||
# Use `is True` so that un-configured mock objects in tests don't
|
||||
# accidentally suppress notifications by being truthy.
|
||||
suppress = entry.options.get(
|
||||
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
||||
DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
||||
) is True
|
||||
if not suppress:
|
||||
self.hass.async_create_task(
|
||||
self._async_send_feedback_notification(
|
||||
entry.title, cycle_data, detected_profile, confidence
|
||||
)
|
||||
)
|
||||
|
||||
async def _async_send_feedback_notification(
|
||||
self, device_title: str, cycle_data: dict[str, Any], profile: str, confidence: float
|
||||
) -> None:
|
||||
"""Send a persistent notification for feedback (Async with translation)."""
|
||||
try:
|
||||
cycle_id = cycle_data.get("id", "unknown")
|
||||
start_ts = cycle_data.get("start_time")
|
||||
end_ts = dt_util.now() # Approximate, or pass actual end time
|
||||
|
||||
# Format times
|
||||
t_str = ""
|
||||
if start_ts:
|
||||
try:
|
||||
s_dt = datetime.fromisoformat(str(start_ts)) if isinstance(start_ts, str) else start_ts
|
||||
s_local = dt_util.as_local(s_dt)
|
||||
e_local = dt_util.as_local(end_ts)
|
||||
t_str = f"{s_local.strftime('%H:%M')} - {e_local.strftime('%H:%M')}"
|
||||
except Exception:
|
||||
t_str = "Just now"
|
||||
|
||||
notification_id = f"ha_washdata_feedback_{self.entry_id}_{cycle_id}"
|
||||
|
||||
# Load translations (from en.json / localization files)
|
||||
# We use "options" category to access the error keys where we stored these strings
|
||||
translations = await translation.async_get_translations(
|
||||
self.hass, self.hass.config.language, "options", {DOMAIN}
|
||||
)
|
||||
|
||||
# Default templates
|
||||
default_title = "WashData: Verify Cycle ({device})"
|
||||
default_msg = (
|
||||
"**Device**: {device}\n"
|
||||
"**Program**: {program} ({confidence}% confidence)\n"
|
||||
"**Time**: {time}\n\n"
|
||||
"WashData needs your help to verify this detected cycle.\n\n"
|
||||
"Please go to **Settings > Devices & Services > WashData > Configure > Learning Feedbacks** to confirm or correct this result."
|
||||
)
|
||||
|
||||
title_template = translations.get(
|
||||
f"component.{DOMAIN}.options.error.feedback_notification_title", default_title
|
||||
)
|
||||
msg_template = translations.get(
|
||||
f"component.{DOMAIN}.options.error.feedback_notification_message", default_msg
|
||||
)
|
||||
|
||||
# Confidence as percentage
|
||||
conf_pct = int(confidence * 100)
|
||||
|
||||
title = title_template.format(device=device_title)
|
||||
message = msg_template.format(
|
||||
device=device_title,
|
||||
program=profile,
|
||||
confidence=conf_pct,
|
||||
time=t_str
|
||||
)
|
||||
|
||||
# Use standard service call
|
||||
await self.hass.services.async_call(
|
||||
"persistent_notification",
|
||||
"create",
|
||||
{
|
||||
"message": message,
|
||||
"title": title,
|
||||
"notification_id": notification_id,
|
||||
},
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
self._logger.exception("Failed to create feedback notification")
|
||||
|
||||
def _send_feedback_notification(
|
||||
self, device_title: str, cycle_data: dict[str, Any], profile: str, confidence: float
|
||||
) -> None:
|
||||
"""Deprecated sync wrapper."""
|
||||
self.hass.async_create_task(
|
||||
self._async_send_feedback_notification(
|
||||
device_title, cycle_data, profile, confidence
|
||||
)
|
||||
)
|
||||
|
||||
def request_cycle_verification(
|
||||
self,
|
||||
cycle_id: str,
|
||||
detected_profile: Optional[str],
|
||||
confidence: float,
|
||||
estimated_duration: Optional[float],
|
||||
actual_duration: float,
|
||||
duration_tolerance: float = 0.10,
|
||||
match_result: Any | None = None,
|
||||
) -> None:
|
||||
"""Request user verification for a detected cycle."""
|
||||
duration_match_pct = (
|
||||
(actual_duration / estimated_duration * 100) if estimated_duration else 0
|
||||
)
|
||||
tolerance_pct = duration_tolerance * 100
|
||||
is_close_match = (
|
||||
estimated_duration and abs(duration_match_pct - 100) <= tolerance_pct
|
||||
)
|
||||
|
||||
# Extract match ranking from MatchResult if available (for UI visualization)
|
||||
ranking_summary: list[dict[str, Any]] = []
|
||||
if match_result and hasattr(match_result, "ranking") and match_result.ranking:
|
||||
for cand in match_result.ranking[:5]: # Store top 5
|
||||
try:
|
||||
ranking_summary.append({
|
||||
"name": cand.get("name", "Unknown"),
|
||||
"score": float(cand.get("score", 0.0)),
|
||||
"metrics": cand.get("metrics", {}),
|
||||
"profile_duration": float(cand.get("profile_duration", 0.0)),
|
||||
})
|
||||
except (TypeError, ValueError, KeyError, AttributeError):
|
||||
continue
|
||||
|
||||
feedback_req: dict[str, Any] = {
|
||||
"cycle_id": cycle_id,
|
||||
"detected_profile": detected_profile,
|
||||
"confidence": confidence,
|
||||
"estimated_duration": estimated_duration,
|
||||
"actual_duration": actual_duration,
|
||||
"duration_match_pct": duration_match_pct,
|
||||
"is_close_match": is_close_match,
|
||||
"created_at": dt_util.now().isoformat(),
|
||||
"user_response": None,
|
||||
"expires_at": None,
|
||||
"ranking": ranking_summary, # Top candidates for UI display
|
||||
}
|
||||
|
||||
self.profile_store.add_pending_feedback(cycle_id, feedback_req)
|
||||
|
||||
est_min = int(estimated_duration / 60) if estimated_duration else 0
|
||||
self._logger.info(
|
||||
"Feedback requested for cycle %s: profile='%s' (conf=%.2f), "
|
||||
"est=%smin, actual=%smin (%.0f%%)",
|
||||
cycle_id,
|
||||
detected_profile,
|
||||
confidence,
|
||||
est_min,
|
||||
int(actual_duration / 60),
|
||||
duration_match_pct,
|
||||
)
|
||||
|
||||
def auto_label_high_confidence(
|
||||
self,
|
||||
cycle_id: str,
|
||||
profile_name: str,
|
||||
confidence: float,
|
||||
confidence_threshold: float,
|
||||
) -> bool:
|
||||
"""Auto-label a cycle with high confidence."""
|
||||
if confidence < confidence_threshold:
|
||||
return False
|
||||
|
||||
# Reuse existing internal logic
|
||||
self._auto_label_cycle(cycle_id, profile_name)
|
||||
|
||||
# Verify it was labeled (cycle found)
|
||||
cycles = self.profile_store.get_past_cycles()
|
||||
cycle = next((c for c in cycles if c["id"] == cycle_id), None)
|
||||
|
||||
return bool(cycle and cycle.get("auto_labeled"))
|
||||
|
||||
async def async_submit_cycle_feedback(
|
||||
self,
|
||||
cycle_id: str,
|
||||
user_confirmed: bool,
|
||||
corrected_profile: Optional[str] = None,
|
||||
corrected_duration: Optional[float] = None,
|
||||
notes: str = "",
|
||||
dismiss: bool = False,
|
||||
) -> bool:
|
||||
"""Submit user feedback for a cycle."""
|
||||
pending = self.profile_store.get_pending_feedback().get(cycle_id)
|
||||
if not pending:
|
||||
return False
|
||||
|
||||
# Parse corrected_duration before writing to history so a bad value
|
||||
# never leaves a partially-applied state.
|
||||
duration_sec: float | None = None
|
||||
if corrected_duration is not None:
|
||||
try:
|
||||
duration_sec = float(corrected_duration)
|
||||
except (TypeError, ValueError):
|
||||
self._logger.warning(
|
||||
"Invalid corrected_duration %r for cycle %s, ignoring",
|
||||
corrected_duration,
|
||||
cycle_id,
|
||||
)
|
||||
|
||||
feedback_record: dict[str, Any] = {
|
||||
"cycle_id": cycle_id,
|
||||
"original_detected_profile": pending["detected_profile"],
|
||||
"original_confidence": pending["confidence"],
|
||||
"user_confirmed": user_confirmed,
|
||||
"corrected_profile": corrected_profile,
|
||||
"corrected_duration": duration_sec,
|
||||
"notes": notes,
|
||||
"submitted_at": dt_util.now().isoformat(),
|
||||
}
|
||||
|
||||
self.profile_store.get_feedback_history()[cycle_id] = feedback_record
|
||||
|
||||
# Track which profiles need envelope rebuild (issue #131)
|
||||
profiles_to_rebuild: set[str] = set()
|
||||
|
||||
if dismiss:
|
||||
# Just dismiss, no action
|
||||
pass
|
||||
elif user_confirmed:
|
||||
profile_name = pending.get("detected_profile")
|
||||
if isinstance(profile_name, str) and profile_name:
|
||||
self._auto_label_cycle(cycle_id, profile_name, duration_sec)
|
||||
if duration_sec is not None:
|
||||
cycles = self.profile_store.get_past_cycles()
|
||||
confirmed_cycle = next((c for c in cycles if c["id"] == cycle_id), None)
|
||||
if confirmed_cycle:
|
||||
confirmed_cycle["duration"] = duration_sec
|
||||
profiles_to_rebuild.add(profile_name)
|
||||
else:
|
||||
# Correction path: only use corrected_profile when user_confirmed is False.
|
||||
# Duration-only corrections (no profile specified) are handled by the elif branch below.
|
||||
target_profile = corrected_profile
|
||||
detected_profile_name = pending.get("detected_profile")
|
||||
|
||||
if isinstance(target_profile, str) and target_profile:
|
||||
self._apply_correction_learning(
|
||||
cycle_id, target_profile, duration_sec
|
||||
)
|
||||
profiles_to_rebuild.add(target_profile)
|
||||
if (
|
||||
isinstance(detected_profile_name, str)
|
||||
and detected_profile_name
|
||||
and detected_profile_name != target_profile
|
||||
):
|
||||
profiles_to_rebuild.add(detected_profile_name)
|
||||
elif duration_sec is not None:
|
||||
# No valid profile could be determined, but a duration correction was
|
||||
# explicitly provided - apply it directly to the cycle so the value
|
||||
# is never silently dropped.
|
||||
cycles = self.profile_store.get_past_cycles()
|
||||
cycle_to_fix = next((c for c in cycles if c["id"] == cycle_id), None)
|
||||
if cycle_to_fix:
|
||||
cycle_to_fix["duration"] = duration_sec
|
||||
cycle_to_fix["manual_duration"] = duration_sec
|
||||
existing_profile = cycle_to_fix.get("profile_name")
|
||||
if isinstance(existing_profile, str) and existing_profile:
|
||||
profiles_to_rebuild.add(existing_profile)
|
||||
else:
|
||||
self._logger.warning(
|
||||
"Duration correction skipped: cycle %s not found in past_cycles",
|
||||
cycle_id,
|
||||
)
|
||||
|
||||
# Remove from pending (add_pending_feedback was wrapper, remove is direct)
|
||||
if cycle_id in self.profile_store.get_pending_feedback():
|
||||
del self.profile_store.get_pending_feedback()[cycle_id]
|
||||
|
||||
# Rebuild envelopes for all modified profiles to recalculate min/max/avg (issue #131)
|
||||
for profile_name in profiles_to_rebuild:
|
||||
try:
|
||||
await self.profile_store.async_rebuild_envelope(profile_name)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
self._logger.error("Failed to rebuild envelope for profile '%s': %s", profile_name, e)
|
||||
|
||||
# Persist changes
|
||||
await self.profile_store.async_save()
|
||||
|
||||
# Trigger UI and sensor refresh (Issue #155)
|
||||
async_dispatcher_send(self.hass, f"ha_washdata_update_{self.entry_id}")
|
||||
|
||||
return True
|
||||
|
||||
def _auto_label_cycle(self, cycle_id: str, profile_name: str, manual_duration: float | None = None) -> None:
|
||||
cycles = self.profile_store.get_past_cycles()
|
||||
cycle = next((c for c in cycles if c["id"] == cycle_id), None)
|
||||
if cycle:
|
||||
cycle["profile_name"] = profile_name
|
||||
cycle["auto_labeled"] = True
|
||||
if manual_duration:
|
||||
cycle["manual_duration"] = manual_duration
|
||||
|
||||
def _apply_correction_learning(
|
||||
self,
|
||||
cycle_id: str,
|
||||
corrected_profile: str,
|
||||
corrected_duration: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Apply user correction to a cycle (fix for issue #131).
|
||||
|
||||
Note: We do not update avg_duration here with EMA. Instead, the envelope
|
||||
rebuild in async_submit_cycle_feedback() will recalculate all statistics
|
||||
(min/max/avg) from labeled cycles, ensuring accuracy.
|
||||
"""
|
||||
self._auto_label_cycle(cycle_id, corrected_profile, corrected_duration)
|
||||
if corrected_duration is not None:
|
||||
cycles = self.profile_store.get_past_cycles()
|
||||
cycle = next((c for c in cycles if c["id"] == cycle_id), None)
|
||||
if cycle:
|
||||
cycle["duration"] = corrected_duration
|
||||
# Profile stats will be recalculated when envelope is rebuilt
|
||||
|
||||
async def _async_rebuild_profile_envelope(self, profile_name: str) -> None:
|
||||
"""Async helper to rebuild a profile's envelope (issue #131 fix).
|
||||
|
||||
This wraps async_rebuild_envelope with error handling for safe task scheduling.
|
||||
"""
|
||||
try:
|
||||
await self.profile_store.async_rebuild_envelope(profile_name)
|
||||
self._logger.debug("Rebuilt envelope for profile '%s'", profile_name)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
self._logger.error("Failed to rebuild envelope for profile '%s': %s", profile_name, e)
|
||||
|
||||
async def _async_rebuild_and_save_profile(self, detected_profile: str) -> None:
|
||||
"""Rebuild profile envelope then persist in deterministic order."""
|
||||
await self._async_rebuild_profile_envelope(detected_profile)
|
||||
await self.profile_store.async_save()
|
||||
|
||||
def get_pending_feedback(self) -> dict[str, dict[str, Any]]:
|
||||
"""Return pending feedback requests."""
|
||||
return dict(self.profile_store.get_pending_feedback())
|
||||
|
||||
def get_feedback_history(self, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""Return submitted feedback history."""
|
||||
items = list(self.profile_store.get_feedback_history().values())
|
||||
items.sort(key=lambda x: x.get("submitted_at", ""), reverse=True)
|
||||
return items[:limit]
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Logging utilities for WashData."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
class DeviceLoggerAdapter(logging.LoggerAdapter):
|
||||
"""Logger adapter that prepends the device name to every log message.
|
||||
|
||||
Usage::
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
class MyClass:
|
||||
def __init__(self, device_name: str) -> None:
|
||||
self._logger = DeviceLoggerAdapter(_LOGGER, device_name)
|
||||
|
||||
def do_thing(self) -> None:
|
||||
self._logger.info("Something happened")
|
||||
# emits: "[My Device] Something happened"
|
||||
"""
|
||||
|
||||
def __init__(self, logger: logging.Logger, device_name: str) -> None:
|
||||
super().__init__(logger, {"device_name": device_name})
|
||||
|
||||
def process(self, msg: str, kwargs: dict) -> tuple[str, dict]:
|
||||
device = self.extra.get("device_name") or "unknown" # type: ignore[union-attr]
|
||||
return f"[{device}] {msg}", kwargs
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"domain": "ha_washdata",
|
||||
"name": "WashData",
|
||||
"after_dependencies": [
|
||||
"lovelace",
|
||||
"http"
|
||||
],
|
||||
"codeowners": [
|
||||
"@3dg1luk43"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/3dg1luk43/ha_washdata",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/3dg1luk43/ha_washdata/issues",
|
||||
"requirements": [
|
||||
"numpy"
|
||||
],
|
||||
"version": "0.4.5.1"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Helpers for phase range assignment and timestamp conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
def parse_phase_timestamp(value: str, cycle_start_dt: datetime) -> datetime | None:
|
||||
"""Parse timestamp text used in phase assignment.
|
||||
|
||||
Supported formats:
|
||||
- Full parseable datetime (via Home Assistant parser)
|
||||
- YYYY-MM-DD HH:MM
|
||||
- YYYY-MM-DD HH:MM:SS
|
||||
- HH:MM (on cycle start date)
|
||||
- HH:MM:SS (on cycle start date)
|
||||
"""
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
parsed = dt_util.parse_datetime(text)
|
||||
if parsed is not None:
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=cycle_start_dt.tzinfo)
|
||||
return parsed
|
||||
|
||||
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S"):
|
||||
try:
|
||||
dt_val = datetime.strptime(text, fmt)
|
||||
return dt_val.replace(tzinfo=cycle_start_dt.tzinfo)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
for fmt in ("%H:%M", "%H:%M:%S"):
|
||||
try:
|
||||
t_val = datetime.strptime(text, fmt)
|
||||
base = dt_util.as_local(cycle_start_dt)
|
||||
return base.replace(
|
||||
hour=t_val.hour,
|
||||
minute=t_val.minute,
|
||||
second=t_val.second,
|
||||
microsecond=0,
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,512 @@
|
||||
"""Phase catalog defaults and helpers for WashData."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from .const import (
|
||||
DEVICE_TYPE_AIR_FRYER,
|
||||
DEVICE_TYPE_BREAD_MAKER,
|
||||
DEVICE_TYPE_COFFEE_MACHINE,
|
||||
DEVICE_TYPE_DISHWASHER,
|
||||
DEVICE_TYPE_DRYER,
|
||||
DEVICE_TYPE_EV,
|
||||
DEVICE_TYPE_HEAT_PUMP,
|
||||
DEVICE_TYPE_OVEN,
|
||||
DEVICE_TYPE_WASHER_DRYER,
|
||||
DEVICE_TYPE_WASHING_MACHINE,
|
||||
)
|
||||
|
||||
PhaseItem = dict[str, Any]
|
||||
|
||||
|
||||
def _builtin_phase_id(device_type: str, name: str) -> str:
|
||||
"""Return a stable ID like 'washing_machine.pre_wash' for a built-in phase."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", name.strip().lower()).strip("_")
|
||||
return f"{device_type}.{slug}"
|
||||
|
||||
DEFAULT_PHASES_BY_DEVICE: dict[str, list[PhaseItem]] = {
|
||||
DEVICE_TYPE_WASHING_MACHINE: [
|
||||
{
|
||||
"name": "Pre-Wash",
|
||||
"description": "Initial soak or pre-treatment before the main wash.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Wash",
|
||||
"description": "Main washing cycle with drum movement and optional heating.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Rinse",
|
||||
"description": "Clean-water rinse stage. This phase may repeat multiple times.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Spin",
|
||||
"description": "High-speed extraction to remove water from the load.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Soak",
|
||||
"description": "Low-activity soaking period between active wash stages.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Anti-Crease",
|
||||
"description": "Occasional short tumbles after completion to reduce wrinkles.",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
DEVICE_TYPE_DRYER: [
|
||||
{
|
||||
"name": "Heat Up",
|
||||
"description": "Initial heater warm-up before full drying begins.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Drying",
|
||||
"description": "Main heated tumbling period.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Cool Down",
|
||||
"description": "Tumbling without heat near cycle end.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Anti-Wrinkle",
|
||||
"description": "Periodic post-cycle tumbling to reduce wrinkles.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Sensor Check",
|
||||
"description": "Short low-power pause while dryness is measured.",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
DEVICE_TYPE_WASHER_DRYER: [
|
||||
{
|
||||
"name": "Pre-Wash",
|
||||
"description": "Initial soak or pre-treatment before the main wash.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Wash",
|
||||
"description": "Main washing cycle with drum movement and optional heating.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Rinse",
|
||||
"description": "Clean-water rinse stage. This phase may repeat multiple times.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Spin",
|
||||
"description": "High-speed extraction before drying transition.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Drain & Switch",
|
||||
"description": "Transition period from washing to drying mode.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Heat Up",
|
||||
"description": "Initial heater warm-up before full drying begins.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Drying",
|
||||
"description": "Main heated tumbling period.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Cool Down",
|
||||
"description": "Tumbling without heat near cycle end.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Anti-Wrinkle",
|
||||
"description": "Periodic post-cycle tumbling to reduce wrinkles.",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
DEVICE_TYPE_DISHWASHER: [
|
||||
{
|
||||
"name": "Pre-Rinse",
|
||||
"description": "Initial spray-down before detergent wash.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Wash",
|
||||
"description": "Main detergent wash with heating.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Rinse",
|
||||
"description": "Clean-water rinse stage. This phase may repeat multiple times.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Dry",
|
||||
"description": "Drying stage using heater and/or residual heat.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Sanitize",
|
||||
"description": "High-temperature cleaning stage for sanitization programs.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Soak",
|
||||
"description": "Extended soak period for heavy soil.",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
DEVICE_TYPE_COFFEE_MACHINE: [
|
||||
{
|
||||
"name": "Heat Up",
|
||||
"description": "Boiler heating to reach operating temperature.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Brewing",
|
||||
"description": "Water pumping through coffee grounds.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Keep Warm",
|
||||
"description": "Maintaining temperature after brew completion.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Grinding",
|
||||
"description": "Bean grinding stage on machines with integrated grinder.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Steaming",
|
||||
"description": "Steam generation for milk frothing.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Idle",
|
||||
"description": "Ready/standby period with low power use.",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
DEVICE_TYPE_EV: [
|
||||
{
|
||||
"name": "Initialization",
|
||||
"description": "Vehicle and charger handshake before power transfer.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Charging",
|
||||
"description": "Main charging period at available power.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Taper",
|
||||
"description": "Reduced charging rate near high state of charge.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Maintenance",
|
||||
"description": "Battery balancing or conditioning activity.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Complete",
|
||||
"description": "Charge complete with minimal top-up activity.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Pre-Conditioning",
|
||||
"description": "Battery temperature conditioning before or during charge.",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
DEVICE_TYPE_AIR_FRYER: [
|
||||
{
|
||||
"name": "Pre-Heat",
|
||||
"description": "Initial chamber heating before full cooking.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Cooking",
|
||||
"description": "Main cooking phase with active heater and fan.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Pause",
|
||||
"description": "Short pause for shaking or inspection.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Cool Down",
|
||||
"description": "Fan-only cool-down stage after heating.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Keep Warm",
|
||||
"description": "Low-heat holding stage to keep food warm.",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
DEVICE_TYPE_HEAT_PUMP: [
|
||||
{
|
||||
"name": "Start-Up",
|
||||
"description": "Compressor and system stabilization at cycle start.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Heating",
|
||||
"description": "Active heating operation.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Cooling",
|
||||
"description": "Active cooling operation.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Defrost",
|
||||
"description": "Defrost routine to clear outdoor coil ice.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Standby",
|
||||
"description": "Low-activity temperature holding period.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Fan Only",
|
||||
"description": "Air circulation without compressor heating/cooling.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Boost",
|
||||
"description": "High-output operation for rapid temperature change.",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
DEVICE_TYPE_BREAD_MAKER: [
|
||||
{
|
||||
"name": "Kneading",
|
||||
"description": "Motor-driven dough mixing and development. High power draw.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Resting",
|
||||
"description": "Short low-power pause between kneading stages for gluten relaxation.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Proving",
|
||||
"description": "Low-heat rising period to allow yeast fermentation and dough expansion.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Baking",
|
||||
"description": "High-temperature heating element active for crust and crumb formation.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Keep Warm",
|
||||
"description": "Low-heat holding stage to keep the loaf warm after baking.",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
DEVICE_TYPE_OVEN: [
|
||||
{
|
||||
"name": "Pre-Heat",
|
||||
"description": "Heating element runs continuously to bring the cavity up to the target temperature.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Heating",
|
||||
"description": "Active heater bursts during cooking when the thermostat calls for heat.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Maintaining Temp",
|
||||
"description": "Thermostat-regulated holding period: heater cycles on and off to keep the set temperature.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Cool Down",
|
||||
"description": "Heater off after the cycle ends; residual heat dissipates and the cooling fan may continue to run.",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Pyrolytic Clean",
|
||||
"description": "High-temperature self-clean phase that burns off residue. Optional and only active during pyrolytic programs.",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def normalize_phase_name(name: str) -> str:
|
||||
"""Normalize and validate phase names."""
|
||||
normalized = " ".join(name.strip().split())
|
||||
if not normalized:
|
||||
raise ValueError("invalid_phase_name")
|
||||
if len(normalized) > 48:
|
||||
raise ValueError("phase_name_too_long")
|
||||
return normalized
|
||||
|
||||
|
||||
def get_default_phase_catalog(device_type: str) -> list[PhaseItem]:
|
||||
"""Return default phase catalog for a device type, with id and device_type injected."""
|
||||
phases = deepcopy(DEFAULT_PHASES_BY_DEVICE.get(device_type, []))
|
||||
for phase in phases:
|
||||
phase["id"] = _builtin_phase_id(device_type, str(phase.get("name", "")))
|
||||
phase["device_type"] = device_type
|
||||
return phases
|
||||
|
||||
|
||||
def get_shared_default_phase_catalog() -> list[PhaseItem]:
|
||||
"""Return a shared default catalog deduplicated across all device types."""
|
||||
merged: list[PhaseItem] = []
|
||||
seen: set[str] = set()
|
||||
for device_type, device_phases in DEFAULT_PHASES_BY_DEVICE.items():
|
||||
for item in device_phases:
|
||||
name = str(item.get("name", "")).strip()
|
||||
if not name:
|
||||
continue
|
||||
key = name.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
merged.append(
|
||||
{
|
||||
"id": _builtin_phase_id(device_type, name),
|
||||
"device_type": device_type,
|
||||
"name": name,
|
||||
"description": str(item.get("description", "")).strip(),
|
||||
"is_default": True,
|
||||
}
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def get_builtin_phase_by_id(phase_id: str) -> PhaseItem | None:
|
||||
"""Return a copy of the built-in phase with the given id, or None."""
|
||||
for device_type, device_phases in DEFAULT_PHASES_BY_DEVICE.items():
|
||||
for item in device_phases:
|
||||
name = str(item.get("name", "")).strip()
|
||||
if _builtin_phase_id(device_type, name) == phase_id:
|
||||
result = deepcopy(item)
|
||||
result["id"] = phase_id
|
||||
result["device_type"] = device_type
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def merge_phase_catalog(device_type: str, custom_phases: list[PhaseItem] | None) -> list[PhaseItem]:
|
||||
"""Merge device defaults with custom phases. Uses 'id' as the primary collision key."""
|
||||
merged = (
|
||||
get_default_phase_catalog(device_type)
|
||||
if device_type in DEFAULT_PHASES_BY_DEVICE
|
||||
else get_shared_default_phase_catalog()
|
||||
)
|
||||
|
||||
# Index built-ins by id and by (device_type, name) for the name-based fallback.
|
||||
builtin_by_id: dict[str, int] = {}
|
||||
builtin_by_name: dict[tuple[str, str], int] = {}
|
||||
for idx, item in enumerate(merged):
|
||||
item_id = str(item.get("id", ""))
|
||||
if item_id:
|
||||
builtin_by_id[item_id] = idx
|
||||
item_dt = str(item.get("device_type", "")).casefold()
|
||||
item_name = str(item.get("name", "")).strip().casefold()
|
||||
if item_name:
|
||||
builtin_by_name[(item_dt, item_name)] = idx
|
||||
|
||||
seen_ids: set[str] = set(builtin_by_id.keys())
|
||||
seen_names: set[tuple[str, str]] = set(builtin_by_name.keys())
|
||||
|
||||
# All known built-in names - used to guard against polluting unrelated catalogs.
|
||||
all_builtin_names = {
|
||||
str(p.get("name", "")).strip().casefold()
|
||||
for phases_list in DEFAULT_PHASES_BY_DEVICE.values()
|
||||
for p in phases_list
|
||||
}
|
||||
|
||||
for item in (custom_phases or []):
|
||||
try:
|
||||
normalized_name = normalize_phase_name(str(item.get("name", "")))
|
||||
except ValueError:
|
||||
continue
|
||||
if not normalized_name:
|
||||
continue
|
||||
|
||||
item_device_type = str(item.get("device_type", "")).strip()
|
||||
# Skip if this custom phase targets a different specific device type.
|
||||
if item_device_type:
|
||||
if item_device_type.casefold() != str(device_type or "").strip().casefold():
|
||||
continue
|
||||
|
||||
phase_id = str(item.get("id", "")).strip()
|
||||
|
||||
# Primary: id-based in-place replacement of a built-in entry.
|
||||
if phase_id and phase_id in builtin_by_id:
|
||||
idx = builtin_by_id[phase_id]
|
||||
original_device_type = str(merged[idx].get("device_type", item_device_type))
|
||||
merged[idx] = {
|
||||
"id": phase_id,
|
||||
"device_type": original_device_type,
|
||||
"name": normalized_name,
|
||||
"description": str(item.get("description", "")).strip(),
|
||||
"is_default": False,
|
||||
}
|
||||
continue
|
||||
|
||||
# Fallback: name-based match for old data without ids.
|
||||
name_key = (item_device_type.casefold(), normalized_name.casefold())
|
||||
if name_key in builtin_by_name:
|
||||
idx = builtin_by_name[name_key]
|
||||
new_desc = str(item.get("description", "")).strip()
|
||||
if new_desc:
|
||||
merged[idx]["description"] = new_desc
|
||||
merged[idx]["is_default"] = False
|
||||
continue
|
||||
|
||||
# New phase: guard against universal overrides leaking into unrelated catalogs.
|
||||
# For legacy items with no device_type, first try matching against the active
|
||||
# catalog device_type before discarding, so legacy overrides are preserved.
|
||||
if not item_device_type and normalized_name.casefold() in all_builtin_names:
|
||||
active_dt_key = (str(device_type or "").strip().casefold(), normalized_name.casefold())
|
||||
if active_dt_key in builtin_by_name:
|
||||
idx = builtin_by_name[active_dt_key]
|
||||
new_desc = str(item.get("description", "")).strip()
|
||||
if new_desc:
|
||||
merged[idx]["description"] = new_desc
|
||||
merged[idx]["is_default"] = False
|
||||
continue
|
||||
|
||||
# Deduplicate before appending.
|
||||
if phase_id and phase_id in seen_ids:
|
||||
continue
|
||||
append_name_key = (item_device_type.casefold(), normalized_name.casefold())
|
||||
if append_name_key in seen_names:
|
||||
continue
|
||||
|
||||
new_phase: PhaseItem = {
|
||||
"name": normalized_name,
|
||||
"description": str(item.get("description", "")).strip(),
|
||||
"device_type": item_device_type,
|
||||
"is_default": False,
|
||||
}
|
||||
if phase_id:
|
||||
new_phase["id"] = phase_id
|
||||
seen_ids.add(phase_id)
|
||||
seen_names.add(append_name_key)
|
||||
merged.append(new_phase)
|
||||
|
||||
return [p for p in merged if p.get("name")]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,317 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Select entity for WashData."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from .const import DOMAIN, SIGNAL_WASHER_UPDATE
|
||||
from .manager import WashDataManager
|
||||
from .profile_store import profile_sort_key
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
OPTION_AUTO = "auto_detect"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the select entity."""
|
||||
manager: WashDataManager = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
async_add_entities([WashDataProgramSelect(manager, config_entry)])
|
||||
|
||||
|
||||
class WashDataProgramSelect(SelectEntity):
|
||||
"""Select entity to manually choose the running program."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
_attr_translation_key = "program_select"
|
||||
|
||||
def __init__(self, manager: WashDataManager, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize the select entity."""
|
||||
self._manager = manager
|
||||
self._config_entry = config_entry
|
||||
self._attr_unique_id = f"{config_entry.entry_id}_program_select"
|
||||
self._attr_device_info = {
|
||||
"identifiers": {(DOMAIN, config_entry.entry_id)},
|
||||
"name": config_entry.title,
|
||||
"manufacturer": "WashData",
|
||||
}
|
||||
|
||||
# Determine icon based on device type
|
||||
dtype = getattr(manager, "device_type", "washing_machine")
|
||||
if dtype == "dryer":
|
||||
self._attr_icon = "mdi:tumble-dryer"
|
||||
elif dtype == "dishwasher":
|
||||
self._attr_icon = "mdi:dishwasher"
|
||||
elif dtype == "ev":
|
||||
self._attr_icon = "mdi:car-electric"
|
||||
elif dtype == "coffee_machine":
|
||||
self._attr_icon = "mdi:coffee"
|
||||
elif dtype == "air_fryer":
|
||||
self._attr_icon = "mdi:pot-steam"
|
||||
elif dtype == "heat_pump":
|
||||
self._attr_icon = "mdi:heat-pump"
|
||||
elif dtype == "oven":
|
||||
self._attr_icon = "mdi:stove"
|
||||
else:
|
||||
self._attr_icon = "mdi:washing-machine" # Default and washing_machine
|
||||
|
||||
self._update_options()
|
||||
|
||||
@callback
|
||||
def _update_options(self) -> None:
|
||||
"""Update the list of available options from profiles."""
|
||||
profiles = self._manager.profile_store.list_profiles()
|
||||
# Sort profiles by name
|
||||
profile_names = sorted([p["name"] for p in profiles], key=profile_sort_key)
|
||||
self._attr_options = [OPTION_AUTO] + profile_names
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
SIGNAL_WASHER_UPDATE.format(self._manager.entry_id),
|
||||
self._update_state,
|
||||
)
|
||||
)
|
||||
self._update_state()
|
||||
|
||||
@callback
|
||||
def _update_state(self) -> None:
|
||||
"""Update state from manager."""
|
||||
# Refresh options in case new profiles were created
|
||||
self._update_options()
|
||||
|
||||
current = self._manager.current_program
|
||||
manual_active = getattr(self._manager, "manual_program_active", False)
|
||||
|
||||
if manual_active and current:
|
||||
self._attr_current_option = current
|
||||
elif current in ("detecting...", "off", "restored..."):
|
||||
self._attr_current_option = OPTION_AUTO
|
||||
elif current:
|
||||
# It detected a program, but it's not "manual override" mode.
|
||||
# Should we show the detected program or "Auto"?
|
||||
# Showing "Auto" implies "I am in auto mode".
|
||||
# But user might want to see what is detected here too?
|
||||
# Standard pattern: Select shows target/mode.
|
||||
# If we are in auto mode, show Auto. The sensor shows the detected program.
|
||||
self._attr_current_option = OPTION_AUTO
|
||||
else:
|
||||
self._attr_current_option = OPTION_AUTO
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
def select_option(self, option: str) -> None:
|
||||
"""Handle the option selection (sync wrapper)."""
|
||||
raise NotImplementedError("Use async_select_option instead")
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
if option == OPTION_AUTO:
|
||||
self._manager.clear_manual_program()
|
||||
else:
|
||||
self._manager.set_manual_program(option)
|
||||
@@ -0,0 +1,864 @@
|
||||
"""Sensors for WashData."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import Task
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription, SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_AUTO_LABEL_CONFIDENCE,
|
||||
CONF_DURATION_TOLERANCE,
|
||||
DOMAIN,
|
||||
CONF_END_ENERGY_THRESHOLD,
|
||||
CONF_MIN_OFF_GAP,
|
||||
CONF_MIN_POWER,
|
||||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||||
CONF_OFF_DELAY,
|
||||
CONF_PROFILE_DURATION_TOLERANCE,
|
||||
CONF_PROFILE_MATCH_INTERVAL,
|
||||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
CONF_RUNNING_DEAD_ZONE,
|
||||
CONF_SAMPLING_INTERVAL,
|
||||
CONF_START_THRESHOLD_W,
|
||||
CONF_STOP_THRESHOLD_W,
|
||||
SIGNAL_WASHER_UPDATE,
|
||||
CONF_WATCHDOG_INTERVAL,
|
||||
CONF_EXPOSE_DEBUG_ENTITIES,
|
||||
DEVICE_TYPE_PUMP,
|
||||
STATE_OFF,
|
||||
STATE_IDLE,
|
||||
STATE_STARTING,
|
||||
STATE_RUNNING,
|
||||
STATE_PAUSED,
|
||||
STATE_USER_PAUSED,
|
||||
STATE_ENDING,
|
||||
STATE_FINISHED,
|
||||
STATE_ANTI_WRINKLE,
|
||||
STATE_DELAY_WAIT,
|
||||
STATE_INTERRUPTED,
|
||||
STATE_FORCE_STOPPED,
|
||||
STATE_RINSE,
|
||||
STATE_UNKNOWN,
|
||||
STATE_CLEAN,
|
||||
)
|
||||
from .manager import WashDataManager
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_STATIC_DIAGNOSTIC_SUFFIXES = {
|
||||
"debug_info",
|
||||
"suggestions",
|
||||
"match_confidence",
|
||||
"top_candidates",
|
||||
"ambiguity",
|
||||
}
|
||||
|
||||
|
||||
def _profile_count_unique_id(entry_id: str, profile_name: str) -> str:
|
||||
"""Build deterministic unique_id for a profile count diagnostic sensor."""
|
||||
profile_token = hashlib.sha256(profile_name.encode("utf-8")).hexdigest()[:8]
|
||||
return f"{entry_id}_profile_count_{profile_token}"
|
||||
|
||||
|
||||
def _expected_diagnostic_unique_ids(manager: WashDataManager, entry: ConfigEntry) -> set[str]:
|
||||
"""Return expected diagnostic unique_ids for this config entry."""
|
||||
expected = {
|
||||
f"{entry.entry_id}_debug_info",
|
||||
f"{entry.entry_id}_suggestions",
|
||||
}
|
||||
|
||||
if entry.options.get(CONF_EXPOSE_DEBUG_ENTITIES):
|
||||
expected.update(
|
||||
{
|
||||
f"{entry.entry_id}_match_confidence",
|
||||
f"{entry.entry_id}_top_candidates",
|
||||
f"{entry.entry_id}_ambiguity",
|
||||
}
|
||||
)
|
||||
|
||||
for profile in manager.profile_store.list_profiles():
|
||||
profile_name = profile.get("name")
|
||||
if isinstance(profile_name, str) and profile_name:
|
||||
expected.add(_profile_count_unique_id(entry.entry_id, profile_name))
|
||||
|
||||
return expected
|
||||
|
||||
|
||||
def cleanup_orphaned_diagnostic_entities(
|
||||
hass: HomeAssistant, manager: WashDataManager, entry: ConfigEntry
|
||||
) -> int:
|
||||
"""Remove stale diagnostic entities for this config entry from entity registry."""
|
||||
ent_reg = entity_registry.async_get(hass)
|
||||
expected_unique_ids = _expected_diagnostic_unique_ids(manager, entry)
|
||||
|
||||
removed = 0
|
||||
entry_prefix = f"{entry.entry_id}_"
|
||||
for reg_entry in entity_registry.async_entries_for_config_entry(ent_reg, entry.entry_id):
|
||||
unique_id = reg_entry.unique_id or ""
|
||||
if not unique_id.startswith(entry_prefix):
|
||||
continue
|
||||
|
||||
suffix = unique_id[len(entry_prefix) :]
|
||||
|
||||
# Remove stale pump_runs_today when device type has changed away from pump.
|
||||
if suffix == "pump_runs_today" and manager.device_type != DEVICE_TYPE_PUMP:
|
||||
ent_reg.async_remove(reg_entry.entity_id)
|
||||
removed += 1
|
||||
continue
|
||||
|
||||
is_diagnostic_family = (
|
||||
suffix in _STATIC_DIAGNOSTIC_SUFFIXES
|
||||
or suffix.startswith("profile_count_")
|
||||
or suffix == "wash_phase"
|
||||
)
|
||||
if not is_diagnostic_family:
|
||||
continue
|
||||
|
||||
if unique_id not in expected_unique_ids:
|
||||
ent_reg.async_remove(reg_entry.entity_id)
|
||||
removed += 1
|
||||
|
||||
if removed:
|
||||
_LOGGER.info(
|
||||
"Removed %s orphaned diagnostic entities for entry %s",
|
||||
removed,
|
||||
entry.entry_id,
|
||||
)
|
||||
return removed
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the sensors."""
|
||||
manager: WashDataManager = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
entities: list[SensorEntity] = [
|
||||
WasherStateSensor(manager, entry),
|
||||
WasherProgramSensor(manager, entry),
|
||||
WasherCurrentPhaseSensor(manager, entry),
|
||||
WasherTimeRemainingSensor(manager, entry),
|
||||
WasherTotalDurationSensor(manager, entry),
|
||||
WasherProgressSensor(manager, entry),
|
||||
WasherPowerSensor(manager, entry),
|
||||
WasherElapsedTimeSensor(manager, entry),
|
||||
WasherDebugSensor(manager, entry),
|
||||
WasherSuggestionsSensor(manager, entry),
|
||||
WasherCycleCountSensor(manager, entry),
|
||||
]
|
||||
|
||||
# Add pump-specific sensors
|
||||
if manager.device_type == DEVICE_TYPE_PUMP:
|
||||
entities.append(PumpRunsTodaySensor(manager, entry))
|
||||
|
||||
# Add debug entities if enabled
|
||||
if entry.options.get(CONF_EXPOSE_DEBUG_ENTITIES):
|
||||
entities.extend(
|
||||
[
|
||||
WasherMatchConfidenceSensor(manager, entry),
|
||||
WasherTopCandidatesSensor(manager, entry),
|
||||
]
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
# Reconcile diagnostics at startup so stale unavailable entries are auto-removed.
|
||||
cleanup_orphaned_diagnostic_entities(hass, manager, entry)
|
||||
|
||||
# Initialize dynamic profile sensor manager
|
||||
profile_sensor_manager = WasherProfileSensorManager(manager, entry, async_add_entities)
|
||||
await profile_sensor_manager.async_update()
|
||||
entry.async_on_unload(profile_sensor_manager.unsubscribe)
|
||||
|
||||
|
||||
class WasherBaseSensor(SensorEntity):
|
||||
"""Base sensor for ha_washdata."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
self._manager = manager
|
||||
self._entry = entry
|
||||
self._attr_device_info = {
|
||||
"identifiers": {(DOMAIN, entry.entry_id)},
|
||||
"name": entry.title,
|
||||
"manufacturer": "WashData",
|
||||
}
|
||||
self._attr_unique_id = f"{entry.entry_id}_{self.entity_description.key}"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
SIGNAL_WASHER_UPDATE.format(self._entry.entry_id),
|
||||
self._update_callback,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_callback(self) -> None:
|
||||
"""Update the sensor."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class WasherStateSensor(WasherBaseSensor):
|
||||
"""Sensor for the washing machine state."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the state sensor."""
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="washer_state",
|
||||
translation_key="washer_state",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
STATE_OFF,
|
||||
STATE_IDLE,
|
||||
STATE_STARTING,
|
||||
STATE_RUNNING,
|
||||
STATE_PAUSED,
|
||||
STATE_USER_PAUSED,
|
||||
STATE_ENDING,
|
||||
STATE_FINISHED,
|
||||
STATE_ANTI_WRINKLE,
|
||||
STATE_DELAY_WAIT,
|
||||
STATE_INTERRUPTED,
|
||||
STATE_FORCE_STOPPED,
|
||||
STATE_RINSE,
|
||||
STATE_UNKNOWN,
|
||||
STATE_CLEAN,
|
||||
],
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None: # type: ignore[override]
|
||||
"""Return the icon."""
|
||||
dtype = self._manager.device_type
|
||||
if dtype == "dryer":
|
||||
return "mdi:tumble-dryer"
|
||||
if dtype == "dishwasher":
|
||||
return "mdi:dishwasher"
|
||||
if dtype == "ev":
|
||||
return "mdi:car-electric"
|
||||
if dtype == "coffee_machine":
|
||||
return "mdi:coffee-maker"
|
||||
if dtype == "air_fryer":
|
||||
return "mdi:pot-steam"
|
||||
if dtype == "heat_pump":
|
||||
return "mdi:heat-pump"
|
||||
if dtype == "pump":
|
||||
return "mdi:water-pump"
|
||||
if dtype == "oven":
|
||||
return "mdi:stove"
|
||||
return "mdi:washing-machine"
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
return self._manager.check_state()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self): # type: ignore[override]
|
||||
attrs: dict[str, Any] = {
|
||||
"samples_recorded": self._manager.samples_recorded,
|
||||
"current_program_guess": self._manager.current_program,
|
||||
"sub_state": self._manager.sub_state,
|
||||
}
|
||||
if self._manager.device_type == DEVICE_TYPE_PUMP:
|
||||
attrs["pump_stuck"] = self._manager.pump_stuck
|
||||
return attrs
|
||||
|
||||
|
||||
class WasherProgramSensor(WasherBaseSensor):
|
||||
"""Sensor for the current program."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the program sensor."""
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="washer_program",
|
||||
translation_key="washer_program",
|
||||
icon="mdi:file-document-outline",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def options(self) -> list[str] | None: # type: ignore[override]
|
||||
"""Return a list of possible options."""
|
||||
profiles = self._manager.profile_store.list_profiles()
|
||||
# Include current program if not in profiles (e.g. unknown or special states)
|
||||
options = [p["name"] for p in profiles]
|
||||
curr = self._manager.current_program
|
||||
if curr and curr not in options:
|
||||
options.append(curr)
|
||||
if "none" not in options:
|
||||
options.append("none")
|
||||
if "unknown" not in options:
|
||||
options.append("unknown")
|
||||
return options
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
return self._manager.current_program
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self): # type: ignore[override]
|
||||
profile_name = self._manager.current_program
|
||||
if not profile_name or profile_name in ("off", "detecting...", "starting", "unknown"):
|
||||
return None
|
||||
|
||||
device_type = self._manager.device_type
|
||||
if device_type:
|
||||
catalog = self._manager.list_phase_catalog(device_type)
|
||||
assigned = self._manager.get_profile_phase_ranges_for_device(
|
||||
profile_name,
|
||||
device_type,
|
||||
)
|
||||
else:
|
||||
catalog = []
|
||||
assigned = []
|
||||
catalog_view: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": p.get("name"),
|
||||
"description": p.get("description", ""),
|
||||
"is_default": bool(p.get("is_default", False)),
|
||||
}
|
||||
for p in catalog
|
||||
]
|
||||
|
||||
attrs: dict[str, Any] = {
|
||||
"active_phase": self._manager.phase_description,
|
||||
"phase_catalog": catalog_view,
|
||||
"phase_ranges": assigned,
|
||||
}
|
||||
return attrs
|
||||
|
||||
|
||||
class WasherTimeRemainingSensor(WasherBaseSensor):
|
||||
"""Sensor for estimated time remaining."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the time remaining sensor."""
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="time_remaining",
|
||||
translation_key="time_remaining",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
# Declare the unit statically (not as a state-dependent property) so
|
||||
# Home Assistant always sees a duration entity and offers the
|
||||
# duration display-format options, even while the appliance is idle
|
||||
# and the value is unknown (see issue #261).
|
||||
native_unit_of_measurement="min",
|
||||
icon="mdi:timer-sand",
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
if self._manager.check_state() in (STATE_OFF, STATE_ANTI_WRINKLE, STATE_DELAY_WAIT):
|
||||
return None
|
||||
if self._manager.time_remaining is not None:
|
||||
return int(self._manager.time_remaining / 60)
|
||||
return None
|
||||
|
||||
|
||||
class WasherTotalDurationSensor(WasherBaseSensor):
|
||||
"""Sensor for total predicted duration."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the total duration sensor."""
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="total_duration",
|
||||
translation_key="total_duration",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
# See WasherTimeRemainingSensor / issue #261: keep the unit static so
|
||||
# the duration display-format options are available even while idle.
|
||||
native_unit_of_measurement="min",
|
||||
icon="mdi:timer-check-outline",
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
if self._manager.check_state() == STATE_OFF:
|
||||
return None
|
||||
if self._manager.total_duration:
|
||||
return int(self._manager.total_duration / 60)
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self): # type: ignore[override]
|
||||
"""Return extra state attributes."""
|
||||
return {
|
||||
"last_updated": self._manager.last_total_duration_update,
|
||||
}
|
||||
|
||||
|
||||
class WasherProgressSensor(WasherBaseSensor):
|
||||
"""Sensor for cycle progress percentage."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the progress sensor."""
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="cycle_progress",
|
||||
translation_key="cycle_progress",
|
||||
native_unit_of_measurement="%",
|
||||
suggested_display_precision=1,
|
||||
icon="mdi:progress-clock",
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
return self._manager.cycle_progress
|
||||
|
||||
|
||||
class WasherPowerSensor(WasherBaseSensor):
|
||||
"""Sensor for current power usage."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the power sensor."""
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="current_power",
|
||||
translation_key="current_power",
|
||||
native_unit_of_measurement="W",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
icon="mdi:flash",
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
return self._manager.current_power
|
||||
|
||||
|
||||
class WasherElapsedTimeSensor(WasherBaseSensor):
|
||||
"""Sensor for elapsed cycle time."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the elapsed time sensor."""
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="elapsed_time",
|
||||
translation_key="elapsed_time",
|
||||
native_unit_of_measurement="s",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
icon="mdi:timer-outline",
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
if self._manager.check_state() == STATE_OFF:
|
||||
return 0
|
||||
start = self._manager.cycle_start_time
|
||||
if start:
|
||||
delta = dt_util.now() - start
|
||||
return int(delta.total_seconds())
|
||||
return 0
|
||||
|
||||
|
||||
class WasherDebugSensor(WasherBaseSensor):
|
||||
"""Sensor for internal debug information."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the debug sensor."""
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="debug_info",
|
||||
translation_key="debug_info",
|
||||
icon="mdi:bug",
|
||||
entity_registry_enabled_default=False, # Hidden by default
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
return self._manager.check_state()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self): # type: ignore[override]
|
||||
"""Return various internal states for debugging."""
|
||||
detector = self._manager.detector
|
||||
stats = self._manager.sample_interval_stats
|
||||
# pylint: disable=protected-access
|
||||
attrs: dict[str, Any] = {
|
||||
"sub_state": detector.sub_state,
|
||||
"match_confidence": getattr(self._manager, "_last_match_confidence", 0.0),
|
||||
"cycle_id": getattr(detector, "_current_cycle_start", None),
|
||||
"samples": detector.samples_recorded,
|
||||
"energy_accum": getattr(detector, "_energy_since_idle_wh", 0.0),
|
||||
"time_below": getattr(detector, "_time_below_threshold", 0.0),
|
||||
"sampling_p95": stats.get("p95"),
|
||||
"noise_events": len(getattr(self._manager, "_noise_events", [])),
|
||||
"top_candidates": self._manager.top_candidates,
|
||||
"last_match_details": self._manager.last_match_details,
|
||||
}
|
||||
return attrs
|
||||
|
||||
|
||||
class WasherMatchConfidenceSensor(WasherBaseSensor):
|
||||
"""Sensor for profile match confidence."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="match_confidence",
|
||||
translation_key="match_confidence",
|
||||
icon="mdi:chart-bar",
|
||||
state_class="measurement",
|
||||
native_unit_of_measurement="%",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
conf = getattr(self._manager, "_last_match_confidence", 0.0)
|
||||
return int(conf * 100)
|
||||
|
||||
|
||||
class WasherTopCandidatesSensor(WasherBaseSensor):
|
||||
"""Sensor showing top matching candidates."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="top_candidates",
|
||||
translation_key="top_candidates",
|
||||
icon="mdi:format-list-numbered",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
candidates = self._manager.top_candidates
|
||||
if not candidates:
|
||||
return "none"
|
||||
# Return simplified string: "Name (Score), Name (Score)"
|
||||
return ", ".join([f"{c['name']} ({c['score']:.2f})" for c in candidates[:3]])
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self): # type: ignore[override]
|
||||
return {"candidates": self._manager.top_candidates}
|
||||
|
||||
|
||||
class WasherCurrentPhaseSensor(WasherBaseSensor):
|
||||
"""Sensor for the current detected phase."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="current_phase",
|
||||
translation_key="current_phase",
|
||||
icon="mdi:water-sync",
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
return self._manager.phase_description
|
||||
|
||||
|
||||
class WasherProfileCountSensor(WasherBaseSensor):
|
||||
"""Diagnostic sensor showing cycle count for a specific profile."""
|
||||
|
||||
def __init__(
|
||||
self, manager: WashDataManager, entry: ConfigEntry, profile_name: str, count: int
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self._profile_name = profile_name
|
||||
self._profile_token = hashlib.sha256(
|
||||
profile_name.encode("utf-8")
|
||||
).hexdigest()[:8]
|
||||
# We store initial count, but update callback will refresh it
|
||||
self._count = count
|
||||
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key=f"profile_count_{self._profile_token}",
|
||||
translation_key="profile_cycle_count",
|
||||
icon="mdi:counter",
|
||||
state_class="total",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
)
|
||||
self._attr_translation_placeholders = {"profile_name": profile_name}
|
||||
super().__init__(manager, entry)
|
||||
# Override unique ID to be profile specific
|
||||
self._attr_unique_id = f"{entry.entry_id}_profile_count_{self._profile_token}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int: # type: ignore[override]
|
||||
"""Return the cycle count."""
|
||||
# Fetch fresh count from store if available
|
||||
profile = self._manager.profile_store.get_profile(self._profile_name)
|
||||
if profile:
|
||||
return profile.get("cycle_count", 0)
|
||||
return 0
|
||||
|
||||
@property
|
||||
def available(self) -> bool: # type: ignore[override]
|
||||
"""Return True if profile still exists."""
|
||||
return self._manager.profile_store.get_profile(self._profile_name) is not None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None: # type: ignore[override]
|
||||
"""Return profile statistics."""
|
||||
profile = self._manager.profile_store.get_profile(self._profile_name)
|
||||
if not profile:
|
||||
return None
|
||||
|
||||
avg_energy = profile.get("avg_energy")
|
||||
count = profile.get("cycle_count", 0)
|
||||
total_energy = (avg_energy * count) if avg_energy is not None else None
|
||||
duration_std_dev = profile.get("duration_std_dev")
|
||||
consistency_min = (
|
||||
float(duration_std_dev) / 60.0
|
||||
if isinstance(duration_std_dev, (int, float))
|
||||
else None
|
||||
)
|
||||
|
||||
# Helper to format duration
|
||||
def _to_min(sec: float) -> int:
|
||||
return int(sec / 60) if sec else 0
|
||||
|
||||
return {
|
||||
"average_consumption_kwh": avg_energy,
|
||||
"total_consumption_kwh": total_energy,
|
||||
"last_run": profile.get("last_run"),
|
||||
"average_length_min": _to_min(profile.get("avg_duration", 0)),
|
||||
"min_length_min": _to_min(profile.get("min_duration", 0)),
|
||||
"max_length_min": _to_min(profile.get("max_duration", 0)),
|
||||
"consistency_min": consistency_min,
|
||||
}
|
||||
|
||||
|
||||
class WasherProfileSensorManager:
|
||||
"""Manages dynamic profile sensors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager: WashDataManager,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self._manager = manager
|
||||
self._entry = entry
|
||||
self._async_add_entities = async_add_entities
|
||||
self._sensors: dict[str, WasherProfileCountSensor] = {}
|
||||
self._diagnostics_cleanup_done: bool = False
|
||||
|
||||
# Determine the signal string. It must match SIGNAL_WASHER_UPDATE from const.py
|
||||
# which is "washdata_update_{}"
|
||||
self._signal = SIGNAL_WASHER_UPDATE.format(entry.entry_id)
|
||||
self._update_task: Task[None] | None = None
|
||||
self._pending_update: bool = False
|
||||
|
||||
# Register callback for ALL updates (simplest hook we have)
|
||||
# Ideally we'd have a specific profile update signal, but general update is fine
|
||||
# as long as we debounce or check efficiently.
|
||||
self._unsub_dispatcher = async_dispatcher_connect(
|
||||
manager.hass,
|
||||
self._signal,
|
||||
self._update_callback,
|
||||
)
|
||||
|
||||
# Handle stale diagnostics that were left in registry by previous naming schemes
|
||||
# or profile renames. Run once at initialization instead of on every update.
|
||||
cleanup_orphaned_diagnostic_entities(
|
||||
self._manager.hass, self._manager, self._entry
|
||||
)
|
||||
self._diagnostics_cleanup_done = True
|
||||
|
||||
def unsubscribe(self) -> None:
|
||||
"""Remove the dispatcher subscription."""
|
||||
if self._unsub_dispatcher:
|
||||
self._unsub_dispatcher()
|
||||
self._unsub_dispatcher = None
|
||||
|
||||
# Prevent queued follow-up refreshes after teardown.
|
||||
self._pending_update = False
|
||||
if self._update_task and not self._update_task.done():
|
||||
self._update_task.cancel()
|
||||
self._update_task = None
|
||||
|
||||
@callback
|
||||
def _update_callback(self) -> None:
|
||||
"""Handle updates."""
|
||||
if self._update_task and not self._update_task.done():
|
||||
self._pending_update = True
|
||||
return
|
||||
|
||||
task = self._manager.hass.async_create_task(self.async_update())
|
||||
self._update_task = task
|
||||
|
||||
def _clear_update_task(done_task: Task[Any]) -> None:
|
||||
if self._update_task is done_task:
|
||||
self._update_task = None
|
||||
if self._unsub_dispatcher is not None and self._pending_update:
|
||||
self._pending_update = False
|
||||
follow = self._manager.hass.async_create_task(self.async_update())
|
||||
self._update_task = follow
|
||||
follow.add_done_callback(_clear_update_task)
|
||||
|
||||
task.add_done_callback(_clear_update_task)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Reflect profile changes in sensors."""
|
||||
profiles = self._manager.profile_store.list_profiles()
|
||||
current_names = {p["name"] for p in profiles}
|
||||
existing_names = set(self._sensors.keys())
|
||||
|
||||
# Add new
|
||||
new_names = current_names - existing_names
|
||||
new_entities: list[SensorEntity] = []
|
||||
for name in new_names:
|
||||
p_data = self._manager.profile_store.get_profile(name)
|
||||
count = p_data.get("cycle_count", 0) if p_data else 0
|
||||
sensor = WasherProfileCountSensor(self._manager, self._entry, name, count)
|
||||
self._sensors[name] = sensor
|
||||
new_entities.append(sensor)
|
||||
|
||||
if new_entities:
|
||||
self._async_add_entities(new_entities)
|
||||
|
||||
# Remove old (if profile deleted)
|
||||
removed_names = existing_names - current_names
|
||||
|
||||
if removed_names:
|
||||
ent_reg = entity_registry.async_get(self._manager.hass)
|
||||
for name in removed_names:
|
||||
sensor = self._sensors.pop(name)
|
||||
# Remove from Entity Registry if registered
|
||||
if sensor.entity_id:
|
||||
if ent_reg.async_get(sensor.entity_id):
|
||||
ent_reg.async_remove(sensor.entity_id)
|
||||
else:
|
||||
# Fallback for non-registered entities that were attached.
|
||||
if sensor.hass:
|
||||
try:
|
||||
await sensor.async_remove()
|
||||
except Exception as err: # pylint: disable=broad-exception-caught
|
||||
_LOGGER.debug(
|
||||
"Failed to remove sensor '%s' via fallback path: %s",
|
||||
name,
|
||||
err,
|
||||
)
|
||||
|
||||
|
||||
class WasherSuggestionsSensor(WasherBaseSensor):
|
||||
"""Sensor for learned settings suggestions."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="suggestions",
|
||||
translation_key="suggestions",
|
||||
icon="mdi:lightbulb-on-outline",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@staticmethod
|
||||
def _applicable_suggestion_keys() -> tuple[str, ...]:
|
||||
"""Return suggestion keys that can be applied in options flow."""
|
||||
return (
|
||||
CONF_MIN_POWER,
|
||||
CONF_OFF_DELAY,
|
||||
CONF_WATCHDOG_INTERVAL,
|
||||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||||
CONF_SAMPLING_INTERVAL,
|
||||
CONF_PROFILE_MATCH_INTERVAL,
|
||||
CONF_AUTO_LABEL_CONFIDENCE,
|
||||
CONF_DURATION_TOLERANCE,
|
||||
CONF_PROFILE_DURATION_TOLERANCE,
|
||||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
CONF_MIN_OFF_GAP,
|
||||
CONF_STOP_THRESHOLD_W,
|
||||
CONF_START_THRESHOLD_W,
|
||||
CONF_END_ENERGY_THRESHOLD,
|
||||
CONF_RUNNING_DEAD_ZONE,
|
||||
)
|
||||
|
||||
def _count_applicable_suggestions(self, suggestions: dict[str, Any]) -> int:
|
||||
"""Count only suggestions with values that can be applied from options flow."""
|
||||
count = 0
|
||||
for key in self._applicable_suggestion_keys():
|
||||
entry = suggestions.get(key)
|
||||
if isinstance(entry, dict) and entry.get("value") is not None:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
suggestions = self._manager.suggestions
|
||||
if not suggestions:
|
||||
return 0
|
||||
return self._count_applicable_suggestions(suggestions)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self): # type: ignore[override]
|
||||
suggestions: dict[str, Any] = self._manager.suggestions or {}
|
||||
count = self._count_applicable_suggestions(suggestions)
|
||||
applicable_keys = sorted(
|
||||
k for k in self._applicable_suggestion_keys()
|
||||
if isinstance(suggestions.get(k), dict) and suggestions[k].get("value") is not None
|
||||
)
|
||||
|
||||
attrs: dict[str, Any] = {
|
||||
"has_actionable_suggestions": count > 0,
|
||||
"suggestions_count": count,
|
||||
"suggested_option_keys": applicable_keys,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
return attrs
|
||||
|
||||
|
||||
class PumpRunsTodaySensor(WasherBaseSensor):
|
||||
"""Sensor reporting how many pump cycles occurred in the last 24 hours.
|
||||
|
||||
Only created when device type is ``pump``.
|
||||
"""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="pump_runs_today",
|
||||
translation_key="pump_runs_today",
|
||||
icon="mdi:counter",
|
||||
native_unit_of_measurement="cycles",
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int: # type: ignore[override]
|
||||
return self._manager.pump_runs_today
|
||||
|
||||
|
||||
class WasherCycleCountSensor(WasherBaseSensor):
|
||||
"""Sensor reporting the total number of completed cycles stored for this device."""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
self.entity_description = SensorEntityDescription(
|
||||
key="cycle_count",
|
||||
translation_key="cycle_count",
|
||||
icon="mdi:counter",
|
||||
native_unit_of_measurement="cycles",
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int: # type: ignore[override]
|
||||
return self._manager.cycle_count
|
||||
@@ -0,0 +1,249 @@
|
||||
label_cycle:
|
||||
name: Label Cycle
|
||||
description: Assign an existing profile to a past cycle, or remove the label.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The WashData device to label.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: ha_washdata
|
||||
cycle_id:
|
||||
name: Cycle ID
|
||||
description: The ID of the cycle to label.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
profile_name:
|
||||
name: Profile Name
|
||||
description: The name of an existing profile (create profiles in Manage Profiles menu). Leave blank to remove label.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
create_profile:
|
||||
name: Create Profile
|
||||
description: Create a new profile (standalone or based on a reference cycle).
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The WashData device.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: ha_washdata
|
||||
profile_name:
|
||||
name: Profile Name
|
||||
description: Name for the new profile (e.g. "Heavy Duty", "Delicates").
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
reference_cycle_id:
|
||||
name: Reference Cycle ID
|
||||
description: Optional cycle ID to base this profile on.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
delete_profile:
|
||||
name: Delete Profile
|
||||
description: Delete a profile and optionally unlabel cycles using it.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The WashData device.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: ha_washdata
|
||||
profile_name:
|
||||
name: Profile Name
|
||||
description: The profile to delete.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
unlabel_cycles:
|
||||
name: Unlabel Cycles
|
||||
description: Remove profile label from cycles using this profile.
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
auto_label_cycles:
|
||||
name: Auto-Label Old Cycles
|
||||
description: Retroactively label unlabeled cycles using profile matching.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The WashData device.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: ha_washdata
|
||||
confidence_threshold:
|
||||
name: Confidence Threshold
|
||||
description: Minimum match confidence (0.50-0.95) to apply labels.
|
||||
required: false
|
||||
default: 0.70
|
||||
selector:
|
||||
number:
|
||||
min: 0.50
|
||||
max: 0.95
|
||||
step: 0.05
|
||||
|
||||
export_config:
|
||||
name: Export Config
|
||||
description: Export this washer's profiles and cycles to a JSON file (per device).
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The WashData device to export.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: ha_washdata
|
||||
path:
|
||||
name: Path
|
||||
description: Optional absolute file path to write (defaults to /config/ha_washdata_export_<entry>.json).
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
import_config:
|
||||
name: Import Config
|
||||
description: Import profiles and cycles for this washer from a JSON export file.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The WashData device to import into.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: ha_washdata
|
||||
path:
|
||||
name: Path
|
||||
description: Absolute path to the export JSON file to import.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
submit_cycle_feedback:
|
||||
name: Submit Cycle Feedback
|
||||
description: >
|
||||
Confirm or correct an auto-detected program after a completed cycle.
|
||||
Provide either `entry_id` (advanced) or `device_id` (recommended).
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The WashData device (recommended instead of entry_id).
|
||||
required: false
|
||||
selector:
|
||||
device:
|
||||
integration: ha_washdata
|
||||
entry_id:
|
||||
name: Entry ID
|
||||
description: The config entry id for the device (alternative to device_id).
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
cycle_id:
|
||||
name: Cycle ID
|
||||
description: The cycle id shown in the feedback notification / logs.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
user_confirmed:
|
||||
name: Confirm Detected Program
|
||||
description: Set true if the detected program is correct.
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
corrected_profile:
|
||||
name: Corrected Profile
|
||||
description: If not confirmed, provide the correct profile/program name.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
corrected_duration:
|
||||
name: Corrected Duration (seconds)
|
||||
description: Optional corrected duration in seconds.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 86400
|
||||
step: 1
|
||||
mode: box
|
||||
notes:
|
||||
name: Notes
|
||||
description: Optional notes about this cycle.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
record_start:
|
||||
name: Record Cycle Start
|
||||
description: Start manually recording a clean cycle (bypasses all matching logic).
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The WashData device to record.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: ha_washdata
|
||||
|
||||
record_stop:
|
||||
name: Record Cycle Stop
|
||||
description: Stop manual recording.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The WashData device to stop recording.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: ha_washdata
|
||||
|
||||
trim_cycle:
|
||||
name: Trim Cycle
|
||||
description: >
|
||||
Trim the power data of a past cycle to a specific time window.
|
||||
Offsets are renormalized to start from 0 and the cycle duration is updated.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The WashData device.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: ha_washdata
|
||||
cycle_id:
|
||||
name: Cycle ID
|
||||
description: The ID of the cycle to trim.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
trim_start_s:
|
||||
name: Trim Start (seconds)
|
||||
description: Keep data from this offset (seconds from cycle start). Default 0.
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 86400
|
||||
step: 1
|
||||
mode: box
|
||||
trim_end_s:
|
||||
name: Trim End (seconds)
|
||||
description: Keep data up to this offset (seconds from cycle start). Default = full duration.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 86400
|
||||
step: 1
|
||||
mode: box
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Signal processing primitives for WashData.
|
||||
|
||||
Constraint: NumPy only.
|
||||
Constraint: All computations must be dt-aware (robust to irregular cadence).
|
||||
Constraint: Resampling must be segment-based (no interpolation across gaps).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
"""A continuous metrics segment suitable for matching.
|
||||
|
||||
Attributes:
|
||||
timestamps: Uniformly spaced timestamps (seconds)
|
||||
power: Interpolated power values (Watts)
|
||||
mask: Boolean mask (True = valid, False = gap/invalid).
|
||||
In strict segmentation, typically all True, but support mask for partial validity.
|
||||
"""
|
||||
|
||||
timestamps: np.ndarray
|
||||
power: np.ndarray
|
||||
mask: np.ndarray
|
||||
# Future extensibility: might add other channels here
|
||||
|
||||
|
||||
def integrate_wh(timestamps: np.ndarray, power: np.ndarray) -> float:
|
||||
"""Compute energy in Wh using trapezoidal integration.
|
||||
|
||||
Args:
|
||||
timestamps: Array of timestamps in seconds.
|
||||
power: Array of power values in Watts.
|
||||
|
||||
Returns:
|
||||
Energy in Watt-hours.
|
||||
"""
|
||||
if len(timestamps) < 2:
|
||||
return 0.0
|
||||
|
||||
# Calculate dt in hours
|
||||
# np.diff(timestamps) is in seconds, divide by 3600 for hours
|
||||
dt_hours = np.diff(timestamps) / 3600.0
|
||||
|
||||
# Trapezoidal rule: (p[i] + p[i+1]) / 2 * dt
|
||||
avg_power = (power[:-1] + power[1:]) * 0.5
|
||||
|
||||
return float(np.sum(avg_power * dt_hours))
|
||||
|
||||
|
||||
def robust_smooth(
|
||||
power: np.ndarray, timestamps: np.ndarray, time_constant_s: float = 30.0
|
||||
) -> np.ndarray:
|
||||
"""Apply robust smoothing to power data.
|
||||
|
||||
Combines a median filter (spike rejection) with an Exponential Moving Average (EMA).
|
||||
EMA is calculated using time-weighted alpha to handle irregular jitter.
|
||||
|
||||
Args:
|
||||
power: Array of power values.
|
||||
timestamps: Array of timestamps in seconds.
|
||||
time_constant_s: EMA time constant in seconds.
|
||||
alpha = 1 - exp(-dt / time_constant)
|
||||
|
||||
Returns:
|
||||
Smoothed power array.
|
||||
"""
|
||||
if len(power) == 0:
|
||||
return np.array([])
|
||||
if len(power) < 3:
|
||||
return power.copy()
|
||||
|
||||
# 1. Median filter (3-point) using pure NumPy
|
||||
p_med = power.copy()
|
||||
|
||||
# Vectorized 3-point median: y[i] = median(x[i-1], x[i], x[i+1])
|
||||
# Edge handling: repeat values (first and last)
|
||||
if len(power) >= 3:
|
||||
# Pad with edge values
|
||||
p_padded = np.empty(len(power) + 2)
|
||||
p_padded[0] = power[0]
|
||||
p_padded[-1] = power[-1]
|
||||
p_padded[1:-1] = power
|
||||
|
||||
# Stack shifted views
|
||||
# Left neighbor: p_padded[0:-2] -> indices 0..N
|
||||
# Center: p_padded[1:-1] -> indices 1..N+1 (original)
|
||||
# Right neighbor: p_padded[2:] -> indices 2..N+2
|
||||
stack = np.vstack(
|
||||
[p_padded[0 : len(power)], p_padded[1 : len(power) + 1], p_padded[2:]]
|
||||
)
|
||||
|
||||
# Compute median down columns
|
||||
p_med = np.median(stack, axis=0)
|
||||
|
||||
# 2. Time-aware EMA
|
||||
# y[i] = alpha * x[i] + (1-alpha) * y[i-1]
|
||||
# alpha = 1 - exp(-dt / tau)
|
||||
|
||||
smoothed = np.zeros_like(p_med, dtype=float)
|
||||
smoothed[0] = p_med[0]
|
||||
|
||||
# We Iterate because alpha changes with dt.
|
||||
# Vectorization is possible but complex for IIR filter with variable coefs.
|
||||
# Python loop is fine for typical cycle lengths (points < 10k).
|
||||
|
||||
prev_y = p_med[0]
|
||||
prev_t = timestamps[0]
|
||||
|
||||
for i in range(1, len(p_med)):
|
||||
dt = timestamps[i] - prev_t
|
||||
if dt <= 0:
|
||||
# Duplicate or disorderly timestamp, just carry forward
|
||||
smoothed[i] = prev_y
|
||||
continue
|
||||
|
||||
current_val = p_med[i]
|
||||
|
||||
# Adaptive alpha based on dt
|
||||
alpha = 1.0 - np.exp(-dt / time_constant_s)
|
||||
|
||||
# Apply EMA
|
||||
y = alpha * current_val + (1.0 - alpha) * prev_y
|
||||
|
||||
smoothed[i] = y
|
||||
prev_y = y
|
||||
prev_t = timestamps[i]
|
||||
|
||||
return smoothed
|
||||
|
||||
|
||||
def resample_uniform(
|
||||
timestamps: np.ndarray, power: np.ndarray, dt_s: float = 5.0, gap_s: float = 60.0
|
||||
) -> List[Segment]:
|
||||
"""Resample irregularly sampled data onto a uniform grid, respecting gaps.
|
||||
|
||||
Returns a LIST of Segments. Does NOT interpolate across gaps > gap_s.
|
||||
|
||||
Args:
|
||||
timestamps: Raw timestamps (seconds).
|
||||
power: Raw power values.
|
||||
dt_s: Target uniform step size (seconds).
|
||||
gap_s: Max gap to interpolate across (seconds).
|
||||
|
||||
Returns:
|
||||
List of Segment objects.
|
||||
"""
|
||||
if len(timestamps) < 2:
|
||||
return []
|
||||
|
||||
segments: List[Segment] = []
|
||||
|
||||
# Find indices where dt > gap_s
|
||||
diffs = np.diff(timestamps)
|
||||
break_indices = np.where(diffs > gap_s)[0] + 1
|
||||
|
||||
# Add start and end indices
|
||||
start_indices = np.concatenate(([0], break_indices))
|
||||
end_indices = np.concatenate((break_indices, [len(timestamps)]))
|
||||
|
||||
for start_idx, end_idx in zip(start_indices, end_indices):
|
||||
chunk_ts = timestamps[start_idx:end_idx]
|
||||
chunk_p = power[start_idx:end_idx]
|
||||
|
||||
if len(chunk_ts) < 2:
|
||||
continue
|
||||
|
||||
# Define uniform grid for this chunk
|
||||
# Define uniform grid for this chunk (start at first timestamp)
|
||||
# Simple approach: start at t[0], go to t[-1] stepping by dt_s
|
||||
|
||||
grid_start = chunk_ts[0]
|
||||
grid_end = chunk_ts[-1]
|
||||
|
||||
# Ensure at least two points
|
||||
if grid_end - grid_start < dt_s:
|
||||
continue
|
||||
|
||||
# arange(start, end + epsilon, dt)
|
||||
target_ts = np.arange(grid_start, grid_end + 0.001, dt_s)
|
||||
|
||||
# Use numpy interp (linear interpolation)
|
||||
# It's safe here because we know max gap < gap_s within this chunk
|
||||
interpolated_p = np.interp(target_ts, chunk_ts, chunk_p)
|
||||
|
||||
segments.append(
|
||||
Segment(
|
||||
timestamps=target_ts,
|
||||
power=interpolated_p,
|
||||
mask=np.ones_like(target_ts, dtype=bool),
|
||||
)
|
||||
)
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def resample_adaptive(
|
||||
timestamps: np.ndarray,
|
||||
power: np.ndarray,
|
||||
min_dt: float = 5.0,
|
||||
gap_s: float = 300.0,
|
||||
) -> Tuple[List[Segment], float]:
|
||||
"""Resample data using an adaptive time step based on input cadence.
|
||||
|
||||
Target dt is based on observed cadence with a lower bound:
|
||||
``target_dt = max(min_dt, median_interval)``.
|
||||
- If data is dense (for example 1s), it is downsampled to ``min_dt``.
|
||||
- If data is sparse (for example 30s), cadence is preserved.
|
||||
|
||||
Args:
|
||||
timestamps: Raw timestamps (seconds).
|
||||
power: Raw power values.
|
||||
min_dt: Minimum allowed dt (seconds).
|
||||
gap_s: Max gap to interpolate across.
|
||||
|
||||
Returns:
|
||||
Tuple of ``(segments, used_dt_s)`` where ``segments`` are gap-aware,
|
||||
uniformly sampled chunks and ``used_dt_s`` is the chosen target step.
|
||||
"""
|
||||
if len(timestamps) < 2:
|
||||
return [], min_dt
|
||||
|
||||
# Determine cadence
|
||||
diffs = np.diff(timestamps)
|
||||
# Filter strictly zero diffs (duplicates)
|
||||
valid_diffs = diffs[diffs > 0.001]
|
||||
|
||||
if len(valid_diffs) == 0:
|
||||
median_dt = min_dt
|
||||
else:
|
||||
median_dt = float(np.median(valid_diffs))
|
||||
|
||||
# Logic: Never resample finer than sensor (median_dt).
|
||||
# Also enforce min_dt (don't go finer than 5s).
|
||||
# We ignore max_dt for clamping down, to respect "never finer" rule.
|
||||
min_dt = max(min_dt, 1e-3) # Guard against non-positive step
|
||||
target_dt = max(min_dt, median_dt)
|
||||
gap_s = max(gap_s, target_dt * 1.5, 1e-3) # Guard against non-positive gap
|
||||
|
||||
# Delegate to uniform resampler with chosen dt
|
||||
segments = resample_uniform(timestamps, power, dt_s=target_dt, gap_s=gap_s)
|
||||
|
||||
return segments, target_dt
|
||||
|
||||
|
||||
def estimate_idle_baseline(power: np.ndarray) -> Tuple[float, float]:
|
||||
"""Estimate idle baseline level using robust statistics.
|
||||
|
||||
Args:
|
||||
power: Power samples (ideally from a period known or suspected to be IDLE/lower).
|
||||
If mixed data is passed, the median might be biased if active time > idle time.
|
||||
|
||||
Returns:
|
||||
(baseline_median, baseline_mad)
|
||||
"""
|
||||
if len(power) == 0:
|
||||
return 0.0, 0.0
|
||||
|
||||
median = float(np.median(power))
|
||||
# Median Absolute Deviation
|
||||
mad = float(np.median(np.abs(power - median)))
|
||||
|
||||
return median, mad
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
"""Suggestion engine for WashData."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, TYPE_CHECKING, cast
|
||||
|
||||
import numpy as np
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
CONF_WATCHDOG_INTERVAL,
|
||||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||||
CONF_OFF_DELAY,
|
||||
CONF_PROFILE_MATCH_INTERVAL,
|
||||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
CONF_DURATION_TOLERANCE,
|
||||
CONF_PROFILE_DURATION_TOLERANCE,
|
||||
CONF_START_THRESHOLD_W,
|
||||
CONF_STOP_THRESHOLD_W,
|
||||
CONF_END_ENERGY_THRESHOLD,
|
||||
CONF_RUNNING_DEAD_ZONE,
|
||||
CONF_MIN_OFF_GAP,
|
||||
DEFAULT_OFF_DELAY_BY_DEVICE,
|
||||
DEFAULT_OFF_DELAY,
|
||||
DEFAULT_MIN_OFF_GAP_BY_DEVICE,
|
||||
DEFAULT_MIN_OFF_GAP,
|
||||
)
|
||||
from .time_utils import power_data_to_offsets
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .profile_store import ProfileStore
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_ts(v: Any) -> float | None:
|
||||
"""Parse a value into a unix timestamp float, supporting ISO strings."""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return datetime.fromisoformat(v.replace("Z", "+00:00")).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class SuggestionEngine:
|
||||
"""Refined engine for generating data-driven parameter suggestions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry_id: str,
|
||||
profile_store: "ProfileStore",
|
||||
device_type: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the suggestion engine."""
|
||||
self.hass = hass
|
||||
self.entry_id = entry_id
|
||||
self.profile_store = profile_store
|
||||
self.device_type = device_type
|
||||
|
||||
def generate_operational_suggestions(self, p95_dt: float, median_dt: float) -> dict[str, Any]:
|
||||
"""Generate suggestions for operational parameters based on cadence."""
|
||||
suggestions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# 1. Watchdog Interval
|
||||
suggested_watchdog = int(max(30, p95_dt * 10))
|
||||
suggestions[CONF_WATCHDOG_INTERVAL] = {
|
||||
"value": suggested_watchdog,
|
||||
"reason": f"Based on observed update cadence (p95={p95_dt:.1f}s) * 10 (min 30s buffer)."
|
||||
}
|
||||
|
||||
# 2. No Update Timeout
|
||||
suggested_timeout = int(max(60, p95_dt * 20))
|
||||
suggestions[CONF_NO_UPDATE_ACTIVE_TIMEOUT] = {
|
||||
"value": suggested_timeout,
|
||||
"reason": f"Based on observed update cadence (p95={p95_dt:.1f}s) * 20 (min 60s)."
|
||||
}
|
||||
|
||||
# 3. Off Delay
|
||||
# Use device-specific default as floor to prevent splitting cycles with long pauses
|
||||
device_floor = (
|
||||
DEFAULT_OFF_DELAY_BY_DEVICE.get(self.device_type, DEFAULT_OFF_DELAY)
|
||||
if self.device_type is not None
|
||||
else DEFAULT_OFF_DELAY
|
||||
)
|
||||
suggested_off_delay = int(max(device_floor, p95_dt * 5))
|
||||
|
||||
reason_off = f"Based on observed update cadence (p95={p95_dt:.1f}s) * 5"
|
||||
if suggested_off_delay == device_floor:
|
||||
if self.device_type and self.device_type in DEFAULT_OFF_DELAY_BY_DEVICE:
|
||||
reason_off = (
|
||||
f"Used device-specific safe minimum for {self.device_type} ({device_floor}s)."
|
||||
)
|
||||
else:
|
||||
reason_off = f"Used generic safe minimum ({DEFAULT_OFF_DELAY}s)."
|
||||
|
||||
suggestions[CONF_OFF_DELAY] = {
|
||||
"value": suggested_off_delay,
|
||||
"reason": reason_off
|
||||
}
|
||||
|
||||
# 4. Profile Match Interval
|
||||
suggested_match = int(max(10, median_dt * 10))
|
||||
suggestions[CONF_PROFILE_MATCH_INTERVAL] = {
|
||||
"value": suggested_match,
|
||||
"reason": f"Based on observed update cadence (median={median_dt:.1f}s) * 10."
|
||||
}
|
||||
|
||||
return suggestions
|
||||
|
||||
def generate_model_suggestions(self) -> dict[str, Any]:
|
||||
"""Generate suggestions for model parameters based on past cycles."""
|
||||
suggestions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
cycles = self.profile_store.get_past_cycles()[-100:]
|
||||
profiles = self.profile_store.get_profiles()
|
||||
|
||||
ratios: list[float] = []
|
||||
for c in cycles:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
profile_name = c.get("profile_name")
|
||||
if not isinstance(profile_name, str) or c.get("status") == "interrupted":
|
||||
continue
|
||||
prof = profiles.get(profile_name)
|
||||
if not isinstance(prof, dict):
|
||||
continue
|
||||
try:
|
||||
avg = float(prof.get("avg_duration") or 0.0)
|
||||
dur = float(c.get("duration") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if avg > 60 and dur > 60:
|
||||
ratios.append(dur / avg)
|
||||
|
||||
if len(ratios) >= 10:
|
||||
arr: np.ndarray[Any, np.dtype[np.float64]] = np.array(ratios, dtype=float)
|
||||
deviations = np.abs(arr - 1.0)
|
||||
p95_dev = float(np.percentile(deviations, 95))
|
||||
|
||||
suggested_tol = min(0.50, max(0.10, round(p95_dev + 0.05, 2)))
|
||||
reason_tol = f"Based on duration variance of {len(ratios)} recent labeled cycles (p95 dev={p95_dev:.2f})."
|
||||
|
||||
suggestions[CONF_DURATION_TOLERANCE] = {"value": suggested_tol, "reason": reason_tol}
|
||||
suggestions[CONF_PROFILE_DURATION_TOLERANCE] = {"value": suggested_tol, "reason": reason_tol}
|
||||
|
||||
p05_ratio = float(np.percentile(arr, 5))
|
||||
p95_ratio = float(np.percentile(arr, 95))
|
||||
|
||||
min_r = max(0.1, round(p05_ratio - 0.1, 2))
|
||||
max_r = min(3.0, round(p95_ratio + 0.1, 2))
|
||||
|
||||
if min_r < max_r - 0.2:
|
||||
suggestions[CONF_PROFILE_MATCH_MIN_DURATION_RATIO] = {
|
||||
"value": min_r,
|
||||
"reason": f"Based on labeled cycle durations (p05={p05_ratio:.2f})."
|
||||
}
|
||||
suggestions[CONF_PROFILE_MATCH_MAX_DURATION_RATIO] = {
|
||||
"value": max_r,
|
||||
"reason": f"Based on labeled cycle durations (p95={p95_ratio:.2f})."
|
||||
}
|
||||
|
||||
# Min-off-gap: derived from observed inter-cycle gaps
|
||||
min_off_gap = self._suggest_min_off_gap(cycles)
|
||||
if min_off_gap is not None:
|
||||
suggestions[CONF_MIN_OFF_GAP] = min_off_gap
|
||||
|
||||
return suggestions
|
||||
|
||||
def _suggest_min_off_gap(
|
||||
self, cycles: list[dict[str, Any]]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Derive a min_off_gap suggestion from observed inter-cycle gaps."""
|
||||
# Only consider completed, labeled cycles with valid timestamps
|
||||
timed_cycles: list[tuple[float, float]] = []
|
||||
for c in cycles:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
if c.get("status") not in ("completed", "force_stopped"):
|
||||
continue
|
||||
label = c.get("profile_name") or c.get("label")
|
||||
if not label or label == "noise":
|
||||
continue
|
||||
try:
|
||||
start = float(c["start_time"]) if isinstance(c.get("start_time"), (int, float)) and not isinstance(c.get("start_time"), bool) else None
|
||||
end = float(c["end_time"]) if isinstance(c.get("end_time"), (int, float)) and not isinstance(c.get("end_time"), bool) else None
|
||||
if start is None or end is None:
|
||||
# Try ISO string parsing
|
||||
start = _parse_ts(c.get("start_time"))
|
||||
end = _parse_ts(c.get("end_time"))
|
||||
if start is None or end is None or end <= start:
|
||||
continue
|
||||
timed_cycles.append((start, end))
|
||||
except (TypeError, ValueError, KeyError):
|
||||
continue
|
||||
|
||||
if len(timed_cycles) < 3:
|
||||
return None
|
||||
|
||||
timed_cycles.sort(key=lambda x: x[0])
|
||||
gaps: list[float] = []
|
||||
for i in range(1, len(timed_cycles)):
|
||||
gap = timed_cycles[i][0] - timed_cycles[i - 1][1]
|
||||
if 30 <= gap <= 86400: # Only gaps between 30s and 1 day
|
||||
gaps.append(gap)
|
||||
|
||||
if len(gaps) < 3:
|
||||
return None
|
||||
|
||||
gaps_arr = np.array(gaps)
|
||||
# Use the 5th-percentile gap as the safe minimum, with device-type floor
|
||||
p05_gap = float(np.percentile(gaps_arr, 5))
|
||||
device_floor = (
|
||||
DEFAULT_MIN_OFF_GAP_BY_DEVICE.get(self.device_type, DEFAULT_MIN_OFF_GAP)
|
||||
if self.device_type is not None
|
||||
else DEFAULT_MIN_OFF_GAP
|
||||
)
|
||||
# Add a 20% safety margin so we never split a real gap into two cycles
|
||||
suggested = int(max(device_floor, min(p05_gap * 0.8, 3600)))
|
||||
# When the data-derived value is equal to the device floor, we have no
|
||||
# useful signal to surface — return None to suppress a misleading suggestion.
|
||||
if suggested == device_floor:
|
||||
return None
|
||||
reason = (
|
||||
f"Based on {len(gaps)} observed inter-cycle gaps "
|
||||
f"(p05={p05_gap:.0f}s). Device floor: {device_floor}s."
|
||||
)
|
||||
return {"value": suggested, "reason": reason}
|
||||
|
||||
def run_simulation(self, cycle_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Replay a single cycle with varied parameters to find optimal settings.
|
||||
|
||||
For richer, multi-cycle suggestions use :meth:`run_batch_simulation`.
|
||||
"""
|
||||
power_data_raw: Any = cycle_data.get("power_data", [])
|
||||
if not isinstance(power_data_raw, list):
|
||||
return {}
|
||||
power_data = cast(list[list[float] | tuple[Any, float]], power_data_raw)
|
||||
if len(power_data) < 10:
|
||||
return {}
|
||||
|
||||
start_time_raw = cycle_data.get("start_time")
|
||||
start_time_iso = (
|
||||
start_time_raw if isinstance(start_time_raw, str) and start_time_raw else None
|
||||
)
|
||||
|
||||
# Normalise power_data to [[offset_sec, power], ...] regardless of source format.
|
||||
readings_list = power_data_to_offsets(power_data, start_time_iso)
|
||||
|
||||
readings: list[tuple[float, float]] = [
|
||||
(float(offset), float(power)) for offset, power in readings_list
|
||||
]
|
||||
|
||||
if not readings:
|
||||
return {}
|
||||
|
||||
powers = np.array([p[1] for p in readings])
|
||||
active_powers = powers[powers > 0.5]
|
||||
|
||||
if len(active_powers) < 5:
|
||||
return {}
|
||||
|
||||
min_active = float(np.min(active_powers))
|
||||
|
||||
suggested_stop = round(min_active * 0.8, 2)
|
||||
suggested_start = round(min_active * 1.2, 2)
|
||||
|
||||
# Energy suggestions
|
||||
suggested_end_energy = 0.05
|
||||
|
||||
# Dead zone: look for early dips in the first 5 minutes
|
||||
dead_zone = 0
|
||||
for ts_offset, p in readings:
|
||||
elapsed = ts_offset
|
||||
if elapsed > 300:
|
||||
break
|
||||
if p < 5.0 and elapsed > 5.0:
|
||||
dead_zone = int(elapsed)
|
||||
|
||||
suggested_dead_zone = min(300, dead_zone) if dead_zone > 0 else 60
|
||||
|
||||
return {
|
||||
CONF_STOP_THRESHOLD_W: {
|
||||
"value": suggested_stop,
|
||||
"reason": f"Based on minimum active power ({min_active:.1f}W) observed in last cycle."
|
||||
},
|
||||
CONF_START_THRESHOLD_W: {
|
||||
"value": suggested_start,
|
||||
"reason": f"Based on minimum active power ({min_active:.1f}W) observed in last cycle."
|
||||
},
|
||||
CONF_END_ENERGY_THRESHOLD: {
|
||||
"value": suggested_end_energy,
|
||||
"reason": "Default recommended baseline for end-of-cycle noise gate."
|
||||
},
|
||||
CONF_RUNNING_DEAD_ZONE: {
|
||||
"value": suggested_dead_zone,
|
||||
"reason": f"Based on early power dip detected at {suggested_dead_zone}s."
|
||||
},
|
||||
}
|
||||
|
||||
def run_batch_simulation(self, cycles: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Derive parameter suggestions from a collection of labeled cycles.
|
||||
|
||||
Unlike :meth:`run_simulation` (single-cycle heuristics), this method
|
||||
aggregates statistics across *multiple* cycles for robustness:
|
||||
|
||||
- Power thresholds from the 5th-percentile minimum active power.
|
||||
- Dead zone from the 75th-percentile of early dips across cycles.
|
||||
- End-energy threshold from the maximum false-end energy seen.
|
||||
- Min-off-gap from the 5th-percentile inter-cycle gap.
|
||||
|
||||
Returns an empty dict when fewer than ``_BATCH_MIN_CYCLES`` valid
|
||||
cycles are provided.
|
||||
"""
|
||||
_BATCH_MIN_CYCLES = 5
|
||||
|
||||
valid_cycles: list[list[tuple[float, float]]] = []
|
||||
for c in cycles:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
label = c.get("label") or c.get("profile_name")
|
||||
if not isinstance(label, str) or not label:
|
||||
continue
|
||||
if label.lower() == "noise":
|
||||
continue
|
||||
if not (
|
||||
c.get("state") == "completed"
|
||||
or c.get("status") in ("completed", "force_stopped")
|
||||
):
|
||||
continue
|
||||
raw = c.get("power_data")
|
||||
if not isinstance(raw, list) or len(raw) < 5:
|
||||
continue
|
||||
start_iso = c.get("start_time") if isinstance(c.get("start_time"), str) else None
|
||||
readings_list = power_data_to_offsets(
|
||||
cast(list[list[float] | tuple[Any, float]], raw), start_iso
|
||||
)
|
||||
readings = [(float(o), float(p)) for o, p in readings_list]
|
||||
if len(readings) >= 5:
|
||||
valid_cycles.append(readings)
|
||||
|
||||
if len(valid_cycles) < _BATCH_MIN_CYCLES:
|
||||
return {}
|
||||
|
||||
# --- Power thresholds ---
|
||||
lowest_active: list[float] = []
|
||||
false_end_energies: list[float] = []
|
||||
dead_zone_candidates: list[int] = []
|
||||
|
||||
_MAX_PAUSE_GAP_H = 1.0
|
||||
max_gap_s = _MAX_PAUSE_GAP_H * 3600
|
||||
for readings in valid_cycles:
|
||||
powers = np.array([p for _, p in readings])
|
||||
active = powers[powers > 0.5]
|
||||
if len(active) > 0:
|
||||
lowest_active.append(float(np.min(active)))
|
||||
|
||||
# Dead zone: first dip below 5 W within the first 5 minutes
|
||||
for ts_offset, p in readings:
|
||||
if ts_offset > 300:
|
||||
break
|
||||
if p < 5.0 and ts_offset > 5.0:
|
||||
dead_zone_candidates.append(int(ts_offset))
|
||||
break
|
||||
|
||||
# False-end energies: low-power segments that resumed
|
||||
in_pause = False
|
||||
pause_energy = 0.0
|
||||
stop_w = 2.0
|
||||
for i in range(1, len(readings)):
|
||||
t0, p0 = readings[i - 1]
|
||||
t1, p1 = readings[i]
|
||||
dt_s = t1 - t0
|
||||
# Guard against non-positive or excessively large time gaps
|
||||
if dt_s <= 0 or dt_s > max_gap_s:
|
||||
# Skip this interval and reset pause state
|
||||
in_pause = False
|
||||
pause_energy = 0.0
|
||||
continue
|
||||
avg_p = (p0 + p1) / 2.0
|
||||
dt_h = dt_s / 3600.0
|
||||
if avg_p < stop_w:
|
||||
if not in_pause:
|
||||
in_pause = True
|
||||
pause_energy = 0.0
|
||||
pause_energy += avg_p * dt_h
|
||||
elif in_pause:
|
||||
false_end_energies.append(pause_energy)
|
||||
in_pause = False
|
||||
|
||||
suggestions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
if lowest_active:
|
||||
p05_min = float(np.percentile(lowest_active, 5))
|
||||
suggested_stop = round(p05_min * 0.8, 2)
|
||||
suggested_start = round(max(suggested_stop + 0.1, p05_min * 1.2), 2)
|
||||
n = len(lowest_active)
|
||||
suggestions[CONF_STOP_THRESHOLD_W] = {
|
||||
"value": suggested_stop,
|
||||
"reason": (
|
||||
f"Based on p05 of minimum active power across {n} cycles "
|
||||
f"({p05_min:.1f}W)."
|
||||
),
|
||||
}
|
||||
suggestions[CONF_START_THRESHOLD_W] = {
|
||||
"value": suggested_start,
|
||||
"reason": (
|
||||
f"Based on p05 of minimum active power across {n} cycles "
|
||||
f"({p05_min:.1f}W)."
|
||||
),
|
||||
}
|
||||
|
||||
if false_end_energies:
|
||||
max_false = float(np.max(false_end_energies))
|
||||
suggested_end = round(max(0.05, max_false * 1.2), 4)
|
||||
else:
|
||||
suggested_end = 0.05
|
||||
suggestions[CONF_END_ENERGY_THRESHOLD] = {
|
||||
"value": suggested_end,
|
||||
"reason": (
|
||||
f"Based on maximum false-end energy "
|
||||
f"({float(np.max(false_end_energies)) if false_end_energies else 0:.4f}Wh) "
|
||||
f"across {len(valid_cycles)} cycles."
|
||||
),
|
||||
}
|
||||
|
||||
if dead_zone_candidates:
|
||||
# Use the 75th percentile to cover most cycles without being overly generous
|
||||
p75_dz = int(np.percentile(dead_zone_candidates, 75))
|
||||
suggested_dz = min(300, p75_dz)
|
||||
suggestions[CONF_RUNNING_DEAD_ZONE] = {
|
||||
"value": suggested_dz,
|
||||
"reason": (
|
||||
f"Based on p75 of early power dips across "
|
||||
f"{len(dead_zone_candidates)} cycles ({suggested_dz}s)."
|
||||
),
|
||||
}
|
||||
|
||||
min_off_gap = self._suggest_min_off_gap(cycles)
|
||||
if min_off_gap is not None:
|
||||
suggestions[CONF_MIN_OFF_GAP] = min_off_gap
|
||||
|
||||
return suggestions
|
||||
|
||||
def apply_suggestions(self, suggestions: dict[str, Any]) -> None:
|
||||
"""Persist suggestions to the profile store."""
|
||||
for key, data in suggestions.items():
|
||||
self.profile_store.set_suggestion(key, data["value"], reason=data["reason"])
|
||||
|
||||
if self.hass and suggestions:
|
||||
self.hass.async_create_task(self.profile_store.async_save())
|
||||
@@ -0,0 +1,275 @@
|
||||
"""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
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user