Added Alexa Music

This commit is contained in:
2026-07-17 10:12:15 -04:00
parent 92c5268dc8
commit 28a8cb98f6
757 changed files with 151171 additions and 85450 deletions
+250 -25
View File
@@ -1,3 +1,19 @@
# 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/>.
"""The WashData integration."""
from __future__ import annotations
@@ -89,6 +105,7 @@ from .const import (
DEFAULT_COMPLETION_MIN_SECONDS,
DEFAULT_NOTIFY_BEFORE_END_MINUTES,
DEFAULT_DEVICE_TYPE,
DEVICE_TYPE_OTHER,
DEFAULT_START_DURATION_THRESHOLD,
CONF_START_DURATION_THRESHOLD,
)
@@ -125,7 +142,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
return False
if version == 3 and minor_version >= 5:
if version == 3 and minor_version >= 6:
return True
data: dict[str, Any] = dict(entry.data)
@@ -239,19 +256,86 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
):
options.pop(k, None)
# 3.6: the feedback/verify-cycle and ghost-cycle persistent notifications
# were removed (suggestions and pending reviews are surfaced in the panel),
# so the now-inert "suppress feedback notifications" toggle is stripped.
options.pop("suppress_feedback_notifications", None)
# 3.6: coffee_machine / ev / heat_pump / oven device types were removed.
# Remap any entry still on one of them to DEVICE_TYPE_OTHER (Threshold Device),
# preserving all tuned options so no user data is lost.
_removed_device_types = {"coffee_machine", "ev", "heat_pump", "oven"}
if (options.get(CONF_DEVICE_TYPE) or data.get(CONF_DEVICE_TYPE)) in _removed_device_types:
_log.info(
"Device type %r is no longer supported; migrating to %r (options preserved)",
options.get(CONF_DEVICE_TYPE), DEVICE_TYPE_OTHER,
)
options[CONF_DEVICE_TYPE] = DEVICE_TYPE_OTHER
# NB: CONF_DEVICE_TYPE was already popped from ``data`` above (keys_to_remove),
# so no stale removed value can linger there; the flow/manager read it from
# options (options-first).
hass.config_entries.async_update_entry(
entry,
data=data,
options=options,
version=3,
minor_version=5,
minor_version=6,
)
_log.info(
"Migrated WashData entry from version %s.%s to 3.5", version, minor_version
"Migrated WashData entry from version %s.%s to 3.6", version, minor_version
)
return True
async def _migrate_online_to_global(hass: HomeAssistant, entry: ConfigEntry, manager: Any) -> None:
"""Hoist the (formerly per-device) online-features flag + store account to the
integration-wide store. Pre-release cleanup.
The enable flag is hoisted exactly ONCE (guarded by a marker in the global store):
the stale per-entry option is never cleared, so without the marker a user who later
turns online off would have it silently re-enabled on the next restart. The account
hoist stays idempotent (it clears the per-entry copy after moving it)."""
from . import store_account # pylint: disable=import-outside-toplevel
from .const import CONF_ENABLE_ONLINE_FEATURES # pylint: disable=import-outside-toplevel
# Best-effort, pre-release migration: a transient store write failure here must
# never propagate and abort async_setup_entry (it retries on the next restart).
try:
await store_account.async_load(hass)
if not store_account.migration_done(hass):
any_on = any(
e.options.get(CONF_ENABLE_ONLINE_FEATURES)
for e in hass.config_entries.async_entries(DOMAIN)
)
if any_on and not store_account.online_enabled(hass):
await store_account.async_set_online(hass, True)
await store_account.async_mark_migrated(hass)
except Exception: # pylint: disable=broad-exception-caught
_LOGGER.warning("Online-features migration to global store failed", exc_info=True)
try:
acct = manager.profile_store.get_store_account()
except Exception: # pylint: disable=broad-exception-caught
acct = {}
if acct:
_account_preserved = False
try:
if acct.get("refresh_token") and not store_account.get_account(hass).get("refresh_token"):
await store_account.async_set_account(hass, {
"refresh_token": acct.get("refresh_token"),
"uid": acct.get("uid"), "name": acct.get("name"),
})
_account_preserved = True
except Exception: # pylint: disable=broad-exception-caught
_LOGGER.warning("Store-account hoist to global store failed", exc_info=True)
if _account_preserved:
try:
await manager.profile_store.clear_store_account()
except Exception: # pylint: disable=broad-exception-caught
pass
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up WashData from a config entry."""
_log = DeviceLoggerAdapter(_LOGGER, entry.title)
@@ -284,6 +368,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN][entry.entry_id] = manager
await manager.async_setup()
await _migrate_online_to_global(hass, entry, manager)
# Check for initial profile from onboarding
if "initial_profile" in entry.data:
@@ -336,12 +421,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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)
try:
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)
except ValueError as exc:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="assign_profile_failed",
translation_placeholders={"error": str(exc)},
) from exc
manager.notify_update()
@@ -367,9 +459,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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
)
try:
await manager.profile_store.create_profile_standalone(
profile_name, reference_cycle_id
)
except ValueError as exc:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="create_profile_failed",
translation_placeholders={"error": str(exc)},
) from exc
manager.notify_update()
hass.services.async_register(DOMAIN, "create_profile", handle_create_profile)
@@ -466,15 +565,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Determine trim end - default to full cycle duration if not supplied
raw_end = call.data.get("trim_end_s")
# Always check cycle existence first, regardless of which trim path is taken
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",
)
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:
@@ -524,6 +624,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data["ha_washdata_card_registered"] = False
_log.warning("Card registration failed and was not deferred")
# Register full-screen sidebar panel - once per HA instance only.
# pylint: disable=import-outside-toplevel
from .frontend import async_register_panel, PANEL_REGISTERED_KEY
if not hass.data.get(PANEL_REGISTERED_KEY):
await async_register_panel(hass)
# Register WebSocket API commands for the panel. Re-run on every setup/reload:
# HA's async_register_command overwrites the handler per command type, so this
# is idempotent AND means NEW commands become available after an integration
# reload, not only after a full Home Assistant restart (previously the
# once-per-instance guard forced a full restart for any newly-added command).
from .ws_api import ( # pylint: disable=import-outside-toplevel
async_load_panel_config,
async_register_commands,
)
await async_load_panel_config(hass) # self-guards; safe to call repeatedly
from . import store_account # pylint: disable=import-outside-toplevel
await store_account.async_load(hass) # integration-wide online flag + account
async_register_commands(hass)
hass.data["ha_washdata_ws_registered"] = True
# Register conversation intents (e.g. "is my washer done?") - once per HA
# instance. Intents are domain-global, so guard against re-registration when
# more than one device is configured.
if not hass.data.get("ha_washdata_intents_registered"):
from .intents import async_setup_intents # pylint: disable=import-outside-toplevel
async_setup_intents(hass)
hass.data["ha_washdata_intents_registered"] = True
# Register feedback service
if not hass.services.has_service(
DOMAIN, SERVICE_SUBMIT_FEEDBACK.rsplit(".", maxsplit=1)[-1]
@@ -623,8 +755,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
target = target.resolve()
# Write export
target.write_text(json.dumps(payload, indent=2), encoding="utf-8")
# Restrict caller-supplied paths to HA-allowed dirs (path-traversal /
# arbitrary-write guard). The default (no path) lands in the config dir.
if file_path and not hass.config.is_allowed_path(str(target)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="path_not_allowed",
translation_placeholders={"path": str(target)},
)
# Write export (offloaded to executor to avoid blocking the event
# loop). A caller-supplied path must never silently overwrite an
# existing file even when is_allowed_path() accepts it; exclusive
# creation ("x") makes that no-overwrite check atomic (no TOCTOU
# window). The default generated path may be re-written freely.
def _dump_and_write():
text = json.dumps(payload, indent=2)
try:
if file_path:
# Exclusive creation ("x") makes the no-overwrite check
# atomic; the default generated path may be re-written.
with open(target, "x", encoding="utf-8") as handle:
handle.write(text)
else:
target.write_text(text, encoding="utf-8")
except FileExistsError as exc:
# Subclass of OSError -> must be caught first (no-overwrite).
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="export_path_exists",
translation_placeholders={"path": str(target)},
) from exc
except OSError as exc:
# Disk full / permission denied / bad path: surface a clean
# localized error instead of a raw OSError from the executor.
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="export_write_failed",
translation_placeholders={
"path": str(target), "error": str(exc)
},
) from exc
await hass.async_add_executor_job(_dump_and_write)
manager._logger.info("Exported ha_washdata entry %s to %s", entry_id, target)
hass.services.async_register(DOMAIN, "export_config", handle_export_config)
@@ -655,12 +827,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if entry is None:
raise ValueError(f"Config entry not found: {entry_id}")
source = Path(file_path).resolve()
if not source.exists():
# resolve()/exists() hit the filesystem; offload so the event loop is not
# blocked on I/O during the import service call.
source = await hass.async_add_executor_job(
lambda: Path(file_path).resolve()
)
# Restrict reads to HA-allowed dirs (path-traversal / arbitrary-read guard).
if not hass.config.is_allowed_path(str(source)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="path_not_allowed",
translation_placeholders={"path": str(source)},
)
if not await hass.async_add_executor_job(source.exists):
raise ValueError(f"File not found: {source}")
try:
payload = json.loads(source.read_text(encoding="utf-8"))
def _read_and_parse():
text = source.read_text(encoding="utf-8")
return json.loads(text)
payload = await hass.async_add_executor_job(_read_and_parse)
except Exception as err: # noqa: BLE001
raise ValueError(f"Failed to read import file: {err}") from err
@@ -726,6 +912,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.services.async_register(DOMAIN, "record_stop", handle_record_stop)
# Register on-device ML training trigger (Stage 4, gated by ENABLE_ML_TRAINING)
from .const import ENABLE_ML_TRAINING, SERVICE_TRIGGER_ML_TRAINING
if ENABLE_ML_TRAINING and not hass.services.has_service(
DOMAIN, SERVICE_TRIGGER_ML_TRAINING
):
async def handle_trigger_ml_training(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]
summary = await manager.async_run_ml_training(force=True)
manager._logger.info("Manual ML training: %s", summary)
hass.services.async_register(
DOMAIN, SERVICE_TRIGGER_ML_TRAINING, handle_trigger_ml_training
)
# Register pause/resume services
if not hass.services.has_service(DOMAIN, "pause_cycle"):
async def handle_pause_cycle(call: ServiceCall) -> None:
@@ -753,7 +963,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
manager = hass.data[DOMAIN][entry_id]
await manager.async_pause_cycle()
success = await manager.async_pause_cycle()
if not success:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_active_cycle",
)
hass.services.async_register(DOMAIN, "pause_cycle", handle_pause_cycle)
@@ -783,7 +998,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
manager = hass.data[DOMAIN][entry_id]
await manager.async_resume_cycle()
success = await manager.async_resume_cycle()
if not success:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_active_cycle",
)
hass.services.async_register(DOMAIN, "resume_cycle", handle_resume_cycle)
@@ -834,5 +1054,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
manager = hass.data[DOMAIN].pop(entry.entry_id)
await manager.async_shutdown()
# When the last WashData entry is removed, tear down the shared panel/sidebar
# so no stale registration flags or sidebar entry linger.
if not hass.data.get(DOMAIN):
from .frontend import async_unregister_panel
await async_unregister_panel(hass)
return unload_ok