# 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 . """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 ``/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())