This commit is contained in:
2026-06-14 02:01:49 -04:00
parent 16c1da4289
commit dcc2524785
132 changed files with 136000 additions and 4 deletions
+838
View File
@@ -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
+540
View File
@@ -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}
+144
View File
@@ -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
+461
View File
@@ -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(),
}
+249
View File
@@ -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]),
)
+223
View File
@@ -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
+791
View File
@@ -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
+317
View File
@@ -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)
+126
View File
@@ -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)
+864
View File
@@ -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
+249
View File
@@ -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())
+275
View File
@@ -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