997 lines
37 KiB
Python
997 lines
37 KiB
Python
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
|
|
# Copyright (C) 2026 Lukas Bandura
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published
|
|
# by the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
"""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.const import EntityCategory, UnitOfEnergy
|
|
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),
|
|
WasherEnergyTotalSensor(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),
|
|
WasherAmbiguitySensor(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 == "air_fryer":
|
|
return "mdi:pot-steam"
|
|
if dtype == "pump":
|
|
return "mdi:water-pump"
|
|
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
|
|
# Runtime anomaly signal (visible only; never a notification). Present the
|
|
# overrun ratio while a cycle is overrunning its usual duration so users /
|
|
# automations can react without a push.
|
|
anomaly = self._manager.cycle_anomaly
|
|
if anomaly and anomaly != "none":
|
|
attrs["cycle_anomaly"] = anomaly
|
|
attrs["overrun_ratio"] = round(self._manager.overrun_ratio, 2)
|
|
# Post-cycle anomaly data (underrun, energy spike/low) from last completed cycle.
|
|
last_post = self._manager.last_cycle_post_anomaly
|
|
if isinstance(last_post, dict):
|
|
if last_post.get("anomaly") == "underrun":
|
|
attrs["last_cycle_anomaly"] = "underrun"
|
|
if "underrun_ratio" in last_post:
|
|
attrs["last_cycle_underrun_ratio"] = last_post["underrun_ratio"]
|
|
if "energy_anomaly" in last_post:
|
|
attrs["last_cycle_energy_anomaly"] = last_post["energy_anomaly"]
|
|
if "energy_z_score" in last_post:
|
|
attrs["last_cycle_energy_z_score"] = last_post["energy_z_score"]
|
|
# Surface HA restart gaps recorded during the current cycle so automations
|
|
# can see that the power trace has holes (pure metadata, never a notification).
|
|
gaps = self._manager.restart_gaps
|
|
if gaps:
|
|
attrs["ha_restart_gaps"] = len(gaps)
|
|
# Predictive-maintenance reminders (E2): event types whose cycle threshold
|
|
# has been reached. Automatable by users; never a notification.
|
|
maintenance_due = self._manager.maintenance_due
|
|
if maintenance_due:
|
|
attrs["maintenance_due"] = maintenance_due
|
|
return attrs
|
|
|
|
|
|
class WasherProgramSensor(WasherBaseSensor):
|
|
"""Sensor for the current program."""
|
|
|
|
# The reference-profile curve is a live forecast for energy managers; it is
|
|
# static per profile and has no historical value, so keep it out of the
|
|
# recorder database (still available live via state/templates/WebSocket).
|
|
_unrecorded_attributes = frozenset({"reference_profile"})
|
|
|
|
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,
|
|
}
|
|
# Forward-looking reference power curve of the matched profile (issue
|
|
# #304): a compact `[[offset_s, watts], ...]` shape energy managers can
|
|
# slice by the live progress position to anticipate later load (e.g. a
|
|
# heating spike). Only present once a real profile is matched - the
|
|
# guard above already returns None while detecting/unmatched/off.
|
|
reference = self._manager.profile_store.reference_curve(profile_name)
|
|
if reference:
|
|
attrs["reference_profile"] = reference
|
|
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
|
|
|
|
@property
|
|
def extra_state_attributes(self): # type: ignore[override]
|
|
"""Expose the live projected total energy/cost for the running cycle.
|
|
|
|
Derived from accumulated energy and the (ML-blended) progress estimate.
|
|
Keys are present only while a projection is available, so the attributes
|
|
stay clean when idle or early in a cycle.
|
|
"""
|
|
attrs: dict[str, float] = {}
|
|
projected_wh = self._manager.projected_energy_wh
|
|
if projected_wh is not None:
|
|
attrs["projected_energy_kwh"] = round(float(projected_wh) / 1000.0, 3)
|
|
projected_cost = self._manager.projected_cost
|
|
if projected_cost is not None:
|
|
attrs["projected_cost"] = round(float(projected_cost), 2)
|
|
return attrs or None
|
|
|
|
|
|
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 WasherAmbiguitySensor(WasherBaseSensor):
|
|
"""Diagnostic sensor for how ambiguous the last profile match was.
|
|
|
|
Reports the score margin between the top-1 and top-2 candidates as a
|
|
percentage: a small margin means the matcher could not confidently
|
|
distinguish the best profile from the runner-up.
|
|
"""
|
|
|
|
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
|
self.entity_description = SensorEntityDescription(
|
|
key="ambiguity",
|
|
translation_key="ambiguity",
|
|
icon="mdi:help-rhombus-outline",
|
|
state_class=SensorStateClass.MEASUREMENT,
|
|
native_unit_of_measurement="%",
|
|
entity_category=EntityCategory.DIAGNOSTIC,
|
|
)
|
|
super().__init__(manager, entry)
|
|
|
|
@property
|
|
def native_value(self): # type: ignore[override]
|
|
margin = self._manager.last_ambiguity_margin
|
|
if margin is None:
|
|
return None
|
|
return round(float(margin) * 100, 1)
|
|
|
|
@property
|
|
def extra_state_attributes(self): # type: ignore[override]
|
|
return {"is_ambiguous": self._manager.match_ambiguity}
|
|
|
|
|
|
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,
|
|
# Average power (W) per 15-min slot of this profile's learned shape,
|
|
# e.g. [2200, 2200, 800, ...] - the flat array external planners such
|
|
# as tibber_prices' `power_profile` consume to pick the cheapest run
|
|
# window (issue #272). Empty until the profile has a learned envelope.
|
|
# Refreshes with the profile: the envelope is rebuilt on each new
|
|
# labelled cycle, which bumps this sensor's cycle_count state.
|
|
"power_profile": self._manager.profile_store.get_profile_power_profile(
|
|
self._profile_name
|
|
),
|
|
"power_profile_interval_min": 15,
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
class WasherEnergyTotalSensor(WasherBaseSensor):
|
|
"""Lifetime-accumulating energy meter for the HA Energy dashboard."""
|
|
|
|
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
|
self.entity_description = SensorEntityDescription(
|
|
key="energy_total",
|
|
translation_key="energy_total",
|
|
device_class=SensorDeviceClass.ENERGY,
|
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
|
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
|
suggested_display_precision=3,
|
|
icon="mdi:lightning-bolt",
|
|
)
|
|
super().__init__(manager, entry)
|
|
|
|
@property
|
|
def native_value(self) -> float: # type: ignore[override]
|
|
return self._manager.lifetime_energy_kwh |