354 lines
14 KiB
Python
354 lines
14 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/>.
|
|
"""Home Assistant conversation intents for WashData.
|
|
|
|
Lets users ask Assist (voice or text) natural-language questions about their
|
|
appliances and get a plain-language spoken/text answer derived from the live
|
|
manager/sensor state, for example:
|
|
|
|
"Is my washer done?"
|
|
"How long until the dryer finishes?"
|
|
|
|
The single intent registered here is :data:`INTENT_STATUS`
|
|
(``"HaWashdataStatus"``), handled by :class:`WashDataStatusIntentHandler`.
|
|
|
|
Wiring trigger sentences
|
|
------------------------
|
|
Registering the :class:`intent.IntentHandler` (via :func:`async_setup_intents`)
|
|
makes the intent handleable — it can be fired immediately from automations, the
|
|
``intent_script`` integration, developer tools, or the Assist pipeline **once a
|
|
sentence maps text to it**. Home Assistant has no public runtime API for a
|
|
*custom* integration to inject sentences into the built-in conversation agent,
|
|
so trigger sentences are wired by the user with a config-directory sentence pack.
|
|
A ready-to-use pack ships in the repo at ``docs/custom_sentences/en/ha_washdata.yaml``
|
|
- copy it to ``<config>/custom_sentences/en/ha_washdata.yaml`` and restart HA. The
|
|
minimal shape is (one file per language)::
|
|
|
|
language: en
|
|
intents:
|
|
HaWashdataStatus:
|
|
data:
|
|
- sentences:
|
|
- "is my {name} done"
|
|
- "is the {name} finished"
|
|
- "how long until the {name} finishes"
|
|
- "how long is left on the {name}"
|
|
- sentences:
|
|
- "is the laundry done"
|
|
- "how long until it finishes"
|
|
lists:
|
|
name:
|
|
wildcard: true
|
|
|
|
The optional ``{name}`` slot disambiguates which appliance when more than one is
|
|
configured. Users who prefer templated responses instead of the handler can also
|
|
declare the same intent via the ``intent_script`` integration.
|
|
|
|
This module has no import-time side effects: it only defines constants, helpers
|
|
and the handler class. Registration happens when :func:`async_setup_intents` is
|
|
called from ``async_setup_entry`` (guarded to run once per HA instance).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers import config_validation as cv, intent
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
from .const import (
|
|
DOMAIN,
|
|
STATE_ANTI_WRINKLE,
|
|
STATE_CLEAN,
|
|
STATE_ENDING,
|
|
STATE_FINISHED,
|
|
STATE_PAUSED,
|
|
STATE_RINSE,
|
|
STATE_RUNNING,
|
|
STATE_STARTING,
|
|
STATE_USER_PAUSED,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# Unique, stable intent identifier. Referenced by sentence packs / automations.
|
|
INTENT_STATUS = "HaWashdataStatus"
|
|
|
|
# States where the appliance is actively working on a cycle.
|
|
_ACTIVE_STATES = frozenset(
|
|
{
|
|
STATE_RUNNING,
|
|
STATE_STARTING,
|
|
STATE_ENDING,
|
|
STATE_PAUSED,
|
|
STATE_USER_PAUSED,
|
|
STATE_RINSE,
|
|
STATE_ANTI_WRINKLE,
|
|
}
|
|
)
|
|
|
|
# States that mean a cycle just completed (laundry/dishes ready).
|
|
_FINISHED_STATES = frozenset({STATE_FINISHED, STATE_CLEAN})
|
|
|
|
# How far back a completed cycle still counts as "finished recently" (minutes).
|
|
_RECENT_FINISH_WINDOW_MIN = 720 # 12 hours
|
|
|
|
# English fallback response templates. The canonical translatable copies live in
|
|
# strings.json / translations/en.json under the top-level "intent" section and
|
|
# are loaded on top of these by _localized_templates(). Kept here so the handler
|
|
# always works even if the translation cache is unavailable.
|
|
DEFAULT_TEMPLATES: dict[str, str] = {
|
|
"running_with_estimate": "Your {device} is still running. About {minutes} minutes left.",
|
|
"running_no_estimate": "Your {device} is still running.",
|
|
"finished_recently": "Your {device} finished {minutes} minutes ago.",
|
|
"just_finished": "Your {device} just finished.",
|
|
"not_running": "Your {device} is not running.",
|
|
"no_devices": "I couldn't find any WashData appliances.",
|
|
"unknown_device": "I couldn't find a WashData appliance called {device}.",
|
|
"none_running": "None of your WashData appliances are running.",
|
|
"error": "Sorry, I couldn't check your appliances right now.",
|
|
}
|
|
|
|
|
|
def _minutes_from_seconds(seconds: Any) -> int | None:
|
|
"""Return whole minutes (>=1) from a seconds value, or None when unusable."""
|
|
try:
|
|
value = float(seconds)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if value <= 0:
|
|
return None
|
|
return max(1, int(round(value / 60.0)))
|
|
|
|
|
|
def _minutes_since(end: Any, now: datetime) -> int | None:
|
|
"""Return whole minutes elapsed since ``end`` (clamped at 0), or None."""
|
|
if not isinstance(end, datetime):
|
|
return None
|
|
try:
|
|
delta = (now - end).total_seconds()
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if delta < 0:
|
|
return 0
|
|
return int(round(delta / 60.0))
|
|
|
|
|
|
def _iter_managers(hass: HomeAssistant) -> list[tuple[str, Any]]:
|
|
"""Return (title, manager) pairs for every loaded WashData device."""
|
|
result: list[tuple[str, Any]] = []
|
|
domain_data = hass.data.get(DOMAIN) or {}
|
|
for manager in domain_data.values():
|
|
# hass.data[DOMAIN] maps entry_id -> manager; skip anything unexpected.
|
|
if not hasattr(manager, "check_state"):
|
|
continue
|
|
entry = getattr(manager, "config_entry", None)
|
|
title = getattr(entry, "title", None)
|
|
result.append((title or "appliance", manager))
|
|
return result
|
|
|
|
|
|
def _is_running(manager: Any) -> bool:
|
|
"""Return True when the manager reports an active-cycle state."""
|
|
try:
|
|
return manager.check_state() in _ACTIVE_STATES
|
|
except Exception: # noqa: BLE001 - never let intent handling raise
|
|
return False
|
|
|
|
|
|
def _describe_device(
|
|
title: str, manager: Any, templates: dict[str, str], now: datetime
|
|
) -> str:
|
|
"""Build a one-sentence plain-language status line for a single device."""
|
|
device = title or "appliance"
|
|
try:
|
|
state = manager.check_state()
|
|
except Exception: # noqa: BLE001
|
|
state = None
|
|
|
|
if state in _ACTIVE_STATES:
|
|
minutes = _minutes_from_seconds(getattr(manager, "time_remaining", None))
|
|
if minutes:
|
|
return templates["running_with_estimate"].format(
|
|
device=device, minutes=minutes
|
|
)
|
|
return templates["running_no_estimate"].format(device=device)
|
|
|
|
# Not actively running: mention a recent finish when we can.
|
|
ago = _minutes_since(getattr(manager, "last_cycle_end_time", None), now)
|
|
|
|
if state in _FINISHED_STATES:
|
|
if ago and ago > 0:
|
|
return templates["finished_recently"].format(device=device, minutes=ago)
|
|
return templates["just_finished"].format(device=device)
|
|
|
|
# Off / idle / interrupted / unknown: only claim a finish if it was recent.
|
|
if ago is not None and 0 < ago <= _RECENT_FINISH_WINDOW_MIN:
|
|
return templates["finished_recently"].format(device=device, minutes=ago)
|
|
if ago == 0:
|
|
return templates["just_finished"].format(device=device)
|
|
return templates["not_running"].format(device=device)
|
|
|
|
|
|
def _build_speech(
|
|
hass: HomeAssistant,
|
|
name_slot: str | None,
|
|
templates: dict[str, str],
|
|
now: datetime,
|
|
) -> str:
|
|
"""Resolve the target device(s) and build the spoken answer."""
|
|
managers = _iter_managers(hass)
|
|
if not managers:
|
|
return templates["no_devices"]
|
|
|
|
needle = str(name_slot).strip().casefold() if name_slot else ""
|
|
if needle:
|
|
# Prefer an exact title, then a whole-word hit, then any substring, so
|
|
# "washer" resolves to "Laundry Washer" rather than "Dishwasher".
|
|
exact = [(t, m) for (t, m) in managers if t.casefold() == needle]
|
|
word = [(t, m) for (t, m) in managers if needle in t.casefold().split()]
|
|
sub = [(t, m) for (t, m) in managers if needle in t.casefold()]
|
|
picked = exact or word or sub
|
|
if not picked:
|
|
return templates["unknown_device"].format(device=name_slot)
|
|
title, manager = picked[0]
|
|
return _describe_device(title, manager, templates, now)
|
|
|
|
if len(managers) == 1:
|
|
title, manager = managers[0]
|
|
return _describe_device(title, manager, templates, now)
|
|
|
|
# Multiple devices, none specified: summarize the running ones.
|
|
running = [(t, m) for (t, m) in managers if _is_running(m)]
|
|
if not running:
|
|
return templates["none_running"]
|
|
return " ".join(
|
|
_describe_device(t, m, templates, now) for (t, m) in running
|
|
)
|
|
|
|
|
|
# Cache of loaded intent-response templates per language ({} when the file is
|
|
# absent/unreadable). Populated lazily by _load_intent_file.
|
|
_INTENT_TRANS_CACHE: dict[str, dict[str, str]] = {}
|
|
|
|
|
|
def _load_intent_file(language: str) -> dict[str, str]:
|
|
"""Load the ``HaWashdataStatus`` response templates for *language* (sync, cached).
|
|
|
|
These live in ``translations/intent/{lang}.json`` rather than the HA-layer
|
|
``translations/{lang}.json``: a top-level ``intent`` key is rejected by hassfest
|
|
(``extra keys not allowed``), so - exactly like the self-served panel translations
|
|
- the intent responses are kept in a sub-directory hassfest does not validate and
|
|
loaded directly. Returns ``{}`` on any failure. Never raises.
|
|
"""
|
|
if language in _INTENT_TRANS_CACHE:
|
|
return _INTENT_TRANS_CACHE[language]
|
|
# Sanitize before interpolating into a path: HA language tags are letters/digits/
|
|
# hyphen only, so reject anything else (defends against path traversal via a
|
|
# crafted language value).
|
|
if not re.fullmatch(r"[A-Za-z0-9_-]{1,20}", language or ""):
|
|
_INTENT_TRANS_CACHE[language] = {}
|
|
return {}
|
|
result: dict[str, str] = {}
|
|
try:
|
|
path = os.path.join(
|
|
os.path.dirname(__file__), "translations", "intent", f"{language}.json"
|
|
)
|
|
with open(path, encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
block = data.get(INTENT_STATUS) if isinstance(data, dict) else None
|
|
if isinstance(block, dict):
|
|
result = {k: v for k, v in block.items() if isinstance(v, str) and v}
|
|
except Exception: # noqa: BLE001 - missing/broken file -> English fallback
|
|
result = {}
|
|
_INTENT_TRANS_CACHE[language] = result
|
|
return result
|
|
|
|
|
|
async def _localized_templates(
|
|
hass: HomeAssistant, language: str | None
|
|
) -> dict[str, str]:
|
|
"""Return response templates, overlaying localized values onto the English base.
|
|
|
|
English base first, then the language's base subtag, then the full tag (so
|
|
``pt-BR`` overrides ``pt`` overrides ``en``). File reads are offloaded to the
|
|
executor when the hass supports it, with a synchronous fallback (minimal test
|
|
hass). Falls back to :data:`DEFAULT_TEMPLATES` on any failure.
|
|
"""
|
|
templates = dict(DEFAULT_TEMPLATES)
|
|
lang = language or "en"
|
|
order = list(dict.fromkeys(["en", lang.split("-")[0], lang]))
|
|
for lg in order:
|
|
if not lg:
|
|
continue
|
|
try:
|
|
loaded = await hass.async_add_executor_job(_load_intent_file, lg)
|
|
except Exception: # noqa: BLE001 - minimal test hass has no executor
|
|
loaded = _load_intent_file(lg)
|
|
for key, value in (loaded or {}).items():
|
|
if isinstance(value, str) and value:
|
|
templates[key] = value
|
|
return templates
|
|
|
|
|
|
class WashDataStatusIntentHandler(intent.IntentHandler):
|
|
"""Answer status questions ("is it done?", "how long left?") for a device."""
|
|
|
|
intent_type = INTENT_STATUS
|
|
description = (
|
|
"Report whether a WashData appliance is running and how long is left."
|
|
)
|
|
|
|
@property
|
|
def slot_schema(self) -> dict:
|
|
"""Optional appliance name to disambiguate which device is meant."""
|
|
return {vol.Optional("name"): cv.string}
|
|
|
|
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
|
"""Build a plain-language status response; never raises."""
|
|
response = intent_obj.create_response()
|
|
hass = intent_obj.hass
|
|
templates = await _localized_templates(hass, intent_obj.language)
|
|
try:
|
|
slots = intent_obj.slots or {}
|
|
name_value = slots.get("name", {}).get("value")
|
|
speech = _build_speech(hass, name_value, templates, dt_util.now())
|
|
except Exception: # noqa: BLE001 - graceful degradation, no exceptions escape
|
|
_LOGGER.exception("WashData status intent failed")
|
|
speech = templates.get("error", DEFAULT_TEMPLATES["error"])
|
|
response.async_set_speech(speech)
|
|
return response
|
|
|
|
|
|
@callback
|
|
def async_setup_intents(hass: HomeAssistant) -> None:
|
|
"""Register WashData conversation intents (domain-global, idempotent).
|
|
|
|
Call once per HA instance. ``intent.async_register`` overwrites an existing
|
|
handler of the same ``intent_type`` (with a warning), so callers guard with a
|
|
``hass.data`` flag to avoid re-registration on multi-entry setups.
|
|
"""
|
|
intent.async_register(hass, WashDataStatusIntentHandler())
|