Added Alexa Music
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user