- `;
- }
-}
-
-customElements.define("ai_agent_ha-panel", AiAgentHaPanel);
-
-console.log("AI Agent HA Panel registered");
diff --git a/custom_components/ai_agent_ha/manifest.json b/custom_components/ai_agent_ha/manifest.json
deleted file mode 100644
index a72874e..0000000
--- a/custom_components/ai_agent_ha/manifest.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "domain": "ai_agent_ha",
- "name": "AI Agent HA",
- "after_dependencies": [
- "history",
- "recorder",
- "lovelace"
- ],
- "codeowners": [
- "@sbenodiz"
- ],
- "config_flow": true,
- "dependencies": [
- "http"
- ],
- "documentation": "https://github.com/sbenodiz/ai_agent_ha",
- "integration_type": "service",
- "iot_class": "cloud_polling",
- "issue_tracker": "https://github.com/sbenodiz/ai_agent_ha/issues",
- "requirements": [
- "aiohttp>=3.8.0"
- ],
- "version": "1.14.1"
-}
diff --git a/custom_components/ai_agent_ha/services.yaml b/custom_components/ai_agent_ha/services.yaml
deleted file mode 100644
index 32df89e..0000000
--- a/custom_components/ai_agent_ha/services.yaml
+++ /dev/null
@@ -1,98 +0,0 @@
-query:
- name: "Query AI Agent with Home Assistant context"
- description: "Run a custom AI prompt against your Home Assistant state dump."
- fields:
- prompt:
- description: "The question or instruction to send to AI model."
- example: "Turn on all the lights in the living room"
- debug:
- description: "Include a debug trace of the HA↔AI conversation (true/false)."
- example: true
- default: false
- provider:
- description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
- example: "openai"
- default: "openai"
- selector:
- select:
- options:
- - "openai"
- - "llama"
- - "gemini"
- - "openrouter"
- - "anthropic"
- - "alter"
- - "zai"
- - "local"
-
-create_dashboard:
- name: "Create Dashboard via AI Agent"
- description: "Create a new Home Assistant dashboard using AI assistance."
- fields:
- dashboard_config:
- description: "The dashboard configuration as a JSON object."
- example: '{"title": "My Dashboard", "url_path": "my-dashboard", "views": []}'
- provider:
- description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
- example: "openai"
- default: "openai"
- selector:
- select:
- options:
- - "openai"
- - "llama"
- - "gemini"
- - "openrouter"
- - "anthropic"
- - "alter"
- - "zai"
- - "local"
-
-create_automation:
- name: "Create Automation via AI Agent"
- description: "Create a new Home Assistant automation using AI assistance."
- fields:
- automation:
- description: "The automation configuration as a JSON object."
- example: '{"alias": "Turn off lights at 9 PM", "trigger": [{"platform": "time", "at": "21:00:00"}], "action": [{"service": "light.turn_off", "target": {"entity_id": "light.living_room"}}]}'
- provider:
- description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
- example: "openai"
- default: "openai"
- selector:
- select:
- options:
- - "openai"
- - "llama"
- - "gemini"
- - "openrouter"
- - "anthropic"
- - "alter"
- - "zai"
- - "local"
-
-update_dashboard:
- name: "Update Dashboard via AI Agent"
- description: "Update an existing Home Assistant dashboard using AI assistance."
- fields:
- dashboard_url:
- description: "The URL path of dashboard to update."
- example: "my-dashboard"
- dashboard_config:
- description: "The updated dashboard configuration as a JSON object."
- example: '{"title": "Updated Dashboard", "views": []}'
- provider:
- description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
- example: "openai"
- default: "openai"
- selector:
- select:
- options:
- - "openai"
- - "llama"
- - "gemini"
- - "openrouter"
- - "anthropic"
- - "alter"
- - "zai"
- - "local"
diff --git a/custom_components/ai_agent_ha/strings.json b/custom_components/ai_agent_ha/strings.json
deleted file mode 100644
index 1e1799a..0000000
--- a/custom_components/ai_agent_ha/strings.json
+++ /dev/null
@@ -1,91 +0,0 @@
-{
- "config": {
- "step": {
- "user": {
- "title": "Choose AI Provider",
- "description": "Select your AI provider",
- "data": {
- "ai_provider": "AI Provider"
- },
- "data_description": {
- "ai_provider": "Choose your preferred AI provider"
- }
- },
- "configure": {
- "title": "Configure {provider}",
- "description": "Enter your {token_label} and optionally select a model",
- "data": {
- "llama_token": "Llama API Token",
- "openai_token": "OpenAI API Key",
- "gemini_token": "Google Gemini API Key",
- "openrouter_token": "OpenRouter API Key",
- "anthropic_token": "Anthropic API Key",
- "alter_token": "Alter API Key",
- "model": "Model (Optional)",
- "custom_model": "Custom Model (Optional)"
- },
- "data_description": {
- "llama_token": "Enter your Llama API token",
- "openai_token": "Enter your OpenAI API key",
- "gemini_token": "Enter your Google Gemini API key",
- "openrouter_token": "Enter your OpenRouter API key",
- "anthropic_token": "Enter your Anthropic API key",
- "alter_token": "Enter your Alter API key",
- "model": "Choose a predefined model or select 'Custom...' to enter your own",
- "custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
- }
- }
- },
- "error": {
- "invalid_api_key": "Invalid API key format",
- "unknown": "Unknown error occurred",
- "llama_token": "Llama API token is required",
- "openai_token": "OpenAI API key is required",
- "gemini_token": "Google Gemini API key is required",
- "openrouter_token": "OpenRouter API key is required",
- "anthropic_token": "Anthropic API key is required",
- "alter_token": "Alter API key is required"
- },
- "abort": {
- "already_configured": "AI Agent HA is already configured"
- }
- },
- "options": {
- "step": {
- "init": {
- "title": "AI Provider Settings",
- "description": "Current provider: {current_provider}. Select a provider to configure",
- "data": {
- "ai_provider": "AI Provider"
- },
- "data_description": {
- "ai_provider": "Choose your preferred AI provider"
- }
- },
- "configure_options": {
- "title": "Configure {provider}",
- "description": "Update your {token_label} and model settings",
- "data": {
- "llama_token": "Llama API Token",
- "openai_token": "OpenAI API Key",
- "gemini_token": "Google Gemini API Key",
- "openrouter_token": "OpenRouter API Key",
- "anthropic_token": "Anthropic API Key",
- "alter_token": "Alter API Key",
- "model": "Model",
- "custom_model": "Custom Model (Optional)"
- },
- "data_description": {
- "llama_token": "Enter your Llama API token",
- "openai_token": "Enter your OpenAI API key",
- "gemini_token": "Enter your Google Gemini API key",
- "openrouter_token": "Enter your OpenRouter API key",
- "anthropic_token": "Enter your Anthropic API key",
- "alter_token": "Enter your Alter API key",
- "model": "Choose a model or select 'Custom...' to enter your own",
- "custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/custom_components/ai_agent_ha/translations/ca.json b/custom_components/ai_agent_ha/translations/ca.json
deleted file mode 100644
index 3468855..0000000
--- a/custom_components/ai_agent_ha/translations/ca.json
+++ /dev/null
@@ -1,81 +0,0 @@
-{
- "config": {
- "step": {
- "user": {
- "title": "Agent d'IA HA - Seleccionar proveïdor",
- "description": "Tria el teu proveïdor d'IA",
- "data": {
- "ai_provider": "Proveïdor d'IA"
- },
- "data_description": {
- "ai_provider": "Selecciona el teu proveïdor d'IA preferit"
- }
- },
- "configure": {
- "title": "Agent d'IA HA - Configurar {provider}",
- "description": "Configura les teves credencials d'API i el model de {provider}",
- "data": {
- "llama_token": "Token de l'API de Llama",
- "openai_token": "Clau de l'API d'OpenAI",
- "gemini_token": "Clau de l'API de Google Gemini",
- "openrouter_token": "Clau de l'API d'OpenRouter",
- "model": "Model (Opcional)",
- "custom_model": "Nom del model personalitzat (Opcional)"
- },
- "data_description": {
- "llama_token": "Introdueix el teu token de l'API de Llama",
- "openai_token": "Introdueix la teva clau de l'API d'OpenAI",
- "gemini_token": "Introdueix la teva clau de l'API de Google Gemini",
- "openrouter_token": "Introdueix la teva clau de l'API d'OpenRouter",
- "model": "Tria un model predefinit o selecciona 'Personalitzat...' per utilitzar un model personalitzat",
- "custom_model": "Introdueix un nom de model personalitzat (anul·la la selecció desplegable anterior)"
- }
- }
- },
- "error": {
- "invalid_api_key": "Clau o token d'API no vàlid",
- "llama_token": "Es requereix el token de l'API de Llama",
- "openai_token": "Es requereix la clau de l'API d'OpenAI",
- "gemini_token": "Es requereix la clau de l'API de Google Gemini",
- "openrouter_token": "Es requereix la clau de l'API d'OpenRouter",
- "unknown": "S'ha produït un error inesperat"
- },
- "abort": {
- "already_configured": "L'Agent d'IA HA ja està configurat"
- }
- },
- "options": {
- "step": {
- "init": {
- "title": "Agent d'IA HA - Seleccionar proveïdor",
- "description": "Tria el teu proveïdor d'IA (Actual: {current_provider})",
- "data": {
- "ai_provider": "Proveïdor d'IA"
- },
- "data_description": {
- "ai_provider": "Selecciona el teu proveïdor d'IA preferit"
- }
- },
- "configure_options": {
- "title": "Agent d'IA HA - Configurar {provider}",
- "description": "Actualitza les teves credencials d'API i el model de {provider}",
- "data": {
- "llama_token": "Token de l'API de Llama",
- "openai_token": "Clau de l'API d'OpenAI",
- "gemini_token": "Clau de l'API de Google Gemini",
- "openrouter_token": "Clau de l'API d'OpenRouter",
- "model": "Model (Opcional)",
- "custom_model": "Nom del model personalitzat (Opcional)"
- },
- "data_description": {
- "llama_token": "Introdueix el teu token de l'API de Llama",
- "openai_token": "Introdueix la teva clau de l'API d'OpenAI",
- "gemini_token": "Introdueix la teva clau de l'API de Google Gemini",
- "openrouter_token": "Introdueix la teva clau de l'API d'OpenRouter",
- "model": "Tria un model predefinit o selecciona 'Personalitzat...' per utilitzar un model personalitzat",
- "custom_model": "Introdueix un nom de model personalitzat (anul·la la selecció desplegable anterior)"
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/custom_components/ai_agent_ha/translations/de.json b/custom_components/ai_agent_ha/translations/de.json
deleted file mode 100644
index bfd8cf2..0000000
--- a/custom_components/ai_agent_ha/translations/de.json
+++ /dev/null
@@ -1,86 +0,0 @@
-{
- "config": {
- "step": {
- "user": {
- "title": "KI-Anbieter wählen",
- "description": "Wähle deinen KI-Anbieter aus",
- "data": {
- "ai_provider": "KI-Anbieter"
- },
- "data_description": {
- "ai_provider": "Wähle deinen bevorzugten KI-Anbieter"
- }
- },
- "configure": {
- "title": "{provider} konfigurieren",
- "description": "Gib deinen {token_label} ein und wähle optional ein Model",
- "data": {
- "llama_token": "Llama API Token",
- "openai_token": "OpenAI API Key",
- "gemini_token": "Google Gemini API Key",
- "openrouter_token": "OpenRouter API Key",
- "anthropic_token": "Anthropic API Key",
- "model": "Model (Optional)",
- "custom_model": "Custom Model (Optional)"
- },
- "data_description": {
- "llama_token": "Gib deinen Llama API Token ein",
- "openai_token": "Gib deinen OpenAI API Key ein",
- "gemini_token": "Gib deinen Google Gemini API Key ein",
- "openrouter_token": "Gib deinen OpenRouter API Key ein",
- "anthropic_token": "Gib deinen Anthropic API Key ein",
- "model": "Wähle ein vordefiniertes Model oder 'Custom...' zum manuellen Eintrag",
- "custom_model": "Gib einen benutzerdefinierten Model-Namen ein (nur relevant bei Auswahl von 'Custom...')"
- }
- }
- },
- "error": {
- "invalid_api_key": "Ungültiges API-Key-Format",
- "unknown": "Unbekannter Fehler aufgetreten",
- "llama_token": "Llama API Token ist erforderlich",
- "openai_token": "OpenAI API Key ist erforderlich",
- "gemini_token": "Google Gemini API Key ist erforderlich",
- "openrouter_token": "OpenRouter API Key ist erforderlich",
- "anthropic_token": "Anthropic API Key ist erforderlich"
- },
- "abort": {
- "already_configured": "AI Agent HA ist bereits konfiguriert"
- }
- },
- "options": {
- "step": {
- "init": {
- "title": "Einstellungen für KI-Anbieter",
- "description": "Aktueller Anbieter: {current_provider}. Wähle einen Anbieter zur Konfiguration",
- "data": {
- "ai_provider": "KI-Anbieter"
- },
- "data_description": {
- "ai_provider": "Wähle deinen bevorzugten KI-Anbieter"
- }
- },
- "configure_options": {
- "title": "{provider} konfigurieren",
- "description": "Aktualisiere deinen {token_label} und Model-Einstellungen",
- "data": {
- "llama_token": "Llama API Token",
- "openai_token": "OpenAI API Key",
- "gemini_token": "Google Gemini API Key",
- "openrouter_token": "OpenRouter API Key",
- "anthropic_token": "Anthropic API Key",
- "model": "Model",
- "custom_model": "Custom Model (Optional)"
- },
- "data_description": {
- "llama_token": "Gib deinen Llama API Token ein",
- "openai_token": "Gib deinen OpenAI API Key ein",
- "gemini_token": "Gib deinen Google Gemini API Key ein",
- "openrouter_token": "Gib deinen OpenRouter API Key ein",
- "anthropic_token": "Gib deinen Anthropic API Key ein",
- "model": "Wähle ein Model oder 'Custom...' zum manuellen Eintrag",
- "custom_model": "Gib einen benutzerdefinierten Model-Namen ein (nur relevant bei Auswahl von 'Custom...')"
- }
- }
- }
- }
-}
diff --git a/custom_components/ai_agent_ha/translations/en.json b/custom_components/ai_agent_ha/translations/en.json
deleted file mode 100644
index 1e1799a..0000000
--- a/custom_components/ai_agent_ha/translations/en.json
+++ /dev/null
@@ -1,91 +0,0 @@
-{
- "config": {
- "step": {
- "user": {
- "title": "Choose AI Provider",
- "description": "Select your AI provider",
- "data": {
- "ai_provider": "AI Provider"
- },
- "data_description": {
- "ai_provider": "Choose your preferred AI provider"
- }
- },
- "configure": {
- "title": "Configure {provider}",
- "description": "Enter your {token_label} and optionally select a model",
- "data": {
- "llama_token": "Llama API Token",
- "openai_token": "OpenAI API Key",
- "gemini_token": "Google Gemini API Key",
- "openrouter_token": "OpenRouter API Key",
- "anthropic_token": "Anthropic API Key",
- "alter_token": "Alter API Key",
- "model": "Model (Optional)",
- "custom_model": "Custom Model (Optional)"
- },
- "data_description": {
- "llama_token": "Enter your Llama API token",
- "openai_token": "Enter your OpenAI API key",
- "gemini_token": "Enter your Google Gemini API key",
- "openrouter_token": "Enter your OpenRouter API key",
- "anthropic_token": "Enter your Anthropic API key",
- "alter_token": "Enter your Alter API key",
- "model": "Choose a predefined model or select 'Custom...' to enter your own",
- "custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
- }
- }
- },
- "error": {
- "invalid_api_key": "Invalid API key format",
- "unknown": "Unknown error occurred",
- "llama_token": "Llama API token is required",
- "openai_token": "OpenAI API key is required",
- "gemini_token": "Google Gemini API key is required",
- "openrouter_token": "OpenRouter API key is required",
- "anthropic_token": "Anthropic API key is required",
- "alter_token": "Alter API key is required"
- },
- "abort": {
- "already_configured": "AI Agent HA is already configured"
- }
- },
- "options": {
- "step": {
- "init": {
- "title": "AI Provider Settings",
- "description": "Current provider: {current_provider}. Select a provider to configure",
- "data": {
- "ai_provider": "AI Provider"
- },
- "data_description": {
- "ai_provider": "Choose your preferred AI provider"
- }
- },
- "configure_options": {
- "title": "Configure {provider}",
- "description": "Update your {token_label} and model settings",
- "data": {
- "llama_token": "Llama API Token",
- "openai_token": "OpenAI API Key",
- "gemini_token": "Google Gemini API Key",
- "openrouter_token": "OpenRouter API Key",
- "anthropic_token": "Anthropic API Key",
- "alter_token": "Alter API Key",
- "model": "Model",
- "custom_model": "Custom Model (Optional)"
- },
- "data_description": {
- "llama_token": "Enter your Llama API token",
- "openai_token": "Enter your OpenAI API key",
- "gemini_token": "Enter your Google Gemini API key",
- "openrouter_token": "Enter your OpenRouter API key",
- "anthropic_token": "Enter your Anthropic API key",
- "alter_token": "Enter your Alter API key",
- "model": "Choose a model or select 'Custom...' to enter your own",
- "custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/custom_components/ai_agent_ha/translations/en.txt b/custom_components/ai_agent_ha/translations/en.txt
deleted file mode 100644
index ab3ce1b..0000000
--- a/custom_components/ai_agent_ha/translations/en.txt
+++ /dev/null
@@ -1,81 +0,0 @@
-{
- "config": {
- "step": {
- "user": {
- "title": "AI Agent HA - Select Provider",
- "description": "Choose your AI provider",
- "data": {
- "ai_provider": "AI Provider"
- },
- "data_description": {
- "ai_provider": "Select your preferred AI provider"
- }
- },
- "configure": {
- "title": "AI Agent HA - Configure {provider}",
- "description": "Configure your {provider} API credentials and model",
- "data": {
- "llama_token": "Llama API Token",
- "openai_token": "OpenAI API Key",
- "gemini_token": "Google Gemini API Key",
- "openrouter_token": "OpenRouter API Key",
- "model": "Model (Optional)",
- "custom_model": "Custom Model Name (Optional)"
- },
- "data_description": {
- "llama_token": "Enter your Llama API token",
- "openai_token": "Enter your OpenAI API key",
- "gemini_token": "Enter your Google Gemini API key",
- "openrouter_token": "Enter your OpenRouter API key",
- "model": "Choose a predefined model or select 'Custom...' to use a custom model",
- "custom_model": "Enter a custom model name (overrides the dropdown selection above)"
- }
- }
- },
- "error": {
- "invalid_api_key": "Invalid API key or token",
- "llama_token": "Llama API token is required",
- "openai_token": "OpenAI API key is required",
- "gemini_token": "Google Gemini API key is required",
- "openrouter_token": "OpenRouter API key is required",
- "unknown": "Unexpected error occurred"
- },
- "abort": {
- "already_configured": "AI Agent HA is already configured"
- }
- },
- "options": {
- "step": {
- "init": {
- "title": "AI Agent HA - Select Provider",
- "description": "Choose your AI provider (Current: {current_provider})",
- "data": {
- "ai_provider": "AI Provider"
- },
- "data_description": {
- "ai_provider": "Select your preferred AI provider"
- }
- },
- "configure_options": {
- "title": "AI Agent HA - Configure {provider}",
- "description": "Update your {provider} API credentials and model",
- "data": {
- "llama_token": "Llama API Token",
- "openai_token": "OpenAI API Key",
- "gemini_token": "Google Gemini API Key",
- "openrouter_token": "OpenRouter API Key",
- "model": "Model (Optional)",
- "custom_model": "Custom Model Name (Optional)"
- },
- "data_description": {
- "llama_token": "Enter your Llama API token",
- "openai_token": "Enter your OpenAI API key",
- "gemini_token": "Enter your Google Gemini API key",
- "openrouter_token": "Enter your OpenRouter API key",
- "model": "Choose a predefined model or select 'Custom...' to use a custom model",
- "custom_model": "Enter a custom model name (overrides the dropdown selection above)"
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/custom_components/ai_agent_ha/translations/es.json b/custom_components/ai_agent_ha/translations/es.json
deleted file mode 100644
index dc214bb..0000000
--- a/custom_components/ai_agent_ha/translations/es.json
+++ /dev/null
@@ -1,81 +0,0 @@
-{
- "config": {
- "step": {
- "user": {
- "title": "Agente de IA HA - Seleccionar proveedor",
- "description": "Elige tu proveedor de IA",
- "data": {
- "ai_provider": "Proveedor de IA"
- },
- "data_description": {
- "ai_provider": "Selecciona tu proveedor de IA preferido"
- }
- },
- "configure": {
- "title": "Agente de IA HA - Configurar {provider}",
- "description": "Configura tus credenciales de API y modelo de {provider}",
- "data": {
- "llama_token": "Token de la API de Llama",
- "openai_token": "Clave de la API de OpenAI",
- "gemini_token": "Clave de la API de Google Gemini",
- "openrouter_token": "Clave de la API de OpenRouter",
- "model": "Modelo (Opcional)",
- "custom_model": "Nombre de modelo personalizado (Opcional)"
- },
- "data_description": {
- "llama_token": "Introduce tu token de la API de Llama",
- "openai_token": "Introduce tu clave de la API de OpenAI",
- "gemini_token": "Introduce tu clave de la API de Google Gemini",
- "openrouter_token": "Introduce tu clave de la API de OpenRouter",
- "model": "Elige un modelo predefinido o selecciona 'Personalizado...' para usar un modelo personalizado",
- "custom_model": "Introduce un nombre de modelo personalizado (anula la selección desplegable anterior)"
- }
- }
- },
- "error": {
- "invalid_api_key": "Clave o token de API no válido",
- "llama_token": "Se requiere el token de la API de Llama",
- "openai_token": "Se requiere la clave de la API de OpenAI",
- "gemini_token": "Se requiere la clave de la API de Google Gemini",
- "openrouter_token": "Se requiere la clave de la API de OpenRouter",
- "unknown": "Ha ocurrido un error inesperado"
- },
- "abort": {
- "already_configured": "El Agente de IA HA ya está configurado"
- }
- },
- "options": {
- "step": {
- "init": {
- "title": "Agente de IA HA - Seleccionar proveedor",
- "description": "Elige tu proveedor de IA (Actual: {current_provider})",
- "data": {
- "ai_provider": "Proveedor de IA"
- },
- "data_description": {
- "ai_provider": "Selecciona tu proveedor de IA preferido"
- }
- },
- "configure_options": {
- "title": "Agente de IA HA - Configurar {provider}",
- "description": "Actualiza tus credenciales de API y modelo de {provider}",
- "data": {
- "llama_token": "Token de la API de Llama",
- "openai_token": "Clave de la API de OpenAI",
- "gemini_token": "Clave de la API de Google Gemini",
- "openrouter_token": "Clave de la API de OpenRouter",
- "model": "Modelo (Opcional)",
- "custom_model": "Nombre de modelo personalizado (Opcional)"
- },
- "data_description": {
- "llama_token": "Introduce tu token de la API de Llama",
- "openai_token": "Introduce tu clave de la API de OpenAI",
- "gemini_token": "Introduce tu clave de la API de Google Gemini",
- "openrouter_token": "Introduce tu clave de la API de OpenRouter",
- "model": "Elige un modelo predefinido o selecciona 'Personalizado...' para usar un modelo personalizado",
- "custom_model": "Introduce un nombre de modelo personalizado (anula la selección desplegable anterior)"
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc
index 7ffab77..2f08b75 100644
Binary files a/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc
index 28f6562..c96fa01 100644
Binary files a/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc
index 2f876cd..570a7e1 100644
Binary files a/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc
index e8cf67c..165176f 100644
Binary files a/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc
index 94185be..9c07ada 100644
Binary files a/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc
index c9034e4..effad61 100644
Binary files a/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc
index ca5250b..d4e6caa 100644
Binary files a/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc
index 263e773..b10e410 100644
Binary files a/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc
index 429a7ff..9dab212 100644
Binary files a/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc
index 70d0eb8..4e7d519 100644
Binary files a/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc
index 129713a..f18dd4d 100644
Binary files a/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc
index 94a9f98..05660c8 100644
Binary files a/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc
index f4255bb..45fcff1 100644
Binary files a/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc
index 4b0e3d0..c7b4b6e 100644
Binary files a/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc
index eaa4092..1181b20 100644
Binary files a/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc differ
diff --git a/custom_components/album_slideshow/config_flow.py b/custom_components/album_slideshow/config_flow.py
index 31cd371..9b7f9fb 100644
--- a/custom_components/album_slideshow/config_flow.py
+++ b/custom_components/album_slideshow/config_flow.py
@@ -54,6 +54,19 @@ from .const import (
DEFAULT_ICLOUD_IMAGE_SIZE,
ICLOUD_IMAGE_FULL,
ICLOUD_IMAGE_PREVIEW,
+ CONF_SYNOLOGY_URL,
+ CONF_SYNOLOGY_USERNAME,
+ CONF_SYNOLOGY_PASSWORD,
+ CONF_SYNOLOGY_DEVICE_ID,
+ CONF_SYNOLOGY_SPACE,
+ CONF_SYNOLOGY_ALBUM_ID,
+ CONF_SYNOLOGY_IMAGE_SIZE,
+ DEFAULT_SYNOLOGY_IMAGE_SIZE,
+ SYNOLOGY_SPACE_PERSONAL,
+ SYNOLOGY_SPACE_SHARED,
+ SYNOLOGY_IMAGE_SMALL,
+ SYNOLOGY_IMAGE_MEDIUM,
+ SYNOLOGY_IMAGE_LARGE,
DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
@@ -61,6 +74,7 @@ from .const import (
PROVIDER_IMMICH,
PROVIDER_PHOTOPRISM,
PROVIDER_ICLOUD,
+ PROVIDER_SYNOLOGY,
DEFAULT_RECURSIVE,
)
@@ -108,6 +122,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._pp_password: str | None = None
self._pp_albums: dict[str, str] = {}
self._pp_people: dict[str, str] = {}
+ # Synology flow state carried between steps.
+ self._syn_url: str | None = None
+ self._syn_username: str | None = None
+ self._syn_password: str | None = None
+ self._syn_device_id: str | None = None
+ self._syn_space: str = SYNOLOGY_SPACE_PERSONAL
+ self._syn_albums: dict[str, str] = {}
@staticmethod
@callback
@@ -143,6 +164,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_photoprism()
if self._provider == PROVIDER_ICLOUD:
return await self.async_step_icloud()
+ if self._provider == PROVIDER_SYNOLOGY:
+ return await self.async_step_synology()
return await self.async_step_google_shared()
schema = vol.Schema(
@@ -153,6 +176,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
PROVIDER_ICLOUD: "iCloud Shared Album",
+ PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
})
}
@@ -651,6 +675,140 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="icloud", data_schema=schema, errors=errors
)
+ async def async_step_synology(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Collect the Synology URL + credentials (and optional 2FA code)."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ url = user_input[CONF_SYNOLOGY_URL].strip()
+ username = user_input[CONF_SYNOLOGY_USERNAME].strip()
+ password = user_input.get(CONF_SYNOLOGY_PASSWORD) or ""
+ space = user_input.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
+ otp = (user_input.get("otp_code") or "").strip()
+
+ from . import synology as syn_api
+
+ client = syn_api.SynologyClient(
+ self.hass,
+ url,
+ username=username,
+ password=password,
+ space=space,
+ )
+ try:
+ await client.async_login(otp_code=otp or None)
+ albums = await client.async_list_albums()
+ except syn_api.SynologyOtpRequired:
+ errors["otp_code"] = "synology_otp_required"
+ except Exception: # noqa: BLE001 - any failure means bad URL/creds
+ errors["base"] = "synology_cannot_connect"
+ else:
+ self._syn_url = client.base_url
+ self._syn_username = username
+ self._syn_password = password
+ self._syn_space = space
+ # A trusted-device token is captured only on the OTP login;
+ # store it so future logins skip the 2FA prompt.
+ self._syn_device_id = client.captured_device_id
+ self._syn_albums = {
+ str(a["id"]): (a.get("name") or str(a["id"]))
+ for a in albums
+ if a.get("id") is not None
+ }
+ await client.async_logout()
+ return await self.async_step_synology_select()
+
+ schema = vol.Schema(
+ {
+ vol.Required(CONF_SYNOLOGY_URL): str,
+ vol.Required(CONF_SYNOLOGY_USERNAME): str,
+ vol.Required(CONF_SYNOLOGY_PASSWORD): selector.TextSelector(
+ selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
+ ),
+ vol.Required(
+ CONF_SYNOLOGY_SPACE, default=SYNOLOGY_SPACE_PERSONAL
+ ): vol.In(
+ {
+ SYNOLOGY_SPACE_PERSONAL: "Personal (My Photos)",
+ SYNOLOGY_SPACE_SHARED: "Shared Space",
+ }
+ ),
+ vol.Optional("otp_code"): str,
+ }
+ )
+ return self.async_show_form(
+ step_id="synology", data_schema=schema, errors=errors
+ )
+
+ async def async_step_synology_select(
+ self, user_input: dict[str, Any] | None = None
+ ) -> FlowResult:
+ """Pick a Synology album (or the whole space) and image size."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ name = user_input[CONF_ALBUM_NAME].strip()
+ size = user_input.get(
+ CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
+ )
+ album_id = user_input.get("album")
+ if album_id in (None, "", "__all__") or album_id not in self._syn_albums:
+ album_id = ""
+
+ unique = (
+ f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:"
+ f"{self._syn_space}:{album_id or 'all'}"
+ )
+ await self.async_set_unique_id(unique)
+ self._abort_if_unique_id_configured()
+ data = {
+ CONF_PROVIDER: PROVIDER_SYNOLOGY,
+ CONF_SYNOLOGY_URL: self._syn_url,
+ CONF_SYNOLOGY_USERNAME: self._syn_username,
+ CONF_SYNOLOGY_PASSWORD: self._syn_password,
+ CONF_SYNOLOGY_SPACE: self._syn_space,
+ CONF_SYNOLOGY_IMAGE_SIZE: size,
+ CONF_ALBUM_NAME: name,
+ }
+ if album_id:
+ data[CONF_SYNOLOGY_ALBUM_ID] = album_id
+ if self._syn_device_id:
+ data[CONF_SYNOLOGY_DEVICE_ID] = self._syn_device_id
+ return self.async_create_entry(title=name, data=data)
+
+ album_options = [
+ selector.SelectOptionDict(value="__all__", label="All photos in this space")
+ ] + [
+ selector.SelectOptionDict(value=aid, label=aname)
+ for aid, aname in self._syn_albums.items()
+ ]
+ schema = vol.Schema(
+ {
+ vol.Required(CONF_ALBUM_NAME): str,
+ vol.Optional("album", default="__all__"): selector.SelectSelector(
+ selector.SelectSelectorConfig(
+ options=album_options,
+ mode=selector.SelectSelectorMode.DROPDOWN,
+ custom_value=False,
+ )
+ ),
+ vol.Optional(
+ CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE
+ ): vol.In(
+ {
+ SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)",
+ SYNOLOGY_IMAGE_MEDIUM: "Medium",
+ SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)",
+ }
+ ),
+ }
+ )
+ return self.async_show_form(
+ step_id="synology_select", data_schema=schema, errors=errors
+ )
+
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
"""Options for local-folder entries.
diff --git a/custom_components/album_slideshow/const.py b/custom_components/album_slideshow/const.py
index 02db829..84cadc0 100644
--- a/custom_components/album_slideshow/const.py
+++ b/custom_components/album_slideshow/const.py
@@ -57,6 +57,34 @@ PROVIDER_MEDIA_SOURCE = "media_source"
PROVIDER_IMMICH = "immich"
PROVIDER_PHOTOPRISM = "photoprism"
PROVIDER_ICLOUD = "icloud"
+PROVIDER_SYNOLOGY = "synology"
+
+# Synology Photos (direct API) provider. Talks to a DSM Photos package over its
+# entry.cgi web API. The account password is stored so the coordinator can
+# re-authenticate when the session id expires; accounts with 2FA are handled by
+# capturing a trusted-device token during setup (see synology.py).
+CONF_SYNOLOGY_URL = "synology_url"
+CONF_SYNOLOGY_USERNAME = "synology_username"
+CONF_SYNOLOGY_PASSWORD = "synology_password"
+CONF_SYNOLOGY_DEVICE_ID = "synology_device_id"
+CONF_SYNOLOGY_SPACE = "synology_space"
+CONF_SYNOLOGY_ALBUM_ID = "synology_album_id"
+CONF_SYNOLOGY_IMAGE_SIZE = "synology_image_size"
+
+# Personal ("My Photos") vs shared ("Shared Space") library.
+SYNOLOGY_SPACE_PERSONAL = "personal"
+SYNOLOGY_SPACE_SHARED = "shared"
+
+# Native Synology thumbnail sizes. ``xl`` is the largest (best for a slideshow).
+SYNOLOGY_IMAGE_SMALL = "sm"
+SYNOLOGY_IMAGE_MEDIUM = "m"
+SYNOLOGY_IMAGE_LARGE = "xl"
+SYNOLOGY_IMAGE_SIZE_OPTIONS = [
+ SYNOLOGY_IMAGE_SMALL,
+ SYNOLOGY_IMAGE_MEDIUM,
+ SYNOLOGY_IMAGE_LARGE,
+]
+DEFAULT_SYNOLOGY_IMAGE_SIZE = SYNOLOGY_IMAGE_LARGE
# iCloud Shared Album provider. The share token in the pasted link is the only
# credential; no account or password is involved.
diff --git a/custom_components/album_slideshow/coordinator.py b/custom_components/album_slideshow/coordinator.py
index 6712cbe..b5e5a7b 100644
--- a/custom_components/album_slideshow/coordinator.py
+++ b/custom_components/album_slideshow/coordinator.py
@@ -44,6 +44,15 @@ from .const import (
CONF_ICLOUD_TOKEN,
CONF_ICLOUD_IMAGE_SIZE,
DEFAULT_ICLOUD_IMAGE_SIZE,
+ CONF_SYNOLOGY_URL,
+ CONF_SYNOLOGY_USERNAME,
+ CONF_SYNOLOGY_PASSWORD,
+ CONF_SYNOLOGY_DEVICE_ID,
+ CONF_SYNOLOGY_SPACE,
+ CONF_SYNOLOGY_ALBUM_ID,
+ CONF_SYNOLOGY_IMAGE_SIZE,
+ DEFAULT_SYNOLOGY_IMAGE_SIZE,
+ SYNOLOGY_SPACE_PERSONAL,
DEFAULT_REVERSE_GEOCODE,
DOMAIN,
PROVIDER_GOOGLE_SHARED,
@@ -52,6 +61,7 @@ from .const import (
PROVIDER_IMMICH,
PROVIDER_PHOTOPRISM,
PROVIDER_ICLOUD,
+ PROVIDER_SYNOLOGY,
)
from .store import SlideshowStore
@@ -939,6 +949,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
data = await self._update_photoprism()
elif self.provider == PROVIDER_ICLOUD:
data = await self._update_icloud()
+ elif self.provider == PROVIDER_SYNOLOGY:
+ data = await self._update_synology()
else:
raise UpdateFailed(f"Unsupported provider: {self.provider}")
except UpdateFailed:
@@ -1512,6 +1524,87 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items,
}
+ async def _update_synology(self) -> dict[str, Any]:
+ """Fetch photos from a Synology Photos library via its web API.
+
+ Metadata (capture date, GPS, address, description) is returned inline
+ with each item, so every ``MediaItem`` is built fully here - there is
+ no background enrichment pass. Thumbnail URLs carry no SID; the session
+ cookie is stored on the coordinator and sent server-side by the camera
+ (like the Immich x-api-key), so the SID never reaches the browser.
+ """
+ from . import synology as syn_api
+
+ url = self.entry.data.get(CONF_SYNOLOGY_URL)
+ username = self.entry.data.get(CONF_SYNOLOGY_USERNAME)
+ password = self.entry.data.get(CONF_SYNOLOGY_PASSWORD)
+ device_id = self.entry.data.get(CONF_SYNOLOGY_DEVICE_ID)
+ space = self.entry.data.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL)
+ album_id = self.entry.data.get(CONF_SYNOLOGY_ALBUM_ID)
+ size = self.entry.data.get(
+ CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE
+ )
+ if not url or not username or not password:
+ raise UpdateFailed("Synology provider is missing URL or credentials")
+
+ client = syn_api.SynologyClient(
+ self.hass,
+ url,
+ username=username,
+ password=password,
+ device_id=device_id,
+ space=space,
+ )
+ try:
+ await client.async_login()
+ photos = await client.async_collect_assets(album_id or None)
+ except Exception as err:
+ raise UpdateFailed(f"Error querying Synology Photos: {err}") from err
+
+ if not photos:
+ await client.async_logout()
+ raise UpdateFailed("No images found for the selected Synology source")
+
+ # Store the session cookie so the camera can fetch thumbnail bytes
+ # server-side. Do not log out: the SID must stay valid until the next
+ # refresh re-authenticates.
+ self.image_request_headers = dict(client.image_headers)
+
+ items: list[MediaItem] = []
+ for p in photos:
+ ref = syn_api.thumbnail_ref(p)
+ if not ref:
+ continue
+ unit_id, cache_key = ref
+ meta = syn_api.parse_photo_meta(p)
+ items.append(
+ MediaItem(
+ url=syn_api.build_thumbnail_url(
+ client.base_url, unit_id, cache_key, size, space
+ ),
+ width=meta.get("width"),
+ height=meta.get("height"),
+ mime_type=None,
+ filename=p.get("filename"),
+ captured_at=meta.get("captured_at"),
+ byte_size=meta.get("byte_size"),
+ latitude=meta.get("latitude"),
+ longitude=meta.get("longitude"),
+ location=meta.get("location"),
+ description=meta.get("description"),
+ source_id=str(p.get("id")) if p.get("id") is not None else None,
+ exif_scanned=True,
+ )
+ )
+
+ if not items:
+ raise UpdateFailed("Could not resolve any Synology images")
+
+ return {
+ "title": self.entry.title,
+ "items": items,
+ }
+
async def _enrich_immich_item(self, item: MediaItem) -> None:
"""Fetch one Immich asset's detail and fill location/description."""
from . import immich as immich_api
diff --git a/custom_components/album_slideshow/manifest.json b/custom_components/album_slideshow/manifest.json
index 3623459..e3d5ebf 100644
--- a/custom_components/album_slideshow/manifest.json
+++ b/custom_components/album_slideshow/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"],
- "version": "1.4.0"
+ "version": "1.5.0"
}
diff --git a/custom_components/album_slideshow/strings.json b/custom_components/album_slideshow/strings.json
index 9d66110..3f42f14 100644
--- a/custom_components/album_slideshow/strings.json
+++ b/custom_components/album_slideshow/strings.json
@@ -84,6 +84,26 @@
"icloud_url": "Shared album link",
"icloud_image_size": "Image quality"
}
+ },
+ "synology": {
+ "title": "Synology Photos",
+ "description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.",
+ "data": {
+ "synology_url": "DSM URL",
+ "synology_username": "Username",
+ "synology_password": "Password",
+ "synology_space": "Library",
+ "otp_code": "2FA code (only if enabled)"
+ }
+ },
+ "synology_select": {
+ "title": "Synology source",
+ "description": "Pick an album or show the whole library, and choose the image quality.",
+ "data": {
+ "album_name": "Album name",
+ "album": "Album",
+ "synology_image_size": "Image quality"
+ }
}
},
"error": {
@@ -100,7 +120,9 @@
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
"photoprism_user_required": "Enter both a username and password, or switch to App password.",
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
- "icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
+ "icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
+ "synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
+ "synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue."
}
},
"options": {
diff --git a/custom_components/album_slideshow/translations/en.json b/custom_components/album_slideshow/translations/en.json
index 9d66110..3f42f14 100644
--- a/custom_components/album_slideshow/translations/en.json
+++ b/custom_components/album_slideshow/translations/en.json
@@ -84,6 +84,26 @@
"icloud_url": "Shared album link",
"icloud_image_size": "Image quality"
}
+ },
+ "synology": {
+ "title": "Synology Photos",
+ "description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.",
+ "data": {
+ "synology_url": "DSM URL",
+ "synology_username": "Username",
+ "synology_password": "Password",
+ "synology_space": "Library",
+ "otp_code": "2FA code (only if enabled)"
+ }
+ },
+ "synology_select": {
+ "title": "Synology source",
+ "description": "Pick an album or show the whole library, and choose the image quality.",
+ "data": {
+ "album_name": "Album name",
+ "album": "Album",
+ "synology_image_size": "Image quality"
+ }
}
},
"error": {
@@ -100,7 +120,9 @@
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
"photoprism_user_required": "Enter both a username and password, or switch to App password.",
"invalid_icloud_url": "That does not look like an iCloud Shared Album link.",
- "icloud_cannot_connect": "Could not reach that album. Check the link is a current public share."
+ "icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.",
+ "synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.",
+ "synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue."
}
},
"options": {
diff --git a/custom_components/album_slideshow/www/album-slideshow-card.js b/custom_components/album_slideshow/www/album-slideshow-card.js
index 6bd3a01..b2f5000 100644
--- a/custom_components/album_slideshow/www/album-slideshow-card.js
+++ b/custom_components/album_slideshow/www/album-slideshow-card.js
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
-const VERSION = "1.4.0";
+const VERSION = "1.5.0";
const ANIMATED_TRANSITIONS = [
"fade",
diff --git a/custom_components/alexa_media/__pycache__/__init__.cpython-314.pyc b/custom_components/alexa_media/__pycache__/__init__.cpython-314.pyc
index d81640f..4fc6f84 100644
Binary files a/custom_components/alexa_media/__pycache__/__init__.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/__init__.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/alarm_control_panel.cpython-314.pyc b/custom_components/alexa_media/__pycache__/alarm_control_panel.cpython-314.pyc
index 5f1f2ad..ba54f62 100644
Binary files a/custom_components/alexa_media/__pycache__/alarm_control_panel.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/alarm_control_panel.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/alexa_entity.cpython-314.pyc b/custom_components/alexa_media/__pycache__/alexa_entity.cpython-314.pyc
index e25cacf..45b331a 100644
Binary files a/custom_components/alexa_media/__pycache__/alexa_entity.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/alexa_entity.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/alexa_media.cpython-314.pyc b/custom_components/alexa_media/__pycache__/alexa_media.cpython-314.pyc
index 752cf27..9a541b8 100644
Binary files a/custom_components/alexa_media/__pycache__/alexa_media.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/alexa_media.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/binary_sensor.cpython-314.pyc b/custom_components/alexa_media/__pycache__/binary_sensor.cpython-314.pyc
deleted file mode 100644
index 68459a9..0000000
Binary files a/custom_components/alexa_media/__pycache__/binary_sensor.cpython-314.pyc and /dev/null differ
diff --git a/custom_components/alexa_media/__pycache__/config_flow.cpython-314.pyc b/custom_components/alexa_media/__pycache__/config_flow.cpython-314.pyc
index fe91f7a..a373ce0 100644
Binary files a/custom_components/alexa_media/__pycache__/config_flow.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/config_flow.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/const.cpython-314.pyc b/custom_components/alexa_media/__pycache__/const.cpython-314.pyc
index cf00f4c..11065b0 100644
Binary files a/custom_components/alexa_media/__pycache__/const.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/const.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/coordinator.cpython-314.pyc b/custom_components/alexa_media/__pycache__/coordinator.cpython-314.pyc
index a51f5cd..500e665 100644
Binary files a/custom_components/alexa_media/__pycache__/coordinator.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/coordinator.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/diagnostics.cpython-314.pyc b/custom_components/alexa_media/__pycache__/diagnostics.cpython-314.pyc
index e8cd81a..240fd97 100644
Binary files a/custom_components/alexa_media/__pycache__/diagnostics.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/diagnostics.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/exceptions.cpython-314.pyc b/custom_components/alexa_media/__pycache__/exceptions.cpython-314.pyc
index 620c7d2..f408fda 100644
Binary files a/custom_components/alexa_media/__pycache__/exceptions.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/exceptions.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/helpers.cpython-314.pyc b/custom_components/alexa_media/__pycache__/helpers.cpython-314.pyc
index 6a776d4..9daf9ec 100644
Binary files a/custom_components/alexa_media/__pycache__/helpers.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/helpers.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/light.cpython-314.pyc b/custom_components/alexa_media/__pycache__/light.cpython-314.pyc
index a9b8357..d28c777 100644
Binary files a/custom_components/alexa_media/__pycache__/light.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/light.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/media_player.cpython-314.pyc b/custom_components/alexa_media/__pycache__/media_player.cpython-314.pyc
index 0e32963..6f7f727 100644
Binary files a/custom_components/alexa_media/__pycache__/media_player.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/media_player.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/metrics.cpython-314.pyc b/custom_components/alexa_media/__pycache__/metrics.cpython-314.pyc
index bfb39c1..23f4f38 100644
Binary files a/custom_components/alexa_media/__pycache__/metrics.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/metrics.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/notify.cpython-314.pyc b/custom_components/alexa_media/__pycache__/notify.cpython-314.pyc
index 223e109..b08430f 100644
Binary files a/custom_components/alexa_media/__pycache__/notify.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/notify.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/runtime_data.cpython-314.pyc b/custom_components/alexa_media/__pycache__/runtime_data.cpython-314.pyc
index cd75399..93badbf 100644
Binary files a/custom_components/alexa_media/__pycache__/runtime_data.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/runtime_data.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/sensor.cpython-314.pyc b/custom_components/alexa_media/__pycache__/sensor.cpython-314.pyc
index 1ac31e8..bc4af51 100644
Binary files a/custom_components/alexa_media/__pycache__/sensor.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/sensor.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/services.cpython-314.pyc b/custom_components/alexa_media/__pycache__/services.cpython-314.pyc
index a41f6aa..aa871fe 100644
Binary files a/custom_components/alexa_media/__pycache__/services.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/services.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/__pycache__/switch.cpython-314.pyc b/custom_components/alexa_media/__pycache__/switch.cpython-314.pyc
index 8fd4063..c3fd8f8 100644
Binary files a/custom_components/alexa_media/__pycache__/switch.cpython-314.pyc and b/custom_components/alexa_media/__pycache__/switch.cpython-314.pyc differ
diff --git a/custom_components/alexa_media/config_flow.py b/custom_components/alexa_media/config_flow.py
index 350a1e2..2b86d35 100644
--- a/custom_components/alexa_media/config_flow.py
+++ b/custom_components/alexa_media/config_flow.py
@@ -820,7 +820,8 @@ class AlexaMediaFlowHandler(config_entries.ConfigFlow):
if CONF_PASSWORD in user_input:
self.config[CONF_PASSWORD] = user_input[CONF_PASSWORD]
if CONF_URL in user_input:
- self.config[CONF_URL] = user_input[CONF_URL]
+ # Remove accidental whitespace introduced by mobile keyboards/paste.
+ self.config[CONF_URL] = "".join(user_input[CONF_URL].split())
if CONF_PUBLIC_URL in user_input:
if not user_input[CONF_PUBLIC_URL].endswith("/"):
user_input[CONF_PUBLIC_URL] = user_input[CONF_PUBLIC_URL] + "/"
diff --git a/custom_components/alexa_media/manifest.json b/custom_components/alexa_media/manifest.json
index 0a32955..5f888d8 100644
--- a/custom_components/alexa_media/manifest.json
+++ b/custom_components/alexa_media/manifest.json
@@ -14,5 +14,5 @@
"wrapt>=1.14.0",
"dictor>=0.1.12,<0.2"
],
- "version": "5.15.6"
+ "version": "5.15.7"
}
diff --git a/custom_components/alexa_media/sensor.py b/custom_components/alexa_media/sensor.py
index 5e0d2f4..8a8cffd 100644
--- a/custom_components/alexa_media/sensor.py
+++ b/custom_components/alexa_media/sensor.py
@@ -664,7 +664,7 @@ class AlexaMediaNotificationSensor(SensorEntity):
next_item["snoozedToTime"] = None
return value
- def _is_active_notification(self, item, now):
+ def _is_active_notification(self, item, _now):
"""Return whether a notification should be considered active."""
status = item[1].get("status")
if status == "ON":
diff --git a/custom_components/alexa_media/translations/fr.json b/custom_components/alexa_media/translations/fr.json
index caf325e..2dfb83b 100644
--- a/custom_components/alexa_media/translations/fr.json
+++ b/custom_components/alexa_media/translations/fr.json
@@ -3,32 +3,32 @@
"abort": {
"forgot_password": "La page de réinitialisation du mot de passe a été détectée. Cela résulte généralement de trop nombreuses tentatives de connexion échouées. Amazon peut exiger une action avant qu'une nouvelle connexion ne puisse être tentée.",
"login_failed": "Alexa Media Player n'a pas réussi à se connecter.",
- "reauth_successful": "Alexa Media Player s'est ré-authentifié avec succès. Veuillez ignorer le message \"Abandonné\" de Home Assistant."
+ "reauth_successful": "Alexa Media Player s'est ré-authentifié avec succès. Veuillez ignorer le message « Abandonné » de Home Assistant."
},
"error": {
"2fa_key_invalid": "{otp_secret} n'est pas valide",
- "connection_error": "Erreur de connexion ; vérifiez le réseau et réessayez",
+ "connection_error": "Erreur de connexion ; vérifiez le réseau et réessayez",
"identifier_exists": "L'adresse e-mail pour cette URL Alexa est déjà enregistrée",
"invalid_auth": "La connexion a échoué. Veuillez vérifier votre adresse e-mail, votre mot de passe et votre clé d'authentification.",
"invalid_credentials": "Identifiants invalides",
- "invalid_url": "L'URL n'est pas valide: {message}",
+ "invalid_url": "L'URL n'est pas valide : {message}",
"oauth_error": "Impossible de terminer la connexion OAuth. Veuillez réessayer.",
"unable_to_connect_hass_url": "Impossible de se connecter à l'URL locale de Home Assistant. Veuillez vérifier l'URL sous Paramètres > Système > Réseau > URL de Home Assistant > Réseau local",
- "unknown_error": "Erreur inconnue : {message}"
+ "unknown_error": "Erreur inconnue : {message}"
},
"step": {
"proxy_warning": {
"data": {
"proxy_warning": "Ignorer et continuer - Je comprends qu'aucune assistance pour les problèmes de connexion ne sera fournie si je contourne cet avertissement."
},
- "description": "Le serveur Home Assistant ne peut pas se connecter à l'URL fournie : {hass_url}.\n> {error}\n\nPour résoudre ce problème, veuillez confirmer que votre navigateur peut atteindre {hass_url}. Ce champ provient de Paramètres > Système > Réseau > URL de Home Assistant.\n\nSi vous êtes **certain** que votre navigateur peut accéder à cette URL, vous pouvez ignorer cet avertissement.",
+ "description": "Le serveur Home Assistant ne peut pas se connecter à l'URL fournie : {hass_url}.\n> {error}\n\nPour résoudre ce problème, veuillez confirmer que votre navigateur peut atteindre {hass_url}. Ce champ provient de Paramètres > Système > Réseau > URL de Home Assistant.\n\nSi vous êtes **certain** que votre navigateur peut accéder à cette URL, vous pouvez ignorer cet avertissement.",
"title": "Alexa Media Player - Impossible de se connecter à l'URL de HA"
},
"totp_register": {
"data": {
"registered": "Oui, le code OTP a été vérifié"
},
- "description": "**{email} - alexa.{url}**\nAvez-vous vérifié le code OTP dans la validation en deux étapes Amazon ?\n> Code OTP : {message}",
+ "description": "**{email} - alexa.{url}**\nAvez-vous vérifié le code OTP dans la validation en deux étapes Amazon ?\n> Code OTP : {message}",
"title": "Alexa Media Player - Confirmation OTP"
},
"user": {
@@ -46,10 +46,10 @@
"scan_interval": "Intervalle d'interrogation programmé (secondes)",
"securitycode": "Mot de passe à usage unique (OTP)",
"should_get_network": "Découvrir le réseau Alexa",
- "url": "Domaine de la région Amazon (ex : amazon.fr)"
+ "url": "Domaine de la région Amazon (ex : amazon.fr)"
},
"data_description": {
- "debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé. \n Non recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux. \n Assurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.",
+ "debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé.\nNon recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux.\nAssurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.",
"otp_secret": "Exemple : 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
}
}
@@ -58,7 +58,7 @@
"entity": {
"sensor": {
"air_quality": {
- "name": "qualité de l'air"
+ "name": "Qualité de l'air"
},
"air_quality_carbon_monoxide": {
"name": "Monoxyde de carbone"
@@ -67,7 +67,7 @@
"name": "Humidité"
},
"air_quality_indoor_air_quality": {
- "name": "qualité de l'air intérieur"
+ "name": "Qualité de l'air intérieur"
},
"air_quality_particulate_matter": {
"name": "Matières particulaires"
@@ -82,7 +82,7 @@
"name": "Prochain rappel"
},
"next_timer": {
- "name": "La prochaine fois"
+ "name": "Prochain minuteur"
},
"temperature": {
"name": "Température"
@@ -117,11 +117,11 @@
"otp_secret": "Clé d'authentification à 52 caractères pour Amazon 2SV",
"public_url": "URL publique partagée avec les services externes hébergés",
"queue_delay": "Délai pour regrouper plusieurs commandes (secondes)",
- "scan_interval": "Fréquence d'interrogation programmée (secondes)",
+ "scan_interval": "Intervalle d'interrogation programmé (secondes)",
"should_get_network": "Découvrir le réseau Alexa"
},
"data_description": {
- "debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé. \n Non recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux. \n Assurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.",
+ "debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé.\nNon recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux.\nAssurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.",
"otp_secret": "Exemple : 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA"
},
"description": "* Champs obligatoires",
@@ -135,7 +135,7 @@
"fields": {
"email": {
"description": "Adresse e-mail du compte Alexa ou liste d'adresses (facultatif). Si vide, tous les comptes connus seront actualisés.",
- "name": "Adresse email"
+ "name": "Adresse e-mail"
}
},
"name": "Activer la découverte du réseau"
@@ -155,7 +155,7 @@
"fields": {
"entity_id": {
"description": "Entité pour laquelle obtenir l'historique",
- "name": "Sélectionner le lecteur multimédia:"
+ "name": "Sélectionner le lecteur multimédia :"
},
"entries": {
"description": "Nombre d'entrées à récupérer",
@@ -169,7 +169,7 @@
"fields": {
"entity_id": {
"description": "Entité sur laquelle restaurer le niveau de volume précédent",
- "name": "Sélectionner le lecteur multimédia:"
+ "name": "Sélectionner le lecteur multimédia :"
}
},
"name": "Restaurer le volume précédent"
diff --git a/custom_components/ha_mcp_tools/__init__.py b/custom_components/ha_mcp_tools/__init__.py
index 6e6fcde..96eeafb 100644
--- a/custom_components/ha_mcp_tools/__init__.py
+++ b/custom_components/ha_mcp_tools/__init__.py
@@ -1000,7 +1000,8 @@ def _is_path_allowed_for_read(
Allowed:
- Files directly in config dir: configuration.yaml, automations.yaml, etc.
- - Files in allowed directories: www/, themes/, custom_templates/
+ - Files in allowed directories: www/, themes/, custom_templates/,
+ dashboards/, blueprints/ (blueprints is read-only — issue #1965)
- Files matching patterns: /*.yaml, custom_components/**/*.py
- User-configured extra directories (``extra_dirs``), granted read+write
(issue #1567)
diff --git a/custom_components/ha_mcp_tools/__pycache__/__init__.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/__init__.cpython-314.pyc
index 9c151fa..a5b6731 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/__init__.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/__init__.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/config_flow.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/config_flow.cpython-314.pyc
index a7fd3a2..2772226 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/config_flow.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/config_flow.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/const.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/const.cpython-314.pyc
index 0c2eba4..1f37edc 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/const.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/const.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/coordinator.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/coordinator.cpython-314.pyc
index 76eee24..db5f5f9 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/coordinator.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/coordinator.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/embedded_entry.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/embedded_entry.cpython-314.pyc
index a4f5478..8c01c75 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/embedded_entry.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/embedded_entry.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/embedded_server.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/embedded_server.cpython-314.pyc
index f58000e..b60b7e5 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/embedded_server.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/embedded_server.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/embedded_setup.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/embedded_setup.cpython-314.pyc
index 5b88826..9d0ede1 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/embedded_setup.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/embedded_setup.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/hacs_nudge.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/hacs_nudge.cpython-314.pyc
index e75c088..982bdf8 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/hacs_nudge.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/hacs_nudge.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/install_source_check.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/install_source_check.cpython-314.pyc
index 1a022c7..12fc218 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/install_source_check.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/install_source_check.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/llm_api.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/llm_api.cpython-314.pyc
index 84954dc..b606a7d 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/llm_api.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/llm_api.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/mcp_webhook.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/mcp_webhook.cpython-314.pyc
index 26a0bd4..c049402 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/mcp_webhook.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/mcp_webhook.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/oauth_autoapprove.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/oauth_autoapprove.cpython-314.pyc
index 6256b0a..85c3516 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/oauth_autoapprove.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/oauth_autoapprove.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/oauth_legacy.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/oauth_legacy.cpython-314.pyc
index 93f9d80..f4cfc3a 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/oauth_legacy.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/oauth_legacy.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/ui_panel.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/ui_panel.cpython-314.pyc
index 23db671..f51659e 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/ui_panel.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/ui_panel.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/update.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/update.cpython-314.pyc
index e8ef2fe..3a53b9d 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/update.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/update.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/websocket_api.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/websocket_api.cpython-314.pyc
index a04cebe..3eade2e 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/websocket_api.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/websocket_api.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/__pycache__/yaml_rt.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/yaml_rt.cpython-314.pyc
index eec3a5f..c4ff7e7 100644
Binary files a/custom_components/ha_mcp_tools/__pycache__/yaml_rt.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/yaml_rt.cpython-314.pyc differ
diff --git a/custom_components/ha_mcp_tools/const.py b/custom_components/ha_mcp_tools/const.py
index 762a5b0..dcbdea2 100644
--- a/custom_components/ha_mcp_tools/const.py
+++ b/custom_components/ha_mcp_tools/const.py
@@ -24,7 +24,7 @@ DOMAIN = "ha_mcp_tools"
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
# capability negotiation — not this version — gates each WS command (see
# ``websocket_api.CAPABILITIES``).
-COMPONENT_VERSION = "1.2.2"
+COMPONENT_VERSION = "1.2.3"
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
# means "tools" so the pre-existing services entry keeps working across the
@@ -40,8 +40,17 @@ TOOLS_ENTRY_TITLE = "HA-MCP File & YAML Tools"
TOOLS_ENTRY_LEGACY_TITLE = "HA MCP Tools"
MIN_EMBEDDED_HOME_ASSISTANT_VERSION = "2026.6.0"
-# Allowed directories for file operations (relative to config dir)
-ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards"]
+# Allowed directories for file operations (relative to config dir).
+# "blueprints" is read-only BY DEFAULT — in ALLOWED_READ_DIRS but not
+# ALLOWED_WRITE_DIRS, so ha_write_file / ha_delete_file reject it (raw blueprint
+# reads are safe: community YAML, no secrets — issue #1965). This is the default
+# allowlist, not an absolute guarantee: an admin who adds "blueprints" as a
+# custom extra directory (issue #1567, see _current_extra_dirs) grants it
+# read+write, since extra_dirs are honored on the write path too. Blueprint
+# writes should instead go through ha_import_blueprint (which invokes the
+# blueprint/save WS command internally). Prefer ha_get_blueprint for the parsed
+# body; raw read is the escape hatch for the exact on-disk text.
+ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards", "blueprints"]
ALLOWED_WRITE_DIRS = ["www", "themes", "custom_templates", "dashboards"]
# NON-OVERRIDABLE deny floor for the user-configurable extra read/write
diff --git a/custom_components/ha_mcp_tools/embedded_server.py b/custom_components/ha_mcp_tools/embedded_server.py
index c2e3158..116fbed 100644
--- a/custom_components/ha_mcp_tools/embedded_server.py
+++ b/custom_components/ha_mcp_tools/embedded_server.py
@@ -1568,6 +1568,64 @@ _PENDING_INSTALL_DONE: threading.Event | None = None
_CACHED_IMPORT_VERSION: str | None = None
+def _safe_invalidate_caches() -> None:
+ """Run ``importlib.invalidate_caches()``, completing it if a finder breaks.
+
+ On Python 3.14 with ``homeassistant`` installed as a setuptools *editable*
+ package (the official HA container image), ``PathFinder.invalidate_caches()``
+ raises ``KeyError`` at its ``del sys.path_importer_cache[name]`` line: the
+ synthetic ``__editable__..finder.__path_hook__`` placeholder is not an
+ absolute path, so CPython takes the ``del`` branch on a key an earlier
+ iteration already removed — a CPython 3.14 bug (the ``del`` should be a
+ ``pop``). That aborts ``importlib.invalidate_caches()`` partway and, before
+ this guard, crashed in-process server bring-up on every boot (issues #1891,
+ #1985).
+
+ On that ``KeyError`` we do CPython's own cleanup ourselves — prune the dead /
+ relative ``sys.path_importer_cache`` entries with ``pop`` instead of the
+ buggy ``del`` (the stale placeholder that trips the sweep is among them) —
+ then re-run ``importlib.invalidate_caches()``. With the offending entries
+ gone the retry completes CPython's full sweep itself: every live path-entry
+ finder invalidated, the namespace-path epoch advanced, and the metadata
+ finder refreshed. Delegating the second pass keeps us off private internals
+ (no ``_NamespacePath`` / ``_path_isabs`` poking) and faithful to whatever the
+ running Python's ``invalidate_caches`` does. Recovery only ever runs on the
+ broken 3.14 path (the top-level call succeeds everywhere else); every
+ non-``KeyError`` still propagates.
+
+ The retry is itself guarded: a concurrent import on another HA-core thread
+ could re-add a stale placeholder in the window between the prune and the
+ retry, so a *second* ``KeyError`` is tolerated (logged, best-effort) rather
+ than re-raised — a partial cache refresh must never re-crash bring-up, which
+ is the whole point of this helper. The recovery is logged at WARNING (it
+ recurs on every version check on an affected install), so it is visible for
+ diagnosis rather than a silent workaround.
+ """
+ try:
+ importlib.invalidate_caches()
+ return
+ except KeyError as err:
+ _LOGGER.warning(
+ "importlib.invalidate_caches() raised KeyError from a broken "
+ "(setuptools editable / Python 3.14) finder; pruning stale "
+ "sys.path_importer_cache entries and retrying: %s",
+ err,
+ )
+ for name in list(sys.path_importer_cache):
+ if sys.path_importer_cache.get(name) is None or not os.path.isabs(name):
+ sys.path_importer_cache.pop(name, None)
+ try:
+ importlib.invalidate_caches()
+ except KeyError as err:
+ _LOGGER.warning(
+ "importlib.invalidate_caches() still raised KeyError after pruning "
+ "stale sys.path_importer_cache entries; continuing with a best-effort "
+ "cache state (a concurrent import may have re-added the placeholder): "
+ "%s",
+ err,
+ )
+
+
def _purge_ha_mcp_modules() -> None:
"""Drop every cached ``ha_mcp`` module so the next import loads fresh code.
@@ -1595,7 +1653,7 @@ def _purge_ha_mcp_modules() -> None:
return
for name in purged:
sys.modules.pop(name, None)
- importlib.invalidate_caches()
+ _safe_invalidate_caches()
_LOGGER.debug("Purged %d cached ha_mcp module(s) before worker start", len(purged))
@@ -1608,7 +1666,7 @@ def _installed_ha_mcp_version(preferred_dist: str | None = None) -> str | None:
provided, checks that channel first so stale metadata from a failed
best-effort conflicting uninstall cannot mask the package just installed.
"""
- importlib.invalidate_caches()
+ _safe_invalidate_caches()
# Metadata alone is not proof: a channel switch's best-effort uninstall
# can leave ORPHANED .dist-info whose files are gone (the shared ha_mcp/
# tree belongs to whichever dist installed last). Require the import
@@ -1631,7 +1689,7 @@ def _dist_installed(dist_name: str) -> bool:
Invalidates the import caches first so a just-completed (un)install is seen.
"""
- importlib.invalidate_caches()
+ _safe_invalidate_caches()
try:
importlib.metadata.version(dist_name)
except importlib.metadata.PackageNotFoundError:
@@ -1648,7 +1706,7 @@ def _installed_dist_version(dist_name: str) -> str | None:
the auto-update check compares the newest PyPI build against the version of
the channel actually installed.
"""
- importlib.invalidate_caches()
+ _safe_invalidate_caches()
try:
return importlib.metadata.version(dist_name)
except importlib.metadata.PackageNotFoundError:
diff --git a/custom_components/ha_mcp_tools/manifest.json b/custom_components/ha_mcp_tools/manifest.json
index 1b2adee..27726c8 100644
--- a/custom_components/ha_mcp_tools/manifest.json
+++ b/custom_components/ha_mcp_tools/manifest.json
@@ -22,5 +22,5 @@
"requirements": [
"ruamel.yaml>=0.18.0"
],
- "version": "1.2.2"
+ "version": "1.2.3"
}
diff --git a/custom_components/ha_mcp_tools/mcp_webhook.py b/custom_components/ha_mcp_tools/mcp_webhook.py
index df4e72f..eb18cda 100644
--- a/custom_components/ha_mcp_tools/mcp_webhook.py
+++ b/custom_components/ha_mcp_tools/mcp_webhook.py
@@ -486,6 +486,13 @@ def _register_metadata_views(hass: HomeAssistant) -> None:
"""
if hass.data.get(_OAUTH_VIEWS_REGISTERED_KEY):
return
+ # Set the flag only AFTER every view registers (issue #1978): it must mean
+ # "the full bundle is bound", so a partial bind stays distinguishable from a
+ # complete one. Marking it bound early would let a later setup assign a
+ # provider and advertise discovery while some RFC metadata routes are still
+ # unbound — a 404 for the clients that probe them. On a partial bind the flag
+ # stays unset; the none-mode caller then fails open (the retry's duplicate
+ # register is caught harmlessly) while ha_auth/legacy fail closed.
for view in _metadata_views(hass):
hass.http.register_view(view)
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
@@ -735,9 +742,28 @@ async def async_register_webhook(
# login (issue #1969). Both view bundles bind at most once per
# HA session; the per-request resolvers gate them on this cfg,
# so a none<->ha_auth switch needs no restart.
- _register_metadata_views(hass)
- bind_autoapprove_views(hass)
- cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider()
+ #
+ # Fails OPEN, unlike ha_auth/legacy (issue #1978): none mode is
+ # intentionally unauthenticated, and this discovery is an
+ # enhancement layered on a webhook that (already registered
+ # above) otherwise always forwards. A failure here must NOT fall
+ # through to the outer teardown and take down a webhook the user
+ # configured to need no auth — it only means claude.ai's rare
+ # OAuth-discovery fallback goes unassisted. Mirrors the add-on's
+ # _setup_none_autoapprove. Provider is assigned last, so a
+ # partial bind leaves none-autoapprove inactive (plain proxy)
+ # rather than half-enabled.
+ try:
+ _register_metadata_views(hass)
+ bind_autoapprove_views(hass)
+ cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider()
+ except Exception:
+ _LOGGER.exception(
+ "MCP webhook: failed to set up none-mode auto-approve "
+ "discovery; continuing as a plain unauthenticated proxy "
+ "(the webhook still forwards — only claude.ai's rare "
+ "OAuth-discovery fallback is unassisted)."
+ )
except Exception:
# Never leave a live endpoint (or a leaked session) behind a failed
# auth-setup path. suppress: the ORIGINAL error must be what
diff --git a/custom_components/ha_mcp_tools/oauth_autoapprove.py b/custom_components/ha_mcp_tools/oauth_autoapprove.py
index 18b34fd..76ea9e5 100644
--- a/custom_components/ha_mcp_tools/oauth_autoapprove.py
+++ b/custom_components/ha_mcp_tools/oauth_autoapprove.py
@@ -185,6 +185,18 @@ class AutoApproveAuthorizeView(HomeAssistantView):
open-redirect gate, then issues a PKCE-bound one-time code and redirects
straight back to the client. No login page and no consent screen render, so
claude.ai's OAuth flow completes invisibly (issue #1969).
+
+ ACCEPTED RISK (issue #1978): this endpoint is anonymous by design — none
+ mode requires zero HA login — so it consults neither the webhook id nor a
+ client identity. Anyone who knows the HA origin can therefore fill the
+ shared pending-code store (``MAX_PENDING_CODES``) with S256 challenges bound
+ to the public claude.ai callback, at which point a *brand-new* connector's
+ handshake gets ``temporarily_unavailable`` until those codes expire
+ (``AUTH_CODE_TTL``, 5 min). Accepted because it is self-healing, exposes no
+ data, and grants no access: completing the flow needs the PKCE verifier the
+ attacker never has, and the issued token is cosmetic (none mode ignores
+ bearers). The webhook URL itself keeps forwarding throughout — only the rare
+ OAuth-discovery fallback for a *first* connect is briefly delayed.
"""
requires_auth = False
@@ -298,6 +310,11 @@ def bind_autoapprove_views(hass: HomeAssistant) -> None:
"""
if hass.data.get(_AUTOAPPROVE_VIEWS_REGISTERED_KEY):
return
+ # Set the flag only AFTER both views register (issue #1978): see
+ # mcp_webhook._register_metadata_views. Marking the bundle bound before
+ # /token registers would let a later none-mode setup assign the provider and
+ # advertise OAuth with an unbound /token — a 404 on the token exchange. The
+ # flag must mean the full bundle succeeded; a partial bind leaves it unset.
hass.http.register_view(AutoApproveAuthorizeView(hass))
hass.http.register_view(AutoApproveTokenView(hass))
hass.data[_AUTOAPPROVE_VIEWS_REGISTERED_KEY] = True
diff --git a/custom_components/ha_mcp_tools/oauth_legacy.py b/custom_components/ha_mcp_tools/oauth_legacy.py
index 80c5c1b..46805b6 100644
--- a/custom_components/ha_mcp_tools/oauth_legacy.py
+++ b/custom_components/ha_mcp_tools/oauth_legacy.py
@@ -436,7 +436,11 @@ class PKCECodeStore:
Returns True only for a live, unexpired code whose stored
``redirect_uri`` matches and whose ``code_challenge`` equals
``base64url(SHA-256(code_verifier))``. The code is popped (one-shot)
- before any check that can fail, so a failed attempt still burns it.
+ once the verifier clears the cheap RFC 7636 shape guard below, so every
+ well-formed attempt burns it — a wrong verifier, expired code, or
+ redirect mismatch still consumes the code. A malformed verifier is
+ rejected before the pop and does NOT burn the code, so a client that
+ sends a syntactically broken verifier can retry.
"""
# Validate the verifier shape per RFC 7636 §4.1 before doing any
# crypto. A confused client passing an empty/short verifier should be
diff --git a/custom_components/ha_mcp_tools/services.yaml b/custom_components/ha_mcp_tools/services.yaml
index 552d4fe..4370503 100644
--- a/custom_components/ha_mcp_tools/services.yaml
+++ b/custom_components/ha_mcp_tools/services.yaml
@@ -58,7 +58,10 @@ list_files:
fields:
path:
name: Path
- description: Relative path from config directory (e.g., "www/", "themes/")
+ description: >-
+ Relative path from config directory. Allowed: www/, themes/,
+ custom_templates/, dashboards/, blueprints/, plus your configured
+ packages/ folder (e.g., "www/", "blueprints/automation").
required: true
example: "www/"
selector:
@@ -81,7 +84,8 @@ read_file:
Relative path from config directory. Allowed paths include
configuration.yaml, automations.yaml, scripts.yaml, scenes.yaml,
secrets.yaml (values masked), home-assistant.log, www/**, themes/**,
- custom_templates/**, packages/*.yaml, custom_components/**/*.py
+ custom_templates/**, dashboards/**, blueprints/**, packages/*.yaml,
+ custom_components/**/*.py
required: true
example: "configuration.yaml"
selector:
@@ -119,13 +123,13 @@ read_file:
write_file:
name: Write File
- description: Write a file to allowed directories (www/, themes/, custom_templates/).
+ description: Write a file to allowed directories (www/, themes/, custom_templates/, dashboards/).
fields:
path:
name: Path
description: >-
- Relative path from config directory. Must be in www/, themes/, or
- custom_templates/.
+ Relative path from config directory. Must be in www/, themes/,
+ custom_templates/, or dashboards/.
required: true
example: "www/custom.css"
selector:
@@ -155,13 +159,13 @@ write_file:
delete_file:
name: Delete File
- description: Delete a file from allowed directories (www/, themes/, custom_templates/).
+ description: Delete a file from allowed directories (www/, themes/, custom_templates/, dashboards/).
fields:
path:
name: Path
description: >-
- Relative path from config directory. Must be in www/, themes/, or
- custom_templates/.
+ Relative path from config directory. Must be in www/, themes/,
+ custom_templates/, or dashboards/.
required: true
example: "www/old-file.css"
selector:
diff --git a/custom_components/ha_washdata/__init__.py b/custom_components/ha_washdata/__init__.py
index 7395514..52e31be 100644
--- a/custom_components/ha_washdata/__init__.py
+++ b/custom_components/ha_washdata/__init__.py
@@ -18,6 +18,7 @@
from __future__ import annotations
+import asyncio
import json
import logging
from pathlib import Path
@@ -1035,6 +1036,20 @@ 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()
+
+ # Settle registry: mark any still-RUNNING tasks for this entry as
+ # cancelled so they don't appear as zombies after reload.
+ from . import task_registry as _task_registry
+ _cancelled_tasks = _task_registry.get_registry(hass).cancel_entry_tasks(entry.entry_id)
+ # Drain cancelled WS-spawned tasks so their finally blocks (lock releases)
+ # complete before we remove the write lock below.
+ if _cancelled_tasks:
+ await asyncio.gather(*_cancelled_tasks, return_exceptions=True)
+
+ # Release the per-entry write lock so it doesn't block the next setup.
+ from .ws_api import _WS_WRITE_LOCKS_KEY
+ hass.data.get(_WS_WRITE_LOCKS_KEY, {}).pop(entry.entry_id, None)
+
# 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):
diff --git a/custom_components/ha_washdata/analysis.py b/custom_components/ha_washdata/analysis.py
index 195d1f3..966c75b 100644
--- a/custom_components/ha_washdata/analysis.py
+++ b/custom_components/ha_washdata/analysis.py
@@ -67,6 +67,10 @@ def find_best_alignment(
n_curr = len(curr)
n_ref = len(ref)
+ # Guard: cross-correlation crashes on empty or single-element arrays.
+ if n_curr < 2 or n_ref < 2:
+ return 0.0, {"corr": 0.0, "mae_score": 0.0}, 0
+
# 1. Coarse Alignment (Cross-Correlation)
# Downsample for speed if arrays are large
ds_factor = 1
@@ -251,11 +255,12 @@ def _dtw_component_score(
band: float,
derivative: bool,
scale: float,
+ curr_resampled: np.ndarray | None = None,
) -> float:
"""DTW similarity in [0,1] for one candidate: resample both series to a
common grid, warp (level or derivative), and express the distance relative
to the current peak (behaviour-neutral at MATCH_MAE_REF_PEAK)."""
- a = _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
+ a = curr_resampled if curr_resampled is not None else _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
b = _resample_to(sample_arr, MATCH_DTW_RESAMPLE_N)
dtw_dist = compute_dtw_lite(a, b, band_width_ratio=band, derivative=derivative)
norm_dist = dtw_dist / MATCH_DTW_RESAMPLE_N
@@ -326,6 +331,8 @@ def compute_matches_worker(
l1_scale = float(config.get("dtw_l1_scale", MATCH_DTW_DIST_SCALE))
ddtw_scale = float(config.get("dtw_ddtw_scale", MATCH_DDTW_DIST_SCALE))
ensemble_w = float(config.get("dtw_ensemble_w", MATCH_DTW_ENSEMBLE_W))
+ # Resample the current trace once — it's the same for every candidate.
+ curr_resampled = _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
for cand in to_refine:
sample_arr = np.array(cand["sample"])
@@ -340,8 +347,8 @@ def compute_matches_worker(
elif dtw_mode == "ensemble":
# Blend the level-based (L1) and shape-based (derivative) DTW
# scores; they are complementary signals.
- s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale)
- s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale)
+ s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale, curr_resampled=curr_resampled)
+ s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale, curr_resampled=curr_resampled)
dtw_score = ensemble_w * s_l1 + (1.0 - ensemble_w) * s_dd
norm_dist = 0.0 # composite; per-component distance not meaningful
else:
@@ -352,7 +359,7 @@ def compute_matches_worker(
use_deriv = dtw_mode == "ddtw"
scale = ddtw_scale if use_deriv else l1_scale
dtw_score = _dtw_component_score(
- curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale
+ curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale, curr_resampled=curr_resampled
)
norm_dist = 0.0
diff --git a/custom_components/ha_washdata/const.py b/custom_components/ha_washdata/const.py
index f456abd..bcddcce 100644
--- a/custom_components/ha_washdata/const.py
+++ b/custom_components/ha_washdata/const.py
@@ -297,6 +297,27 @@ MATCH_RANKING_HISTORY_MAX = 500
# hard limit: this only surfaces "running longer than usual" for the UI. Kept
# below the zombie threshold so it lights up well before any termination.
CYCLE_OVERRUN_ANOMALY_RATIO = 1.5
+
+# Duration-anchored hard-finalize backstop in the ENDING state. Smart Termination
+# is (deliberately) gated on a confident, non-ambiguous match; an ambiguous /
+# prefix-ambiguous match therefore skips it and relies on the power+energy fallback
+# timeout, which a low standby baseline (below stop_threshold but energetic enough
+# to trip the energy gate) can hold open until the 8 h cap / zombie-kill — the
+# #296/#311 "finishes hours/1000+ min late" reports. This SEPARATE backstop
+# finalizes a matched cycle that has sat in ENDING well past its expected duration
+# AND been genuinely quiet (below stop_threshold) for a sustained span. It is
+# asymmetric (can only ever SHORTEN a stuck wait, never end a cycle early) and the
+# sustained-quiet guard is what keeps it from truncating a longer program that was
+# mismatched to a shorter profile — a real longer program has high-power phases
+# that keep resetting the below-threshold timer, so it never accumulates the
+# required continuous quiet. Sits between CYCLE_OVERRUN_ANOMALY_RATIO (1.5, soft
+# visible signal) and the manager's 3x zombie-kill, so it fires well after any
+# legitimate overrun but well before the hard kill.
+ENDING_HARD_FINALIZE_RATIO = 2.0
+ENDING_HARD_FINALIZE_MIN_QUIET_S = 600.0 # continuous sub-threshold span floor
+
+# NOTE: STANDBY_BAND_* constants live further down, after the DEVICE_TYPE_*
+# definitions they reference (search "Standby-band stuck-in-RUNNING finalize").
# Underrun anomaly: a cycle that finishes in less than this fraction of its
# matched profile's median duration is flagged "underrun" (post-cycle only,
# never a live signal — computed in _async_process_cycle_end after the cycle
@@ -536,6 +557,68 @@ DEVICE_TYPES = {
DEVICE_TYPE_OTHER: "Threshold Device",
}
+# Standby-band stuck-in-RUNNING finalize (#296). Some appliances finish but hold
+# a small, flat "anti-crease" / display standby draw that sits ABOVE stop_threshold_w
+# (e.g. a ~2.5-3.2 W baseline while stop_threshold is ~1.2 W). Because power never
+# drops below the stop threshold, _time_below_threshold never accumulates, so the
+# cycle never reaches PAUSED/ENDING and runs until the 8 h RUNNING cap — and the
+# anti-wrinkle handler can't help because entering it requires a completed
+# TIMEOUT/SMART finish that never happens (chicken-and-egg). This detector spots a
+# sustained, FLAT, tiny-fraction-of-peak plateau *past* the expected duration and
+# finalizes the cycle (as a normal TIMEOUT completion, so an anti-wrinkle-enabled
+# washer/dryer still routes into ANTI_WRINKLE afterwards). Heavily gated so it can
+# never end an active low-power phase: it needs a matched profile, elapsed >= 2x
+# expected, and the whole recent window flat + below a small fraction of the cycle's
+# own peak. Restricted to wet appliances where a stuck baseline is unambiguously
+# anomalous (bread-maker keep-warm, pump, air-fryer etc. have legitimate holds and
+# are excluded).
+STANDBY_BAND_FINALIZE_DEVICE_TYPES = (
+ DEVICE_TYPE_WASHING_MACHINE,
+ DEVICE_TYPE_WASHER_DRYER,
+ DEVICE_TYPE_DRYER,
+)
+STANDBY_BAND_MIN_RATIO = 2.0 # only past 2x the expected duration
+STANDBY_BAND_WINDOW_S = 600.0 # require a >=10 min flat plateau
+STANDBY_BAND_MAX_FRACTION = 0.10 # plateau level <= 10% of the cycle's peak
+STANDBY_BAND_FLATNESS_FRACTION = 0.03 # window (max-min) <= 3% of the cycle's peak
+STANDBY_BAND_FLATNESS_FLOOR_W = 2.0 # absolute flatness floor for low-peak devices
+
+# Issue #296 follow-up: anti-crease ("Knitterschutz") back-to-back handling.
+#
+# After a wash finishes, some machines (e.g. Miele) hold a low-power tumble tail -
+# a constant baseline plus periodic sub-``anti_wrinkle_max_power`` bursts, NO
+# heating - until the door is opened. To the power-off detector this looks like
+# continued RUNNING (the bursts recur faster than off_delay and keep reviving the
+# cycle out of ENDING), so the cycle never finalises into STATE_ANTI_WRINKLE - the
+# state that is designed to absorb the tail and split off the next wash. If a
+# second load is started before the door is opened, the whole sequence
+# (wash -> tail -> wash -> tail) merges into one multi-hour "cycle".
+#
+# Two coordinated mechanisms fix it, BOTH opt-in via ``anti_wrinkle_enabled`` and
+# gated to the anti-wrinkle device types (see ``anti_wrinkle_active`` in
+# cycle_detector):
+#
+# * a proactive finalise (``_is_anticrease_tail`` -> Smart Termination into
+# ANTI_WRINKLE): once a *matched* cycle is past its expected duration AND the
+# recent window shows only the low-power tail, finalise without waiting to reach
+# ENDING through the burst-defeated off_delay path;
+# * a match freeze (``_try_profile_match`` guard) under the SAME condition, so
+# re-matching on the growing flat tail cannot drift the label to a longer
+# near-duplicate profile (which would push expected_duration out and break the
+# finalise gate) - the field failure that breaks Smart Termination.
+#
+# Gated on ``elapsed >= expected * ratio`` so a legitimate mid-wash low-power phase
+# can NEVER trigger it: a washer spends most of its cycle below
+# ``anti_wrinkle_max_power`` (only brief heating spikes exceed it), but every
+# observed clean cycle's mid-cycle sub-max_power gap ENDS well before its expected
+# duration, while the anti-crease tail BEGINS after it. Also requires a genuinely
+# energetic cycle (peak above ``anti_wrinkle_max_power``) so a low-power program
+# that never heats is left alone. Asymmetric (finalise-only, can only shorten the
+# wait) and self-correcting (a new wash's heating burst leaves the regime and
+# re-arms matching).
+ANTI_CREASE_FINALIZE_RATIO = 0.98 # elapsed must reach 98% of expected duration
+ANTI_CREASE_CONFIRM_WINDOW_S = 180.0 # recent window that must hold no reading > max_power
+
# Device Type Defaults
# Device Type Defaults (Maps)
@@ -618,6 +701,26 @@ DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS = 600.0
# a shorter window can never fire it mid-cycle.
DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS = 300.0
+# Sustained "true off" (power below stop_threshold_w) window that cancels a
+# user-paused STARTING state back to OFF. A user pause during STARTING is held
+# indefinitely (issue #306) waiting for Resume Cycle, but a genuinely paused
+# appliance keeps standby power above the stop threshold; sustained power below it
+# means the machine was switched off, so without this the detector could stay
+# pinned in STARTING forever. Generous enough not to abort a real pause whose
+# standby briefly dips (and well above the issue-#306 test's 10 s hold), while
+# bounding the pinned-forever case.
+STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS = 300.0
+
+# Upper bound on the washer / washer-dryer Smart-Termination debounce, which is
+# otherwise derived as max(180, min_off_gap * 0.5). At the shipped defaults this
+# is 240 s (washing machine, min_off_gap 480) / 300 s (washer-dryer, min_off_gap
+# 600) and the cap never bites. It only bounds the case where a suggested or
+# hand-set min_off_gap (e.g. 1800 s) would inflate the quiet-time requirement to
+# 15 min, starving end-detection for confident non-ambiguous matches the same way
+# the old dishwasher off_delay*0.25 coupling did. 600 s leaves ample headroom for
+# a washer's longest legitimate mid-cycle soak trough while capping the pathology.
+WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS = 600.0
+
DEFAULT_OFF_DELAY_BY_DEVICE = {
DEVICE_TYPE_DISHWASHER: 1800, # 30 min (Drying)
DEVICE_TYPE_BREAD_MAKER: 300, # 5 min (Keep-warm phase after baking)
diff --git a/custom_components/ha_washdata/cycle_detector.py b/custom_components/ha_washdata/cycle_detector.py
index 4d88c2d..f378517 100644
--- a/custom_components/ha_washdata/cycle_detector.py
+++ b/custom_components/ha_washdata/cycle_detector.py
@@ -18,6 +18,7 @@
from __future__ import annotations
+import itertools
import logging
import math
from dataclasses import dataclass
@@ -51,9 +52,21 @@ from .const import (
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
DISHWASHER_END_SPIKE_WAIT_SECONDS,
DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS,
+ WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS,
+ STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS,
DISHWASHER_MATCH_FREEZE_QUIET_SECONDS,
DISHWASHER_MIN_CYCLE_DURATION_S,
TERMINAL_DROP_OFF_DELAY_SECONDS,
+ ENDING_HARD_FINALIZE_RATIO,
+ ENDING_HARD_FINALIZE_MIN_QUIET_S,
+ STANDBY_BAND_FINALIZE_DEVICE_TYPES,
+ STANDBY_BAND_MIN_RATIO,
+ STANDBY_BAND_WINDOW_S,
+ STANDBY_BAND_MAX_FRACTION,
+ STANDBY_BAND_FLATNESS_FRACTION,
+ STANDBY_BAND_FLATNESS_FLOOR_W,
+ ANTI_CREASE_FINALIZE_RATIO,
+ ANTI_CREASE_CONFIRM_WINDOW_S,
)
# The dishwasher end-spike wait window is shared between two code paths
@@ -312,6 +325,11 @@ class CycleDetector:
# OFF only when the machine has clearly been switched off rather
# than briefly dipped.
self._delay_wait_true_off_seconds: float = 0.0
+ # _starting_paused_off_since anchors the first below-stop reading while
+ # a user-paused STARTING state is held (issue #306). Elapsed time is
+ # measured from this anchor, not accumulated per-dt, so a single large
+ # (but sub-outage) interval cannot prematurely credit minutes of quiet time.
+ self._starting_paused_off_since: datetime | None = None
# _delay_wait_high_start anchors the first high-power reading
# observed inside DELAY_WAIT. We only transition to STARTING
# when the high-power streak has lasted at least
@@ -383,6 +401,18 @@ class CycleDetector:
):
return
+ # Terminal-tail match freeze (anti-crease, #296): once a washer/dryer with
+ # anti-wrinkle enabled is past its expected duration and has settled into
+ # the low-power tumble tail, re-matching on the growing flat tail drifts the
+ # label toward a LONGER near-duplicate (its expected duration grows), which
+ # pushes the anti-crease finalize gate out and breaks Smart Termination -
+ # the field failure that merges back-to-back washes. Keep the good
+ # pre-tail match instead. Self-correcting: a new wash's heating burst above
+ # anti_wrinkle_max_power leaves the tail regime, so this stops applying and
+ # matching re-arms for the next cycle.
+ if self._matched_profile and self._in_anticrease_freeze(timestamp):
+ return
+
# Rate limiting
if not force and self._last_match_time:
elapsed = (timestamp - self._last_match_time).total_seconds()
@@ -459,6 +489,23 @@ class CycleDetector:
Can be called by the matcher callback directly or asynchronously.
"""
+ # Terminal-tail match freeze (anti-crease, #296). This is the single sink
+ # for ALL match updates - the detector's own _try_profile_match AND the
+ # manager's async 5-min matcher (manager.py calls update_match directly).
+ # Once a washer/dryer with anti-wrinkle enabled is past its expected
+ # duration and has settled into the low-power tumble tail, re-matching on
+ # the growing flat tail drifts the label toward a LONGER near-duplicate (or
+ # flips it ambiguous), which pushes out expected_duration and would block
+ # the anti-crease finalize - the field failure that merges back-to-back
+ # washes. Keep the good pre-tail match instead. Self-correcting: a new
+ # wash's heating burst above anti_wrinkle_max_power leaves the tail regime,
+ # so this stops applying and matching re-arms for the next cycle.
+ if (
+ self._matched_profile
+ and self._power_readings
+ and self._in_anticrease_freeze(self._power_readings[-1][0])
+ ):
+ return
# Unpack 5 elements (or 4 for backward compatibility if needed, but wrapper is updated)
# wrapper returns (name, confidence, duration, phase, is_mismatch)
# Or MatchResult object if refactored, but currently wrapper returns tuple.
@@ -582,6 +629,13 @@ class CycleDetector:
self._time_below_threshold = 0.0
self._last_match_time = None
self._matched_profile = None
+ # Clear stale match state so the next cycle starts with clean defaults.
+ # _expected_duration left at 0 tells the dishwasher end-spike gate that
+ # no profile is matched yet; stale non-zero would mis-gate the spike check.
+ self._expected_duration = 0.0
+ self._last_match_confidence = 0.0
+ self._match_ambiguous = False
+ self._match_prefix_ambiguous = False
self._ignore_power_until_idle = False # Reset lockout
self._lockout_high_seconds = 0.0
# Clear the verified-pause flag so it can't leak into the next cycle (B6):
@@ -597,6 +651,7 @@ class CycleDetector:
self._delay_band_seconds = 0.0
self._delay_band_peak = 0.0
self._delay_wait_true_off_seconds = 0.0
+ self._starting_paused_off_since = None
self._delay_wait_high_start = None
@property
@@ -972,6 +1027,10 @@ class CycleDetector:
self._power_readings.append((timestamp, power))
self._cycle_max_power = max(self._cycle_max_power, power)
+ if is_high:
+ # Power back up - clear any accumulated "true off" hold time.
+ self._starting_paused_off_since = None
+
if self._time_above_threshold >= self._config.start_duration_threshold:
if self._energy_since_idle_wh >= self._config.start_energy_threshold:
self._transition_to(STATE_RUNNING, timestamp)
@@ -982,7 +1041,41 @@ class CycleDetector:
# that the low power is intentional, not a false start.
if not is_high and self._time_below_threshold > 1.0: # 1s grace period
if getattr(self, "_verified_pause", False):
- pass # user pause holds; wait for Resume Cycle
+ # User pause holds; wait for Resume Cycle (issue #306). But a
+ # genuinely paused appliance keeps standby power above the stop
+ # threshold - sustained power *below* it means the machine was
+ # switched off, so fall back to OFF rather than pinning STARTING
+ # forever.
+ if power < self._config.stop_threshold_w:
+ # An outage-sized gap since the last reading is NOT observed
+ # quiet (the machine may still be paused, we just lost
+ # telemetry): reset anchor so only genuinely-sampled
+ # sustained-off time can cancel a paused STARTING.
+ if dt > self._outage_threshold_s():
+ self._starting_paused_off_since = None
+ elif self._starting_paused_off_since is None:
+ # First below-stop reading: anchor the timestamp.
+ # Don't credit the preceding dt interval — we only
+ # know the device is off *now*, not how long before
+ # this sample it went quiet.
+ self._starting_paused_off_since = timestamp
+ observed_off_s = (
+ (timestamp - self._starting_paused_off_since).total_seconds()
+ if self._starting_paused_off_since is not None
+ else 0.0
+ )
+ if observed_off_s >= STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS:
+ self._logger.info(
+ "Paused STARTING cancelled: power off (%.1fW) for "
+ "%.0fs → OFF",
+ power,
+ observed_off_s,
+ )
+ self._transition_to(STATE_OFF, timestamp)
+ return
+ else:
+ # Power recovered — clear the off anchor.
+ self._starting_paused_off_since = None
else:
# False start
self._logger.debug(
@@ -999,6 +1092,13 @@ class CycleDetector:
self._power_readings.append((timestamp, power))
self._cycle_max_power = max(self._cycle_max_power, power)
+ # Anti-crease finalize (#296): a matched cycle past its expected
+ # duration that has settled into the low-power tumble tail is done -
+ # finalize into anti-wrinkle now instead of letting the periodic
+ # bursts keep reviving RUNNING until a second wash merges in.
+ if self._maybe_finalize_anticrease_tail(timestamp):
+ return
+
# Use dynamic threshold
thresh = self._dynamic_pause_threshold
if self._time_below_threshold >= thresh:
@@ -1008,16 +1108,71 @@ class CycleDetector:
# Periodic profile matching
self._try_profile_match(timestamp)
+ # Standby-band stuck finalize (#296): an appliance holding a flat
+ # low standby draw ABOVE stop_threshold never accumulates
+ # _time_below_threshold, so it never reaches PAUSED/ENDING. Detect
+ # the plateau and finalize as a normal completion (so anti-wrinkle
+ # still engages). Cheaply gated on being well past expected before
+ # the window scan runs.
+ if self._is_standby_band_stuck(timestamp):
+ start_time = self._current_cycle_start or timestamp
+ current_duration = (timestamp - start_time).total_seconds()
+ # The plateau sits ABOVE stop_threshold, so it keeps advancing
+ # _last_active_time and the default keep_tail=False trim would NOT
+ # remove it - inflating the stored duration/energy with minutes of
+ # standby. Snap the end back to the last real activity (the last
+ # reading above the plateau ceiling) and drop the trailing plateau.
+ level_ceiling = float(self._cycle_max_power) * STANDBY_BAND_MAX_FRACTION
+ plateau_start_idx = None
+ for i in range(len(self._power_readings) - 1, -1, -1):
+ if float(self._power_readings[i][1]) > level_ceiling:
+ plateau_start_idx = i
+ break
+ if (
+ plateau_start_idx is not None
+ and plateau_start_idx < len(self._power_readings) - 1
+ ):
+ self._power_readings = self._power_readings[: plateau_start_idx + 1]
+ self._last_active_time = self._power_readings[-1][0]
+ current_duration = (
+ self._last_active_time - start_time
+ ).total_seconds()
+ self._logger.info(
+ "Standby-band finalize: flat plateau ~%.1fW (peak %.0fW) held "
+ "past expected %.0fs — appliance finished but holds a standby "
+ "draw above stop_threshold; finalizing (plateau trimmed, "
+ "duration %.0fs).",
+ power,
+ self._cycle_max_power,
+ self._expected_duration,
+ current_duration,
+ )
+ self._finish_cycle(
+ timestamp,
+ status="completed",
+ termination_reason=TerminationReason.TIMEOUT,
+ keep_tail=False,
+ )
+ return
+
# Max duration safety
if (
self._current_cycle_start
and (timestamp - self._current_cycle_start).total_seconds() > 28800
): # 8h safety
- self._finish_cycle(timestamp, status="force_stopped")
+ self._finish_cycle(
+ timestamp,
+ status="force_stopped",
+ termination_reason=TerminationReason.FORCE_STOPPED,
+ )
elif self._state == STATE_PAUSED:
self._power_readings.append((timestamp, power))
+ # Anti-crease finalize (#296) - see the RUNNING branch.
+ if self._maybe_finalize_anticrease_tail(timestamp):
+ return
+
if is_high:
# Resume to RUNNING
self._transition_to(STATE_RUNNING, timestamp)
@@ -1032,6 +1187,25 @@ class CycleDetector:
elif self._state == STATE_ENDING:
self._power_readings.append((timestamp, power))
+ # Hard cap: ENDING must not run longer than RUNNING's 8 h safety limit.
+ # Without this a standby baseline can hold the state open indefinitely.
+ if (
+ self._current_cycle_start
+ and (timestamp - self._current_cycle_start).total_seconds() > 28800
+ ):
+ self._finish_cycle(
+ timestamp,
+ status="force_stopped",
+ termination_reason=TerminationReason.FORCE_STOPPED,
+ )
+ return
+
+ # Anti-crease finalize (#296) - see the RUNNING branch. Fires ahead of
+ # the is_high end-spike handling so a sub-max_power tail burst finalizes
+ # into anti-wrinkle instead of reviving RUNNING.
+ if self._maybe_finalize_anticrease_tail(timestamp):
+ return
+
if is_high:
start_time = self._current_cycle_start or timestamp
current_duration = (timestamp - start_time).total_seconds()
@@ -1196,8 +1370,14 @@ class CycleDetector:
# the soak-bridging min_off_gap before committing
# Smart Termination, so a near-duplicate profile
# doesn't cut a long cycle short during a mid-cycle
- # power trough.
- smart_debounce = max(180.0, self._config.min_off_gap * 0.5)
+ # power trough. Bounded above so a large suggested /
+ # hand-set min_off_gap can't inflate the quiet-time
+ # requirement and starve end-detection (see
+ # WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS).
+ smart_debounce = min(
+ WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS,
+ max(180.0, self._config.min_off_gap * 0.5),
+ )
else:
smart_debounce = 120.0
@@ -1276,6 +1456,64 @@ class CycleDetector:
)
return
+ # --- DURATION-ANCHORED HARD FINALIZE (backstop) ---
+ # Separate safety net for a matched cycle whose Smart
+ # Termination is blocked (ambiguous / prefix-ambiguous match)
+ # and whose fallback energy gate is held open by a low standby
+ # baseline: without this it sits in ENDING until the 8 h cap /
+ # zombie-kill (#296/#311). Fires only well past the expected
+ # duration AND after a long *continuous* sub-threshold span, so
+ # it can never truncate a longer program mismatched to a shorter
+ # profile (a real longer program has high-power phases that keep
+ # resetting the quiet timer) — asymmetric, shorten-only. Not
+ # for user-paused cycles.
+ required_quiet = max(
+ ENDING_HARD_FINALIZE_MIN_QUIET_S,
+ float(max(self._config.off_delay, self._config.min_off_gap)),
+ )
+ # Require the required_quiet tail to be actually SAMPLED (no
+ # outage-sized gap): otherwise a telemetry outage that inflated
+ # _time_below_threshold could finalize an active cycle early.
+ # Walk in reverse so we can capture the boundary reading (the
+ # first reading outside the window) — an outage right before the
+ # window would be invisible if we only passed in-window timestamps.
+ quiet_ts: list[datetime] = []
+ _boundary_q: datetime | None = None
+ for _ts, _ in reversed(self._power_readings):
+ if (timestamp - _ts).total_seconds() <= required_quiet:
+ quiet_ts.append(_ts)
+ elif quiet_ts:
+ _boundary_q = _ts
+ break
+ if _boundary_q is not None:
+ quiet_ts.append(_boundary_q)
+ if (
+ self._expected_duration > 0
+ and current_duration
+ >= self._expected_duration * ENDING_HARD_FINALIZE_RATIO
+ and self._time_below_threshold >= required_quiet
+ and not self._verified_pause
+ and not self._window_has_outage_gap(quiet_ts)
+ ):
+ self._logger.info(
+ "Duration-anchored finalize: cycle in ENDING at %.0fs "
+ "(%.1fx expected %.0fs), quiet %.0fs — Smart Termination "
+ "was blocked (ambiguous=%s prefix=%s); finalizing.",
+ current_duration,
+ current_duration / self._expected_duration,
+ self._expected_duration,
+ self._time_below_threshold,
+ self._match_ambiguous,
+ self._match_prefix_ambiguous,
+ )
+ self._finish_cycle(
+ timestamp,
+ status="completed",
+ termination_reason=TerminationReason.TIMEOUT,
+ keep_tail=False,
+ )
+ return
+
# --- FALLBACK TIMEOUT CHECK ---
# Rule: To separate cycles, we must wait at least min_off_gap.
effective_off_delay = max(self._config.off_delay, self._config.min_off_gap)
@@ -1333,11 +1571,15 @@ class CycleDetector:
if self._time_below_threshold >= effective_off_delay:
- recent_window = [
- r
- for r in self._power_readings
- if (timestamp - r[0]).total_seconds() <= gate_window
- ]
+ # Walk from the tail — readings are chronological so we can
+ # break as soon as we exceed the gate window (O(window) not O(n)).
+ recent_window = []
+ for r in reversed(self._power_readings):
+ if (timestamp - r[0]).total_seconds() <= gate_window:
+ recent_window.append(r)
+ else:
+ break
+ recent_window.reverse()
if not recent_window:
# Check deferred finish for matched profiles
@@ -1408,6 +1650,10 @@ class CycleDetector:
self._delay_wait_high_start = None
self._delay_wait_high_power = None
self._preserve_delay_band_on_off = False
+ # Clear the paused-STARTING true-off accumulator so a later STARTING
+ # cycle cannot inherit stale hold time and finalize to OFF prematurely
+ # (this path is also reached via the paused-STARTING cancellation).
+ self._starting_paused_off_since = None
# Reset end spike tracker when entering ENDING state
if new_state == STATE_ENDING:
@@ -1433,6 +1679,8 @@ class CycleDetector:
elif new_state == STATE_STARTING:
# Reset idle time if exiting ANTI_WRINKLE to STARTING (high-power burst resumed)
self._anti_wrinkle_idle_time = 0.0
+ # Fresh STARTING cycle: never inherit a prior cycle's true-off hold.
+ self._starting_paused_off_since = None
elif new_state == STATE_RUNNING:
self._delay_band_start = None
self._delay_band_seconds = 0.0
@@ -1478,6 +1726,266 @@ class CycleDetector:
self._ml_end_cache = (now_ts, exp, start, result)
return result
+ def _window_has_outage_gap(self, window_ts: list[datetime]) -> bool:
+ """Whether a 'sustained window' contains a data-outage-sized hole.
+
+ The span + coverage checks in the standby / anti-crease window scans accept
+ e.g. three readings spanning the window even if a long unobserved gap sits
+ between them (a sensor dropout, or a sparse burst next to one old reading).
+ Finalizing on such a window could wrongly cut an active cycle, so reject it.
+ The gap ceiling is the sensor's own data-driven outage threshold
+ (``energy_gap_threshold_s`` over the full trace), so a change-only sensor's
+ legitimately-sparse stable stretches (tens of seconds between reports) are
+ NOT rejected while a genuine dropout is.
+ """
+ if len(window_ts) < 2:
+ return True # too few points to trust as a sustained window
+ max_gap = self._outage_threshold_s()
+ ordered = sorted(t.timestamp() for t in window_ts)
+ return any((b - a) > max_gap for a, b in itertools.pairwise(ordered))
+
+ def _outage_threshold_s(self) -> float:
+ """Sensor-adaptive gap ceiling (seconds): intervals longer than this are
+ treated as telemetry outages, not observed quiet. Data-driven from the
+ trace's own cadence (`energy_gap_threshold_s`), so a change-only sensor's
+ sparse-but-real stable stretches are not mistaken for a dropout.
+ """
+ all_ts = np.array(
+ [r[0].timestamp() for r in self._power_readings], dtype=float
+ )
+ return energy_gap_threshold_s(all_ts)
+
+ def _is_standby_band_stuck(self, timestamp: datetime) -> bool:
+ """Whether a RUNNING cycle is stuck on a flat standby plateau (#296).
+
+ Returns True only when ALL of the following hold, so this can never end
+ an active low-power phase:
+
+ * the device is a wet appliance where a stuck baseline is unambiguously
+ anomalous (``STANDBY_BAND_FINALIZE_DEVICE_TYPES``);
+ * a profile is matched and elapsed >= ``STANDBY_BAND_MIN_RATIO`` x the
+ expected duration (well past when it should have ended);
+ * not user-paused;
+ * the most recent >= ``STANDBY_BAND_WINDOW_S`` of readings are ALL at or
+ below ``STANDBY_BAND_MAX_FRACTION`` of the cycle's own peak power AND
+ span no more than ``STANDBY_BAND_FLATNESS_FRACTION`` of the peak (a flat
+ plateau, not fluctuating activity).
+
+ The expensive window scan runs only after the cheap duration gate passes,
+ so normal cycles never pay for it.
+ """
+ if self._config.device_type not in STANDBY_BAND_FINALIZE_DEVICE_TYPES:
+ return False
+ if getattr(self, "_verified_pause", False):
+ return False
+ if not (self._matched_profile and self._expected_duration > 0):
+ return False
+ start = self._current_cycle_start
+ if start is None:
+ return False
+ current_duration = (timestamp - start).total_seconds()
+ if current_duration < self._expected_duration * STANDBY_BAND_MIN_RATIO:
+ return False
+ peak = float(self._cycle_max_power)
+ if peak <= 0:
+ return False
+
+ level_ceiling = peak * STANDBY_BAND_MAX_FRACTION
+ # Walk the tail; readings are chronological so we can break once outside
+ # the window (O(window), not O(n)).
+ window: list[float] = []
+ window_ts: list[datetime] = []
+ oldest_in_window: datetime | None = None
+ saw_older = False # a reading older than the window exists -> full coverage
+ _standby_boundary_ts: datetime | None = None
+ for ts, p in reversed(self._power_readings):
+ if (timestamp - ts).total_seconds() <= STANDBY_BAND_WINDOW_S:
+ window.append(float(p))
+ window_ts.append(ts)
+ oldest_in_window = ts
+ else:
+ saw_older = True
+ _standby_boundary_ts = ts # boundary: last reading before the window
+ break
+ # The plateau must actually SPAN the required window (data exists from
+ # before it), not just a couple of recent samples, and have enough points
+ # to judge. `saw_older` (rather than an exact span >= WINDOW check) is
+ # robust to sample phase/granularity: with e.g. 30 s sampling the oldest
+ # in-window reading is typically only ~570-599 s old, which an exact check
+ # would wrongly reject. A coverage sanity (oldest >= 90% of the window)
+ # plus an adjacent-gap check (``_window_has_outage_gap``) guard against a
+ # sparse burst of samples sitting next to one old reading across a dropout.
+ if (
+ oldest_in_window is None
+ or not saw_older
+ or len(window) < 3
+ or (timestamp - oldest_in_window).total_seconds()
+ < STANDBY_BAND_WINDOW_S * 0.9
+ or self._window_has_outage_gap(
+ [_standby_boundary_ts, *window_ts]
+ if _standby_boundary_ts is not None
+ else window_ts
+ )
+ ):
+ return False
+ hi = max(window)
+ lo = min(window)
+ if hi > level_ceiling:
+ return False # a real active reading in the window - not standby
+ flatness_limit = max(
+ STANDBY_BAND_FLATNESS_FLOOR_W, peak * STANDBY_BAND_FLATNESS_FRACTION
+ )
+ if (hi - lo) > flatness_limit:
+ return False # fluctuating - still doing work
+ return True
+
+ def _anticrease_gate_open(self, timestamp: datetime) -> bool:
+ """Core anti-crease gate (#296): everything except the current power level
+ and the low-power-window check. Shared by the match freeze
+ (``_in_anticrease_freeze``) and the finalise (``_is_anticrease_tail``).
+
+ True only when a genuinely energetic, confidently-matched cycle for an
+ anti-wrinkle device is PAST its expected duration - the discriminator that
+ separates the post-wash anti-crease tail from a mid-wash low-power trough
+ (a washer spends most of its cycle below ``anti_wrinkle_max_power``, but a
+ mid-wash trough is always BEFORE the expected duration, the tail after it).
+ """
+ if not self._config.anti_wrinkle_enabled:
+ return False
+ if self._config.device_type not in (
+ DEVICE_TYPE_WASHING_MACHINE,
+ DEVICE_TYPE_DRYER,
+ DEVICE_TYPE_WASHER_DRYER,
+ ):
+ return False
+ if getattr(self, "_verified_pause", False):
+ return False
+ if not (self._matched_profile and self._expected_duration > 0):
+ return False
+ if self._last_match_confidence < 0.4:
+ return False
+ if self._match_ambiguous or self._match_prefix_ambiguous:
+ return False
+ if self._cycle_max_power <= float(self._config.anti_wrinkle_max_power):
+ return False # never a hot/energetic cycle - leave low-power programs alone
+ start = self._current_cycle_start
+ if start is None:
+ return False
+ current_duration = (timestamp - start).total_seconds()
+ if current_duration < self._expected_duration * ANTI_CREASE_FINALIZE_RATIO:
+ return False
+ return True
+
+ def _in_anticrease_freeze(self, timestamp: datetime) -> bool:
+ """Whether match updates should be frozen (#296): the anti-crease gate is
+ open AND the most recent reading is in the low-power regime (at or below
+ ``anti_wrinkle_max_power``).
+
+ Deliberately lighter than ``_is_anticrease_tail`` - it does NOT wait for the
+ full ``ANTI_CREASE_CONFIRM_WINDOW_S``, so the confident pre-tail match is
+ preserved from the instant the cycle crosses its expected duration in a
+ low-power state, before the window accrues. Without this a match that
+ degrades to ambiguous within the first window's worth of tail would
+ deadlock both the freeze and the finalise (both require an unambiguous
+ match). Self-correcting: a heating burst above ``anti_wrinkle_max_power``
+ leaves the regime and re-arms matching.
+ """
+ if not self._power_readings:
+ return False
+ if float(self._power_readings[-1][1]) > float(
+ self._config.anti_wrinkle_max_power
+ ):
+ return False
+ return self._anticrease_gate_open(timestamp)
+
+ def _is_anticrease_tail(self, timestamp: datetime) -> bool:
+ """Whether a matched, past-expected cycle has settled into the anti-crease
+ tumble tail (#296) - the trigger for the finalise into STATE_ANTI_WRINKLE.
+
+ Miele-style "Knitterschutz": after the wash proper ends, the machine holds
+ a constant baseline plus periodic sub-``anti_wrinkle_max_power`` tumble
+ bursts (no heating) until the door is opened. Because those bursts recur
+ faster than off_delay they keep reviving the cycle out of ENDING, so the
+ normal power-off path never finalises it and STATE_ANTI_WRINKLE - which is
+ built to absorb the tail and split off the next wash - never engages.
+ Recognising the tail lets us finalise into anti-wrinkle directly.
+
+ Requires the core gate (``_anticrease_gate_open``) AND that the most recent
+ >= ``ANTI_CREASE_CONFIRM_WINDOW_S`` of readings are ALL at or below
+ ``anti_wrinkle_max_power`` (we are in the low-power tail, clear of the final
+ spin and not mid-heating). The expensive window scan runs only after the
+ cheap gate passes, so normal cycles never pay for it.
+ """
+ if not self._anticrease_gate_open(timestamp):
+ return False
+ max_power = float(self._config.anti_wrinkle_max_power)
+ # Walk the tail; readings are chronological so we can break once outside the
+ # window (O(window), not O(n)).
+ window: list[float] = []
+ window_ts: list[datetime] = []
+ oldest_in_window: datetime | None = None
+ saw_older = False
+ _ac_boundary_ts: datetime | None = None
+ for ts, p in reversed(self._power_readings):
+ if (timestamp - ts).total_seconds() <= ANTI_CREASE_CONFIRM_WINDOW_S:
+ window.append(float(p))
+ window_ts.append(ts)
+ oldest_in_window = ts
+ else:
+ saw_older = True
+ _ac_boundary_ts = ts # boundary: last reading before the window
+ break
+ # The low-power tail must actually SPAN the window (data exists from before
+ # it) and have enough points to judge - not just a couple of recent samples.
+ # ``saw_older`` plus a coverage sanity and an adjacent-gap check
+ # (``_window_has_outage_gap``) is robust to sample phase/granularity while
+ # rejecting a dropout-sized hole (mirrors _is_standby_band_stuck).
+ if (
+ oldest_in_window is None
+ or not saw_older
+ or len(window) < 3
+ or (timestamp - oldest_in_window).total_seconds()
+ < ANTI_CREASE_CONFIRM_WINDOW_S * 0.9
+ or self._window_has_outage_gap(
+ [_ac_boundary_ts, *window_ts]
+ if _ac_boundary_ts is not None
+ else window_ts
+ )
+ ):
+ return False
+ if max(window) > max_power:
+ return False # a heating / high-spin reading in the window - still washing
+ return True
+
+ def _maybe_finalize_anticrease_tail(self, timestamp: datetime) -> bool:
+ """Finalise a cycle that has entered the anti-crease tail into
+ STATE_ANTI_WRINKLE (#296). Returns True if the cycle was finalised.
+
+ Shared by the RUNNING / PAUSED / ENDING branches so the finalise fires no
+ matter which state a burst left the detector in. Uses Smart Termination
+ (in ``ANTI_WRINKLE_ELIGIBLE_REASONS``) so ``_finish_cycle`` routes into
+ STATE_ANTI_WRINKLE, which then absorbs the tail and splits off any next
+ wash on its first heating burst above ``anti_wrinkle_max_power``.
+ """
+ if not self._is_anticrease_tail(timestamp):
+ return False
+ start_time = self._current_cycle_start or timestamp
+ current_duration = (timestamp - start_time).total_seconds()
+ self._logger.info(
+ "Anti-crease finalize: matched '%s' past expected %.0fs (elapsed %.0fs), "
+ "settled into the low-power tumble tail — finalizing into anti-wrinkle.",
+ self._matched_profile,
+ self._expected_duration,
+ current_duration,
+ )
+ self._finish_cycle(
+ timestamp,
+ status="completed",
+ termination_reason=TerminationReason.SMART,
+ keep_tail=True,
+ )
+ return True
+
def _is_terminal_drop(self) -> bool:
"""Whether the current low-power event is an anomalously-early hard drop.
diff --git a/custom_components/ha_washdata/frontend.py b/custom_components/ha_washdata/frontend.py
index 429896c..8b02c94 100644
--- a/custom_components/ha_washdata/frontend.py
+++ b/custom_components/ha_washdata/frontend.py
@@ -56,10 +56,24 @@ class LovelaceResourceItem(TypedDict, total=False):
def get_cache_buster(filename: str = CARD_NAME) -> str:
- """Generate a stable cache buster based on a www asset's mtime."""
+ """Generate a stable cache buster based on a www asset's mtime.
+
+ Also considers the translations/panel/ directory mtime so that
+ translation-only releases (e.g. GitLocalize merges) still bust the
+ browser cache for both the panel JS and the per-language JSON files.
+ """
try:
- src = Path(__file__).parent / "www" / filename
- return str(int(os.path.getmtime(src)))
+ base = Path(__file__).parent
+ src_mtime = os.path.getmtime(base / "www" / filename)
+ try:
+ panel_dir = base / "translations" / "panel"
+ trans_mtime = max(
+ (os.path.getmtime(f) for f in panel_dir.iterdir() if f.is_file()),
+ default=0.0,
+ )
+ except OSError:
+ trans_mtime = 0.0
+ return str(int(max(src_mtime, trans_mtime)))
except OSError:
# Deterministic fallback when file is unavailable.
return "1"
diff --git a/custom_components/ha_washdata/learning.py b/custom_components/ha_washdata/learning.py
index bf3e058..3ad3174 100644
--- a/custom_components/ha_washdata/learning.py
+++ b/custom_components/ha_washdata/learning.py
@@ -140,6 +140,7 @@ class LearningManager:
self._sample_interval_model = StatisticalModel(max_samples=200)
self._last_suggestion_update: datetime | None = None
self._last_batch_simulation_count: int = 0 # track when to re-run batch
+ self._last_suggestions_labeled_count: int = 0 # gate model/detection passes
def _apply_suggestions_and_notify(self, suggestions: dict[str, Any]) -> None:
"""Apply suggestions that pass quality gates."""
@@ -232,9 +233,17 @@ class LearningManager:
predicted_duration: Expected duration in seconds
match_result: MatchResult from profile_store.async_match_profile() (optional)
"""
- # 1. Trigger background simulation to find optimal parameters for this cycle
- if cycle_data.get("power_data"):
- # Offload to executor since simulation can be heavy
+ # 1. Trigger single-cycle simulation — only for cleanly-completed, labeled,
+ # non-noise cycles. Skipping force_stopped/unlabeled/noise avoids deriving
+ # start/stop thresholds from mis-detected or truncated cycles.
+ _profile = detected_profile or cycle_data.get("profile_name")
+ _is_clean = (
+ cycle_data.get("power_data")
+ and _profile
+ and _profile != "noise"
+ and cycle_data.get("status") == "completed"
+ )
+ if _is_clean:
self.hass.async_create_task(self._async_run_simulation(cycle_data))
# 2. Check if we should request feedback
@@ -242,11 +251,20 @@ class LearningManager:
cycle_data, detected_profile, confidence, predicted_duration, match_result
)
- # 3. Update model-based suggestions (durations etc)
- self._update_model_suggestions()
-
- # 3b. Update statistical detection suggestions (thresholds, gates, etc.)
- self._update_detection_suggestions()
+ # 3+3b. Heavy per-profile suggestion passes — only run when the labeled
+ # cycle count has grown since the last update (skips passes for unlabeled /
+ # noise / duplicate ends with no new data).
+ labeled_count = sum(
+ 1 for c in self.profile_store.get_past_cycles()
+ if isinstance(c, dict)
+ and c.get("profile_name")
+ and c.get("profile_name") != "noise"
+ and c.get("status") in ("completed", "force_stopped")
+ )
+ if labeled_count > self._last_suggestions_labeled_count:
+ self._last_suggestions_labeled_count = labeled_count
+ self._update_model_suggestions()
+ self._update_detection_suggestions()
# 4. Run multi-cycle batch simulation when enough new labeled cycles have accumulated
self._maybe_run_batch_simulation()
@@ -517,18 +535,27 @@ class LearningManager:
# even if the matcher was confident. Downgrade to a feedback request so
# the user can verify the match; this catches confident but wrong labels.
ml_quality = cycle_data.get("ml_quality_score")
- ml_suspicious = (
- isinstance(ml_quality, float)
- and ml_quality >= ML_QUALITY_SUSPICIOUS_THRESHOLD
- )
+ # Use float() so numpy scalars (float32/float64) returned by resolve_scorer
+ # are accepted — isinstance(numpy_float, float) is False in NumPy ≥ 2.0.
+ # Wrap in try/except so non-numeric sentinel values are silently ignored.
+ try:
+ ml_suspicious = (
+ ml_quality is not None
+ and float(ml_quality) >= ML_QUALITY_SUSPICIOUS_THRESHOLD
+ )
+ except (TypeError, ValueError):
+ ml_suspicious = False
# Also downgrade when the cycle's power trace is mostly outside the
# profile envelope band (low conformance = the shape matched but the
# actual power levels are inconsistent with the profile).
_conformance = cycle_data.get("envelope_conformance")
- envelope_suspicious = (
- isinstance(_conformance, float)
- and _conformance < 0.40
- )
+ try:
+ envelope_suspicious = (
+ _conformance is not None
+ and float(_conformance) < 0.40
+ )
+ except (TypeError, ValueError):
+ envelope_suspicious = False
if route_conf >= auto_label_conf:
if ml_suspicious or envelope_suspicious:
if ml_suspicious:
diff --git a/custom_components/ha_washdata/manager.py b/custom_components/ha_washdata/manager.py
index 6ef829e..da1c1c4 100644
--- a/custom_components/ha_washdata/manager.py
+++ b/custom_components/ha_washdata/manager.py
@@ -25,7 +25,9 @@ import hashlib
import inspect
import math
import uuid
+import asyncio
from asyncio import Task
+from collections.abc import Coroutine
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
import numpy as np
@@ -380,6 +382,7 @@ class WashDataManager:
self._notify_finish_services: list[str] = []
self._notify_live_services: list[str] = []
self._notify_actions: list[dict[str, Any]] = []
+ self._notify_script: Any = None # cached Script; invalidated on options reload
self._notify_people: list[str] = []
self._notify_cycle_timers: list[dict[str, Any]] = []
self._fired_cycle_timers: set[int] = set()
@@ -485,6 +488,12 @@ class WashDataManager:
self._sample_intervals: list[float] = []
self._sample_interval_stats: dict[str, Any] = {}
self._matching_task: Task[Any] | None = None
+ self._cycle_end_task: Task[Any] | None = None
+ # Detached store-touching tasks (matching trigger, active-cycle clear,
+ # post-cycle processing) tracked so async_shutdown can cancel them before a
+ # reload/unload swaps the ProfileStore out from under them.
+ self._background_tasks: set[Task[Any]] = set()
+ self._is_shutdown: bool = False
self._last_state_save = 0.0
self._last_cycle_end_time: datetime | None = None
self._remove_state_expiry_timer = None
@@ -503,7 +512,9 @@ class WashDataManager:
self.entry_id,
min_duration_ratio=config_entry.options.get(
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
- DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
+ DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
+ self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
+ ),
),
max_duration_ratio=config_entry.options.get(
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
@@ -769,7 +780,7 @@ class WashDataManager:
# Snapshotted for thread safety indirectly by task logic
# We don't need a wrapper task if we unify with _update_estimates matching
# but for now let's keep the detector callback as a trigger
- self.hass.async_create_task(self._async_perform_combined_matching(readings))
+ self._spawn_tracked(self._async_perform_combined_matching(readings))
return (None, 0.0, 0.0, None)
self.detector = CycleDetector(
@@ -2060,7 +2071,9 @@ class WashDataManager:
new_min_ratio = float(
config_entry.options.get(
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
- DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
+ DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
+ self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
+ ),
)
)
new_max_ratio = float(
@@ -2074,6 +2087,10 @@ class WashDataManager:
self.profile_store.set_duration_ratio_limits(
min_ratio=new_min_ratio, max_ratio=new_max_ratio
)
+ # Keep the detector's copy in sync: _should_defer_finish() reads
+ # detector.config.min_duration_ratio, which otherwise keeps the
+ # construction-time value until a restart (diverging from the matcher).
+ self.detector.config.min_duration_ratio = new_min_ratio
self._logger.info(
"Updated duration ratios: min %.2f→%.2f, max %.2f→%.2f",
old_min_ratio,
@@ -2106,6 +2123,7 @@ class WashDataManager:
self._notify_actions = list(
cast(list[dict[str, Any]], config_entry.options.get(CONF_NOTIFY_ACTIONS, []) or [])
)
+ self._notify_script = None
self._notify_people = list(
config_entry.options.get(CONF_NOTIFY_PEOPLE, []) or []
)
@@ -2200,8 +2218,43 @@ class WashDataManager:
self._logger.info("Configuration reloaded successfully")
+ def _spawn_tracked(self, coro: Coroutine[Any, Any, Any]) -> Task[Any]:
+ """Create a detached task and track it so shutdown can cancel it.
+
+ Use for fire-and-forget tasks that touch the ProfileStore (matching
+ trigger, active-cycle clear, post-cycle processing): if a reload/unload
+ swaps the store out mid-flight, an untracked task would keep writing to the
+ stale store. The task auto-removes itself from the set when it finishes.
+ """
+ task = self.hass.async_create_task(coro)
+ # Real HA always returns a Task; guard for degenerate returns (e.g. a
+ # mocked hass in tests) so tracking never breaks the caller.
+ if task is not None and hasattr(task, "add_done_callback"):
+ self._background_tasks.add(task)
+ task.add_done_callback(self._background_tasks.discard)
+ return task
+
async def async_shutdown(self) -> None:
"""Shutdown."""
+ self._is_shutdown = True
+ # Cancel in-flight matching and cycle-end tasks so they don't race a
+ # freshly-loaded ProfileStore on reload_config_entry.
+ _to_await: list[Task[Any]] = []
+ if self._matching_task and not self._matching_task.done():
+ self._matching_task.cancel()
+ _to_await.append(self._matching_task)
+ if self._cycle_end_task and not self._cycle_end_task.done():
+ self._cycle_end_task.cancel()
+ _to_await.append(self._cycle_end_task)
+ # Cancel every other tracked detached task (matching trigger, active-cycle
+ # clear, post-cycle processing) for the same reason.
+ for task in list(self._background_tasks):
+ if not task.done():
+ task.cancel()
+ _to_await.append(task)
+ # Drain cancelled tasks so they don't race the freshly-reloaded ProfileStore.
+ if _to_await:
+ await asyncio.gather(*_to_await, return_exceptions=True)
if self._remove_listener:
self._remove_listener()
if self._remove_external_trigger_listener:
@@ -2759,10 +2812,32 @@ class WashDataManager:
now = dt_util.now()
- # Throttle updates to avoid CPU overload on noisy sensors
- # BUT always allow updates if power is below min_power (critical end-of-cycle signal).
+ # Throttle updates to avoid CPU overload on noisy sensors.
+ # Low-power readings bypass throttling when:
+ # (a) a cycle is active (RUNNING/ENDING/PAUSED) — critical end-of-cycle signal, or
+ # (b) this is a genuine power DROP from above min_power — captures power-off events
+ # that occur before the detector has processed the previous above-threshold reading.
+ # Without the guard, an idle device at 0W fires an update on every sensor poll (typically
+ # every 1–5 s), flooding the detector with zero-value no-ops.
min_p = float(self.detector.config.min_power)
- is_low_power = power < min_p
+ # For the "genuine drop" bypass, compare against the previous RAW sensor
+ # value (old_state), not _current_power: the latter is only updated after a
+ # reading passes the throttle, so a suppressed high reading would leave it
+ # low and the following low reading would be throttled too, missing a short
+ # high->low transition. old_state reflects the plug's actual prior value.
+ prev_raw_power = self._current_power
+ old_state = cast(State | None, event_data.get("old_state"))
+ if old_state is not None and old_state.state not in (
+ STATE_UNKNOWN, STATE_UNAVAILABLE
+ ):
+ try:
+ prev_raw_power = float(old_state.state)
+ except ValueError:
+ pass
+ is_low_power = power < min_p and (
+ self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING)
+ or prev_raw_power >= min_p # genuine drop from active power
+ )
if (
not is_low_power
@@ -3191,6 +3266,31 @@ class WashDataManager:
self._notify_update()
return
+ # Secondary zombie guard for unmatched cycles (expected == 0 when no profile
+ # has been matched). The detector hard-caps RUNNING at 8h but a chatty sensor
+ # that never goes silent can keep the watchdog from reaching the end state.
+ # Kill here after 4h so a stuck false-start doesn't run indefinitely.
+ elif (
+ expected == 0
+ and not self._is_user_paused
+ and not _verified_pause_zombie
+ and elapsed > 14400
+ # Gate on the active detector state, not _current_program: the latter is
+ # only set to "detecting..." on the RUNNING transition, so a cycle stuck
+ # in STARTING keeps a stale program and would never hit this 4h guard.
+ and self.detector.state in (
+ STATE_STARTING, STATE_RUNNING, STATE_PAUSED, STATE_ENDING
+ )
+ ):
+ self._logger.warning(
+ "Watchdog: Unmatched cycle exceeded 4h (%.0fs). Force-ending.",
+ elapsed,
+ )
+ self.detector.force_end(now)
+ self._current_power = 0.0
+ self._notify_update()
+ return
+
# 1. GHOST CYCLE SUPPRESSOR
# If we are "detecting" for more than 10 minutes and haven't seen an update for 5 minutes,
# it's likely a pump-out spike or an accidental start (ghost cycle).
@@ -3457,6 +3557,14 @@ class WashDataManager:
},
)
self._start_event_fired = True
+ # Mark the start fully handled ONLY when there is no push to send, so
+ # the restart-recovery fallback does not re-enter for event-only
+ # configs. When a push service/action IS configured, leave
+ # _notified_start False so the push block below still fires: a config
+ # with both events and push must get both (event delivery is tracked
+ # separately by _start_event_fired).
+ if not (self._notify_start_services or self._notify_actions):
+ self._notified_start = True
# Fire push notification immediately - do not wait for profile matching.
if not self._notified_start and (self._notify_start_services or self._notify_actions):
@@ -3512,7 +3620,7 @@ class WashDataManager:
stranded in a terminal state with a stale active snapshot until the next
cycle. Deliberately does NOT persist, notify, or run the learning pipeline.
"""
- self.hass.async_create_task(self.profile_store.async_clear_active_cycle())
+ self._spawn_tracked(self.profile_store.async_clear_active_cycle())
# Anchor the terminal state so _handle_state_expiry (and power-off) can act,
# then arm the expiry timer that resets terminal -> Off after the reset delay.
self._cycle_completed_time = dt_util.now()
@@ -3606,9 +3714,10 @@ class WashDataManager:
# detector into a fresh RUNNING before post-processing completes). See B1 in
# _async_process_cycle_end.
end_token = self._ranking_snapshot_cycle_id
- self.hass.async_create_task(
- self._async_process_cycle_end(cycle_data, cycle_token=end_token)
- )
+ if not self._is_shutdown:
+ self._cycle_end_task = self._spawn_tracked(
+ self._async_process_cycle_end(cycle_data, cycle_token=end_token)
+ )
def _ml_end_confidence(
self, points: list[tuple[float, float]], expected_duration: float
@@ -3811,7 +3920,11 @@ class WashDataManager:
self._logger,
)
- def _compute_cycle_quality_score(self, cycle_data: dict[str, Any]) -> None:
+ def _compute_cycle_quality_score(
+ self,
+ cycle_data: dict[str, Any],
+ past_cycles_snapshot: list[dict[str, Any]] | None = None,
+ ) -> None:
"""Score a just-finished cycle with the hybrid_curve_quality model (opt-in).
When ML models are enabled for this device, computes P(cycle is a problem)
@@ -3842,7 +3955,16 @@ class WashDataManager:
durations: list[float] = []
energies: list[float] = []
peaks: list[float] = []
- for c in self.profile_store.get_past_cycles():
+ # This function runs in an executor thread and the event loop may
+ # append cycles concurrently; iterating (or even copying) the live list
+ # here is a data race. Prefer the snapshot taken on the event loop at
+ # the call site; only fall back to a local copy for direct callers.
+ cycles = (
+ past_cycles_snapshot
+ if past_cycles_snapshot is not None
+ else list(self.profile_store.get_past_cycles())
+ )
+ for c in cycles:
if c.get("profile_name") != profile_name:
continue
if c.get("duration") is not None:
@@ -4265,8 +4387,12 @@ class WashDataManager:
from .ml.engine import ml_models_enabled # noqa: PLC0415
if ml_models_enabled(self.config_entry.options):
+ # Snapshot past_cycles on the event loop before offloading: iterating
+ # (or copying) the live list inside the executor races the loop
+ # appending this just-finished cycle.
+ past_cycles_snapshot = list(self.profile_store.get_past_cycles())
await self.hass.async_add_executor_job(
- self._compute_cycle_quality_score, cycle_data
+ self._compute_cycle_quality_score, cycle_data, past_cycles_snapshot
)
# Add cycle to store immediately (still sync but offloadable parts optimized
@@ -4331,10 +4457,10 @@ class WashDataManager:
# If a new cycle started during the awaits above, it now owns the active
# snapshot; clearing it here would strip the new cycle's restart-resilience.
if cycle_token is None or self._ranking_snapshot_cycle_id == cycle_token:
- self.hass.async_create_task(self.profile_store.async_clear_active_cycle())
+ self._spawn_tracked(self.profile_store.async_clear_active_cycle())
# Auto post-process: merge fragmented cycles from last 3 hours
- self.hass.async_create_task(self._run_post_cycle_processing())
+ self._spawn_tracked(self._run_post_cycle_processing())
# Prepare cycle data for event (enrich if needed)
# IMPORTANT: Exclude large fields to prevent exceeding HA's 32KB event data limit
@@ -5079,33 +5205,52 @@ class WashDataManager:
if not actions:
return False
- try:
- script = script_helper.Script(
- self.hass,
- actions,
- name=f"{self.config_entry.title} notification",
- domain=DOMAIN,
- logger=_LOGGER,
- )
- except (ValueError, TypeError, HomeAssistantError) as err:
- self._logger.error(
- "Invalid notification action configuration for %s: %s",
- self.config_entry.title,
- err,
- )
- return False
- except Exception as err:
- self._logger.exception(
- "Unexpected error while building notification actions for %s: %s",
- self.config_entry.title,
- err,
- )
- return False
+ if self._notify_script is None:
+ try:
+ self._notify_script = script_helper.Script(
+ self.hass,
+ actions,
+ name=f"{self.config_entry.title} notification",
+ domain=DOMAIN,
+ logger=_LOGGER,
+ )
+ except (ValueError, TypeError, HomeAssistantError) as err:
+ self._logger.error(
+ "Invalid notification action configuration for %s: %s",
+ self.config_entry.title,
+ err,
+ )
+ return False
+ except Exception as err:
+ self._logger.exception(
+ "Unexpected error while building notification actions for %s: %s",
+ self.config_entry.title,
+ err,
+ )
+ return False
+ script = self._notify_script
try:
- self.hass.async_create_task(
+ action_task = self.hass.async_create_task(
script.async_run(variables, context=Context())
)
+ # This method is synchronous, so the script runs fire-and-forget: a
+ # failure inside async_run() would otherwise land after we return True
+ # and be swallowed. Surface it via a done-callback so an action-only
+ # setup at least logs the drop. (A user-visible fallback would require
+ # awaiting, i.e. making the whole notification-dispatch chain async.)
+ def _log_action_failure(task: Task[Any]) -> None:
+ if task.cancelled():
+ return
+ exc = task.exception()
+ if exc is not None:
+ self._logger.warning(
+ "Notification action execution failed for %s: %s",
+ self.config_entry.title,
+ exc,
+ )
+
+ action_task.add_done_callback(_log_action_failure)
return True
except HomeAssistantError as err:
self._logger.warning(
@@ -6118,7 +6263,7 @@ class WashDataManager:
@property
def samples_recorded(self):
"""Return the number of power samples recorded in current cycle."""
- return len(self.detector.get_power_trace())
+ return self.detector.samples_recorded
@property
def sample_interval_stats(self):
diff --git a/custom_components/ha_washdata/manifest.json b/custom_components/ha_washdata/manifest.json
index a96b265..c9ff7f6 100644
--- a/custom_components/ha_washdata/manifest.json
+++ b/custom_components/ha_washdata/manifest.json
@@ -21,5 +21,5 @@
"requirements": [
"numpy"
],
- "version": "0.5.1"
+ "version": "0.5.2"
}
\ No newline at end of file
diff --git a/custom_components/ha_washdata/ml/engine.py b/custom_components/ha_washdata/ml/engine.py
index bd107c2..748657c 100644
--- a/custom_components/ha_washdata/ml/engine.py
+++ b/custom_components/ha_washdata/ml/engine.py
@@ -116,6 +116,28 @@ def resolve_scorer(capability: str, store: object | None):
# classifier and regression capability keys are disjoint today, but this
# guard keeps it safe if a key were ever reused.
if isinstance(spec, dict) and spec.get("kind") != "standardized_linear":
+ # Feature-column schema guard: a spec promoted under an older
+ # FEATURE_COLUMNS must be dropped rather than silently scoring on a
+ # stale/neutral-filled schema. The call-time guard already catches
+ # shape mismatches, but this catches them at load time and logs
+ # clearly, so users see a single warm-up warning instead of a
+ # per-inference warning storm.
+ module_name = _MODEL_MODULES.get(capability)
+ if module_name is not None:
+ try:
+ _bm = importlib.import_module(f"{__package__}.{module_name}")
+ _expected = list(getattr(_bm, "FEATURE_COLUMNS", []))
+ _stored = list(spec.get("feature_columns") or [])
+ if _expected and _stored and _stored != _expected:
+ _LOGGER.warning(
+ "Promoted spec for %r has stale feature schema "
+ "(%d cols vs current %d); reverting to baseline.",
+ capability, len(_stored), len(_expected),
+ )
+ return _baseline()
+ except Exception: # noqa: BLE001 - schema check must not break inference
+ pass
+
from .trainer import score_spec
def _on_device_score(feats, _s=spec):
@@ -165,6 +187,21 @@ def resolve_regressor(capability: str, store: object | None):
record = versions.get(capability)
spec = record.get("spec") if isinstance(record, dict) else None
if isinstance(spec, dict) and spec.get("kind") == "standardized_linear":
+ # Feature-column schema guard for regression specs.
+ try:
+ from .feature_extraction import PROGRESS_FEATURE_COLUMNS
+ _expected_r = list(PROGRESS_FEATURE_COLUMNS)
+ _stored_r = list(spec.get("feature_columns") or [])
+ if _expected_r and _stored_r and _stored_r != _expected_r:
+ _LOGGER.warning(
+ "Promoted regression spec for %r has stale feature schema "
+ "(%d cols vs current %d); reverting to inert.",
+ capability, len(_stored_r), len(_expected_r),
+ )
+ return (None, None)
+ except Exception: # noqa: BLE001 - schema check must not break inference
+ pass
+
from .trainer import predict_value_spec
def _on_device_predict(feats, _s=spec):
diff --git a/custom_components/ha_washdata/ml/training_task.py b/custom_components/ha_washdata/ml/training_task.py
index 3fd8aec..906ab41 100644
--- a/custom_components/ha_washdata/ml/training_task.py
+++ b/custom_components/ha_washdata/ml/training_task.py
@@ -469,7 +469,9 @@ def _train_regression_capability(
}
preds = np.clip(T.predict_matrix_spec(spec_probe, X_te), 0.0, 1.0)
metrics = T.regression_metrics(y_te, preds)
- model_mae = float(metrics.get("mae") or 1.0)
+ # Explicit None check — `or 1.0` would coerce MAE=0.0 to 1.0, rejecting a
+ # perfect regressor and blocking promotion.
+ model_mae = float(metrics.get("mae") if metrics.get("mae") is not None else 1.0)
naive_col = columns.index("elapsed_over_expected") if "elapsed_over_expected" in columns else 0
naive = np.clip(X_te[:, naive_col], 0.0, 1.0)
diff --git a/custom_components/ha_washdata/playground.py b/custom_components/ha_washdata/playground.py
index a2e1cf0..2d66238 100644
--- a/custom_components/ha_washdata/playground.py
+++ b/custom_components/ha_washdata/playground.py
@@ -107,6 +107,10 @@ DEFAULT_RECENT_CYCLES = 20
MAX_BATCH_CYCLES = 50
# Cap the per-cycle event log so a pathological trace cannot bloat the payload.
MAX_EVENTS_PER_CYCLE = 300
+# Cap the per-cycle timeline series so a very long cycle (4h dishwasher = ~2800 pts)
+# does not bloat the task result. Points are thinned at finalize time — evenly-spaced,
+# so the shape is preserved rather than truncated.
+MAX_SERIES_PER_CYCLE = 600
# Override keys the Playground honours, mapped to CycleDetectorConfig fields.
# Only detection-relevant knobs matter; everything else in settings_override is
@@ -232,7 +236,7 @@ def _build_match_snapshots(
) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, list[str]], dict[str, Any]]:
"""Prepare the matcher snapshots + config once from the store.
- Mirrors :meth:`ProfileStore.match_profile`: one snapshot per profile using
+ Mirrors the store's async matching path: one snapshot per profile using
its sample cycle's decompressed trace, plus the store's live matching config
(with any on-device tuned weight overrides merged in).
@@ -357,7 +361,7 @@ def _readings_from_cycle(
_PROGRESS_STATES = (STATE_RUNNING, STATE_PAUSED, STATE_ENDING)
# States in which no progress estimate is shown (mirrors _update_remaining_only).
_DEAD_STATES = (STATE_OFF, STATE_UNKNOWN, STATE_IDLE)
-_SIM_SERIES_THROTTLE_S = 5.0 # production recomputes progress every 5s
+_SIM_SERIES_THROTTLE_S = 30.0 # cap estimator calls; 5s matched cadence made this a no-op
def simulate_cycle_detail(
@@ -954,12 +958,20 @@ class _DetailSim:
alerts.append({"code": "underrun", "severity": "warn",
"detail": f"Finished at {ratio:.0%} of typical duration."})
+ series = self.series
+ if len(series) > MAX_SERIES_PER_CYCLE:
+ # Thin evenly so the shape is preserved (first + last always kept).
+ # Span (len-1)/(N-1) so the final index lands on the true last point
+ # (a plain len/N tops out below it and drops the terminal sample).
+ step = (len(series) - 1) / (MAX_SERIES_PER_CYCLE - 1)
+ series = [series[round(i * step)] for i in range(MAX_SERIES_PER_CYCLE)]
+
return {
"cycle_id": self.cycle.get("id"),
"label": self.label,
"duration_s": self.stored_duration,
"config_summary": _sim_config_summary(self.config),
- "series": self.series,
+ "series": series,
"events": self.events,
"alerts": alerts,
"outcome": outcome,
@@ -1030,10 +1042,13 @@ def _run_rows(
settings_override: dict[str, Any] | None,
options: dict[str, Any],
price: float | None,
+ prebuilt: tuple[Any, Any, Any, Any] | None = None,
) -> list[dict[str, Any]]:
# Snapshots are store-derived (independent of the cycle and the detector-level
# settings_override), so build them ONCE and reuse across all cycles/values.
- prebuilt = _build_match_snapshots(store)
+ # Callers that drive many chunks should pass prebuilt= to avoid rebuilding per chunk.
+ if prebuilt is None:
+ prebuilt = _build_match_snapshots(store)
rows: list[dict[str, Any]] = []
for cycle in cycles:
detail = simulate_cycle_detail(
@@ -1054,6 +1069,7 @@ def run_playground_history(
options: dict[str, Any] | None,
price: float | None,
concurrency: int,
+ prebuilt: tuple[Any, Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Per-cycle rows for the Test-on-history table, plus a before/after diff when
``settings_override`` is set. Executor-safe; never raises."""
@@ -1076,11 +1092,11 @@ def run_playground_history(
selected = selected[:concurrency]
override = settings_override or None
- rows = _run_rows(store, selected, base_config, override, options, price)
+ rows = _run_rows(store, selected, base_config, override, options, price, prebuilt)
payload: dict[str, Any] = {"rows": rows, "summary": _rows_summary(rows)}
if override:
- base_rows = _run_rows(store, selected, base_config, None, options, price)
+ base_rows = _run_rows(store, selected, base_config, None, options, price, prebuilt)
payload["baseline_rows"] = base_rows
payload["baseline_summary"] = _rows_summary(base_rows)
payload["diff"] = _diff_rows(base_rows, rows)
@@ -1272,6 +1288,7 @@ def run_playground_sweep(
concurrency: int,
param_y: str | None = None,
values_y: list[float] | None = None,
+ prebuilt: tuple[Any, Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Sweep one param (1D curve) or two params (2D heatmap) and score each point
by ``objective`` computed from the per-cycle rows. Executor-safe; never raises.
@@ -1296,7 +1313,7 @@ def run_playground_sweep(
selected = selected[:concurrency]
def _metric_for(override: dict[str, Any]) -> tuple[float | None, dict[str, Any]]:
- rows = _run_rows(store, selected, base_config, override, options, price)
+ rows = _run_rows(store, selected, base_config, override, options, price, prebuilt)
return objective_metric(rows, objective), _rows_summary(rows)
current_x = _sim_config_summary(base_config).get(
diff --git a/custom_components/ha_washdata/profile_store.py b/custom_components/ha_washdata/profile_store.py
index 6a7e7f4..f78e53c 100644
--- a/custom_components/ha_washdata/profile_store.py
+++ b/custom_components/ha_washdata/profile_store.py
@@ -3081,9 +3081,16 @@ class ProfileStore:
separate ``reference_cycles`` list so imported cycles never enter usage stats.
"""
dest = self._data["past_cycles"] if target is None else target
- # Generate SHA256 ID
+ # Generate SHA256 ID — dedup suffix avoids collisions when two cycles share
+ # an identical raw start_time + duration (e.g. bulk reference-cycle imports).
+ existing_ids = {c.get("id") for c in dest if isinstance(c, dict)}
unique_str = f"{cycle_data['start_time']}_{cycle_data['duration']}"
- cycle_data["id"] = hashlib.sha256(unique_str.encode()).hexdigest()[:12]
+ candidate = hashlib.sha256(unique_str.encode()).hexdigest()[:12]
+ suffix = 0
+ while candidate in existing_ids:
+ suffix += 1
+ candidate = hashlib.sha256(f"{unique_str}_{suffix}".encode()).hexdigest()[:12]
+ cycle_data["id"] = candidate
# Preserve profile_name if already set by manager; default to None otherwise
if "profile_name" not in cycle_data:
@@ -4814,63 +4821,6 @@ class ProfileStore:
is_prefix_ambiguous=is_prefix_ambiguous,
)
- def match_profile(
- self, power_data: list[tuple[str, float]], duration: float
- ) -> MatchResult:
- """Synchronous wrapper for matching (for use in executor tasks)."""
- # Convert to list for worker
- p_list = [p[1] for p in power_data]
-
- # Prepare snapshots safely
- snapshots: list[dict[str, Any]] = []
- # Accessing self._data in thread is generally safe for reads if not modifying
- for name, profile in self._data["profiles"].items():
- sample_id = profile.get("sample_cycle_id")
- sample_cycle = next((c for c in self._data["past_cycles"] if c["id"] == sample_id), None)
- if not sample_cycle:
- continue
-
- # Decompress sample data
- sample_p_data = decompress_power_data(sample_cycle)
- if not sample_p_data:
- continue
-
- snapshots.append({
- "name": name,
- "avg_duration": profile.get("avg_duration", sample_cycle.get("duration", 0)),
- "sample_power": [x[1] for x in sample_p_data],
- })
-
- config = {
- "min_duration_ratio": self._min_duration_ratio,
- "max_duration_ratio": self._max_duration_ratio,
- "dtw_bandwidth": self.dtw_bandwidth,
- # On-device tuned scoring weights (opt-in); empty = shipped defaults.
- **self._matching_overrides(),
- }
-
- candidates = analysis.compute_matches_worker(
- p_list, duration, cast(Any, snapshots), config
- )
-
- if not candidates:
- return MatchResult(None, 0.0, 0.0, None, [], False, 0.0)
-
- best = candidates[0]
-
- margin, is_ambiguous = _ambiguity_from_candidates(candidates)
-
- return MatchResult(
- best["name"],
- best["score"],
- best["profile_duration"],
- None,
- candidates,
- is_ambiguous,
- margin,
- ranking=candidates,
- )
-
async def async_verify_alignment(
self,
profile_name: str,
@@ -4952,23 +4902,34 @@ class ProfileStore:
if not phases:
return None
+ # Guard against non-dict entries (legacy/malformed storage) and
+ # None/non-numeric start values before sorting.
phases_sorted = sorted(
- phases,
- key=lambda p: float(p.get("start", 0)),
+ [p for p in phases if isinstance(p, dict)],
+ key=lambda p: float(p.get("start") or 0),
)
+ last_matched: str | None = None
for phase in phases_sorted:
- p_start = phase.get("start", 0)
- p_end = phase.get("end", 0)
+ p_start = float(phase.get("start") or 0)
+ p_end = float(phase.get("end") or 0)
+ if p_end <= p_start:
+ # Missing or zero end — phase range is invalid; skip silently.
+ continue
if p_start <= duration <= p_end:
return str(phase.get("name", "Unknown"))
+ if p_start <= duration:
+ # Track the last phase whose start we have passed; used for gap fallback.
+ last_matched = str(phase.get("name", "Unknown"))
- # If duration is outside explicit bounds, keep a phase label anyway so
- # entities avoid falling back to generic running/starting states.
+ # Duration is outside explicit bounds — keep a label so entities avoid
+ # falling back to generic running/starting states.
if phases_sorted:
- if duration < float(phases_sorted[0].get("start", 0)):
+ if duration < float(phases_sorted[0].get("start") or 0):
return str(phases_sorted[0].get("name", "Unknown"))
- return str(phases_sorted[-1].get("name", "Unknown"))
+ # Return the phase that just ended (last_matched) when in a gap, or the
+ # final phase when past all ranges, rather than always the absolute last.
+ return last_matched or str(phases_sorted[-1].get("name", "Unknown"))
return None
@@ -5835,6 +5796,19 @@ class ProfileStore:
cycle["sampling_interval"] = round(sampling_interval, 1)
cycle["duration"] = new_duration
+ # Recompute energy and max_power from the trimmed trace so stored stats
+ # reflect the kept window (not the pre-trim full cycle).
+ ts_s = np.array([r[0] for r in renorm], dtype=float)
+ p_s = np.array([r[1] for r in renorm], dtype=float)
+ if len(ts_s) > 1:
+ cycle["energy_wh"] = round(
+ integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3
+ )
+ cycle["max_power"] = round(float(np.max(p_s)), 1)
+ elif len(ts_s) == 1:
+ cycle["energy_wh"] = 0.0
+ cycle["max_power"] = round(float(p_s[0]), 1)
+
# Keep end_time consistent with the updated start_time and duration
new_start_ts = _value_to_timestamp(cycle.get("start_time"))
if new_start_ts is not None:
@@ -5986,38 +5960,55 @@ class ProfileStore:
return []
cycle = cycles[idx]
- cycles.pop(idx) # Remove original
- new_ids: list[str] = []
- original_profile = cycle.get("profile_name")
+ # Validate before mutating — early returns below this point would otherwise
+ # permanently delete the source cycle from the live list.
start_dt_base_parsed = _parse_start_dt(cycle["start_time"])
if not start_dt_base_parsed:
return []
- start_ts = start_dt_base_parsed.timestamp()
-
- # Decompress original data
p_data_tuples = decompress_power_data(cycle)
if not p_data_tuples:
return []
+ # Pre-materialise all segment bounds before touching history; a malformed
+ # segment that raises after the pop would leave the source cycle permanently
+ # deleted with only partial children created.
+ try:
+ materialized_segs: list[tuple[float, float, str | None]] = []
+ for seg in segments:
+ if isinstance(seg, (list, tuple)):
+ seg_s = float(seg[0])
+ seg_e = float(seg[1])
+ seg_p: str | None = None
+ else:
+ seg_s = float(seg["start"])
+ seg_e = float(seg["end"])
+ seg_p = seg.get("profile")
+ if seg_e <= seg_s:
+ return []
+ materialized_segs.append((seg_s, seg_e, seg_p))
+ except (TypeError, ValueError, KeyError, IndexError):
+ return []
+ # A split into fewer than two pieces is not a real split; guard here so
+ # an empty or single-element segments list can't pop the source cycle and
+ # leave zero or one orphaned children.
+ if len(materialized_segs) < 2:
+ return []
+
+ cycles.pop(idx) # Remove original — all segments validated, safe to mutate
+
+ new_ids: list[str] = []
+ original_profile = cycle.get("profile_name")
+ start_ts = start_dt_base_parsed.timestamp()
+
# Prepare points (relative seconds)
points: list[tuple[float, float]] = []
for offset_seconds, val in p_data_tuples:
points.append((float(offset_seconds), float(val)))
# Create new cycles
- for seg in segments:
- if isinstance(seg, (list, tuple)):
- seg_tuple = cast(tuple[Any, ...] | list[Any], seg)
- seg_start = float(seg_tuple[0])
- seg_end = float(seg_tuple[1])
- seg_profile = None
- else:
- seg_start = float(seg["start"])
- seg_end = float(seg["end"])
- seg_profile = seg.get("profile")
-
+ for seg_start, seg_end, seg_profile in materialized_segs:
seg_dur = seg_end - seg_start
new_cycle_start = start_dt_base_parsed + timedelta(seconds=seg_start)
new_cycle_start_ts = new_cycle_start.timestamp()
@@ -6051,14 +6042,14 @@ class ProfileStore:
"power_data": p_data_abs,
"profile_name": seg_profile
}
- self.add_cycle(new_cycle)
+ await self.async_add_cycle(new_cycle)
new_ids.append(new_cycle["id"])
# Fix profile refs (handle original sample cycle logic)
original_sample_id = cycle.get("id")
best_replacement_id = None
longest_dur = 0
- new_cycles_objs = [c for c in cycles if c["id"] in new_ids] # 'cycles' is mutated by add_cycle
+ new_cycles_objs = [c for c in cycles if c["id"] in new_ids]
for c in new_cycles_objs:
d = c.get("duration", 0)
@@ -6239,6 +6230,18 @@ class ProfileStore:
c1["power_data"] = new_power_data
+ # Recompute energy from the merged trace (merge keeps only c1's original
+ # energy_wh which under-represents the combined cycle).
+ try:
+ ts_s = np.array([pt[0] for pt in new_power_data], dtype=float)
+ p_s = np.array([pt[1] for pt in new_power_data], dtype=float)
+ if len(ts_s) > 1:
+ c1["energy_wh"] = round(
+ integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3
+ )
+ except Exception: # pylint: disable=broad-exception-caught
+ self._logger.warning("Energy recompute failed after cycle merge", exc_info=True)
+
# New Hash ID
new_id = hashlib.sha256(f"{c1['start_time']}_{c1['duration']}".encode()).hexdigest()[:12]
old_c1_id = c1["id"]
diff --git a/custom_components/ha_washdata/setup_advisor.py b/custom_components/ha_washdata/setup_advisor.py
index 50306cc..65a456d 100644
--- a/custom_components/ha_washdata/setup_advisor.py
+++ b/custom_components/ha_washdata/setup_advisor.py
@@ -127,7 +127,14 @@ def compute_setup_phase(
# None is also accepted (function signature allows it) and treated as no gap.
# Also skipped once the user has permanently dismissed ("never") or snoozed the
# Phase 1 guidance via setup_skip_phase1.
- if has_real and not (coverage_gap and coverage_gap.get("suggest_create")) and not _is_step_suppressed("setup_skip_phase1", skipped_steps, now):
+ # Auto-graduated when the device is clearly established: ≥2 real profiles, or
+ # ≥5 cycles assigned to real profiles. At that point the "record your first
+ # cycle" nudge is stale and misleading regardless of whether the user ever
+ # clicked Skip.
+ _established = len(real) >= 2 or _real_cycle_count(past_cycles, real) >= 5
+ _coverage_gap_actionable = bool(coverage_gap and coverage_gap.get("suggest_create"))
+ _phase1_suppressed = _is_step_suppressed("setup_skip_phase1", skipped_steps, now)
+ if has_real and not _established and not _coverage_gap_actionable and not _phase1_suppressed:
if has_recorded:
first_recorded_profile = _first_recorded_profile_name(past_cycles, real)
return SetupPhaseResult(
@@ -171,6 +178,10 @@ def _real_profile_names(profile_names: list[str], past_cycles: list[dict]) -> se
return {n for n in profile_names if n in named}
+def _real_cycle_count(past_cycles: list[dict], real: set[str]) -> int:
+ return sum(1 for c in past_cycles if c.get("profile_name") in real)
+
+
def _has_recorded_cycles(past_cycles: list[dict], real: set[str]) -> bool:
return any(
(c.get("meta") or {}).get("source") == "recorder"
diff --git a/custom_components/ha_washdata/suggestion_engine.py b/custom_components/ha_washdata/suggestion_engine.py
index dbbe859..6c3b7c3 100644
--- a/custom_components/ha_washdata/suggestion_engine.py
+++ b/custom_components/ha_washdata/suggestion_engine.py
@@ -481,15 +481,17 @@ def reconcile_suggestions(
adjust(CONF_MIN_POWER, round(stop * 0.8, 1), "the stop threshold")
# ── Rule 2: min_off_gap >= off_delay ──────────────────────────────────
- # Prefer raising the gap (derived); lower off_delay only when the gap is
- # a fixed current value.
+ # Always cascade-RAISE the gap to the off delay; never lower off_delay.
+ # Lowering off_delay makes end/pause detection more aggressive and can
+ # split a genuine multi-minute soak pause into two separate cycles. The
+ # detector already takes max(off_delay, min_off_gap) at runtime, so
+ # raising the gap is the safe (and no-op-at-runtime) direction; the
+ # smart_debounce coupling that a larger gap would otherwise inflate is
+ # bounded in cycle_detector.py.
min_gap = eff(CONF_MIN_OFF_GAP)
off_delay = eff(CONF_OFF_DELAY)
if off_delay is not None and min_gap is not None and min_gap < off_delay and in_out(CONF_MIN_OFF_GAP, CONF_OFF_DELAY):
- if is_original(CONF_MIN_OFF_GAP):
- adjust(CONF_MIN_OFF_GAP, off_delay, "the off delay")
- else:
- adjust(CONF_OFF_DELAY, min_gap, "the minimum off gap")
+ adjust(CONF_MIN_OFF_GAP, off_delay, "the off delay")
# ── Rule 3a: watchdog_interval >= 2 × sampling_interval ───────────────
sampling = eff(CONF_SAMPLING_INTERVAL)
diff --git a/custom_components/ha_washdata/task_registry.py b/custom_components/ha_washdata/task_registry.py
index 2f90f8d..7300979 100644
--- a/custom_components/ha_washdata/task_registry.py
+++ b/custom_components/ha_washdata/task_registry.py
@@ -28,6 +28,7 @@ listener to push updates and calls :func:`get_registry` to read/kick/cancel.
"""
from __future__ import annotations
+import asyncio
import logging
import uuid
from collections import OrderedDict
@@ -125,6 +126,9 @@ class TaskRegistry:
def __init__(self) -> None:
self._tasks: OrderedDict[str, Task] = OrderedDict()
self._listeners: set[Callable[[dict[str, Any]], None]] = set()
+ # Raw asyncio Task handles linked by ws_api so cancel_entry_tasks can
+ # actually inject CancelledError, not just set the polling flag.
+ self._asyncio_tasks: dict[str, asyncio.Task[Any]] = {}
# -- listeners -----------------------------------------------------------
def add_listener(self, cb: Callable[[dict[str, Any]], None]) -> Callable[[], None]:
@@ -165,6 +169,15 @@ class TaskRegistry:
self._evict()
return task
+ def link_asyncio_task(self, task_id: str, asyncio_task: asyncio.Task[Any]) -> None:
+ """Associate the raw asyncio Task with a registry entry.
+
+ Call this immediately after hass.async_create_task() so that
+ cancel_entry_tasks() can inject CancelledError rather than only
+ setting the polling flag.
+ """
+ self._asyncio_tasks[task_id] = asyncio_task
+
def update(
self,
task: Task,
@@ -197,6 +210,11 @@ class TaskRegistry:
result: Any = None,
error: str | None = None,
) -> None:
+ # A cancellation that races a late-arriving normal completion must win:
+ # once STATE_CANCELLED is set, no subsequent finish() call can downgrade it.
+ if task.state == STATE_CANCELLED and state != STATE_CANCELLED:
+ return
+ self._asyncio_tasks.pop(task.id, None)
task.state = state
task.error = error
if result is not None:
@@ -215,6 +233,28 @@ class TaskRegistry:
return True
return False
+ def cancel_entry_tasks(self, entry_id: str) -> list[asyncio.Task[Any]]:
+ """Cancel all running tasks for an entry and mark them finished.
+
+ Sets the polling flag and — when a raw asyncio Task was linked via
+ link_asyncio_task — injects CancelledError so the coroutine stops
+ promptly rather than waiting for the next cancel_requested poll.
+ Returns the list of raw asyncio Tasks that were cancelled so the caller
+ can await their completion (ensuring finally blocks run and locks are
+ released) before tearing down shared state.
+ Called during async_unload_entry.
+ """
+ cancelled: list[asyncio.Task[Any]] = []
+ for task in list(self._tasks.values()):
+ if task.entry_id == entry_id and task.state == STATE_RUNNING:
+ task._cancelled = True # noqa: SLF001
+ raw = self._asyncio_tasks.pop(task.id, None)
+ if raw is not None and not raw.done():
+ raw.cancel()
+ cancelled.append(raw)
+ self.finish(task, state=STATE_CANCELLED)
+ return cancelled
+
# -- reads ---------------------------------------------------------------
def get(self, task_id: str) -> Task | None:
return self._tasks.get(task_id)
@@ -227,12 +267,16 @@ class TaskRegistry:
]
def _evict(self) -> None:
- finished = sorted(
- [t for t in self._tasks.values() if t.state != STATE_RUNNING],
- key=lambda t: t.finished_at or 0.0,
- )
- while len(finished) > _MAX_FINISHED:
- self._tasks.pop(finished.pop(0).id, None)
+ # Evict per entry_id so that a busy entry cannot displace another entry's
+ # finished results before the panel reads them via get_task_result.
+ by_entry: dict[str, list[Task]] = {}
+ for t in self._tasks.values():
+ if t.state != STATE_RUNNING:
+ by_entry.setdefault(t.entry_id, []).append(t)
+ for tasks in by_entry.values():
+ tasks.sort(key=lambda t: t.finished_at or 0.0)
+ while len(tasks) > _MAX_FINISHED:
+ self._tasks.pop(tasks.pop(0).id, None)
def get_registry(hass: HomeAssistant) -> TaskRegistry:
diff --git a/custom_components/ha_washdata/translations/panel/bg.json b/custom_components/ha_washdata/translations/panel/bg.json
index 91c9895..4c33b0c 100644
--- a/custom_components/ha_washdata/translations/panel/bg.json
+++ b/custom_components/ha_washdata/translations/panel/bg.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Спад, по-голям от тази част от текущата мощност, също се третира като рязък (0,6 = 60% спад). Допълва прага на ватовете за всички размери на уреда.",
- "label": "Съотношение на рязко падане"
- },
- "abrupt_drop_watts": {
- "doc": "Спад на мощността, по-голям от този, означава цикъла като прекъснат (ръчно отмяна), а не като естествен завършек.",
- "label": "Рязко падане"
- },
"anti_wrinkle_enabled": {
"doc": "Разпознайте кратките импулси на сушене с ниска мощност, които сушилнята излъчва след основната топлинна фаза, и ги запазете прикрепени към завършения цикъл, вместо да ги четете като нови цикли.",
"label": "Активиране на антибръчково засичане"
@@ -1714,7 +1706,6 @@
"other": "Друго"
},
"pg_desc": {
- "abrupt_drop_watts": "Рязък спад се третира като незабавен край",
"completion_min_seconds": "Най-краткото изпълнение, което се счита за истински цикъл",
"end_repeat_count": "Ниски отчитания поред преди приключване",
"interrupted_min_seconds": "Кратки цикли се отбелязват като прекъснати",
diff --git a/custom_components/ha_washdata/translations/panel/bs.json b/custom_components/ha_washdata/translations/panel/bs.json
index c49b303..0f4c81c 100644
--- a/custom_components/ha_washdata/translations/panel/bs.json
+++ b/custom_components/ha_washdata/translations/panel/bs.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Pad veći od ovog udjela struje također se tretira kao nagli (0,6 = pad od 60%). Dopunjuje prag u vatima za različite veličine uređaja.",
- "label": "Omjer naglog opadanja"
- },
- "abrupt_drop_watts": {
- "doc": "Pad snage veći od ovog označava ciklus kao Prekinut (ručno otkazivanje), a ne kao prirodni završetak.",
- "label": "Naglo opadanje"
- },
"anti_wrinkle_enabled": {
"doc": "Prepoznajte kratke impulse male snage koje sušilica emituje nakon glavne faze zagrijavanja i držite ih priključene na završeni ciklus umjesto da ih čitate kao nove cikluse.",
"label": "Omogućiti detekciju anti-gužvanja"
@@ -1714,7 +1706,6 @@
"other": "Ostalo"
},
"pg_desc": {
- "abrupt_drop_watts": "Nagli pad se tretira kao trenutni završetak",
"completion_min_seconds": "Najkraće pokretanje koje se računa kao pravi ciklus",
"end_repeat_count": "Niska očitanja zaredom prije završetka",
"interrupted_min_seconds": "Kratki ciklusi označeni kao prekinuti",
diff --git a/custom_components/ha_washdata/translations/panel/cs.json b/custom_components/ha_washdata/translations/panel/cs.json
index 215fd3b..f905f84 100644
--- a/custom_components/ha_washdata/translations/panel/cs.json
+++ b/custom_components/ha_washdata/translations/panel/cs.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Pokles větší než tento zlomek aktuálního příkonu je také považován za náhlý (0,6 = pokles o 60 %). Doplňuje prahovou hodnotu ve wattech pro spotřebiče různých velikostí.",
- "label": "Poměr náhlého poklesu"
- },
- "abrupt_drop_watts": {
- "doc": "Pokles příkonu větší než tato hodnota označuje cyklus jako Přerušený (ručně zrušeno) spíše než přirozené ukončení.",
- "label": "Náhlý pokles"
- },
"anti_wrinkle_enabled": {
"doc": "Rozpoznejte krátké impulzy s nízkým příkonem, které sušič emituje po hlavní tepelné fázi, a udržte je připojené k dokončenému cyklu místo čtení jako nové cykly.",
"label": "Povolit detekci ochrany před zmačkáním"
@@ -1714,7 +1706,6 @@
"other": "Jiné"
},
"pg_desc": {
- "abrupt_drop_watts": "Náhlý pokles považovaný za okamžitý konec",
"completion_min_seconds": "Nejkratší běh, který se počítá jako skutečný cyklus",
"end_repeat_count": "Nízká čtení za sebou před ukončením",
"interrupted_min_seconds": "Krátké cykly označené jako přerušené",
diff --git a/custom_components/ha_washdata/translations/panel/da.json b/custom_components/ha_washdata/translations/panel/da.json
index f37eb3e..a24e784 100644
--- a/custom_components/ha_washdata/translations/panel/da.json
+++ b/custom_components/ha_washdata/translations/panel/da.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Muligvis blandede programmer"
},
"pg_desc": {
- "abrupt_drop_watts": "Pludseligt fald behandlet som øjeblikkelig afslutning",
"completion_min_seconds": "Korteste kørsel, der tæller som en rigtig cyklus",
"end_repeat_count": "Lave målinger i træk før afslutning",
"interrupted_min_seconds": "Korte cyklusser markeres som afbrudt",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Et fald, der er større end denne del af den nuværende effekt, behandles også som brat (0,6 = et fald på 60%). Komplementerer watt-tærsklen på tværs af apparatstørrelser.",
- "label": "Ratio for pludseligt fald"
- },
- "abrupt_drop_watts": {
- "doc": "Et krafttab større end dette markerer cyklussen som afbrudt (manuel annullering) snarere end en naturlig finish.",
- "label": "Pludseligt fald"
- },
"anti_wrinkle_enabled": {
"doc": "Genkend de korte tørretumblerimpulser med lav effekt, som en tørretumbler udsender efter hovedvarmefasen, og hold dem fastgjort til den færdige cyklus i stedet for at læse dem som nye cyklusser.",
"label": "Aktivér anti-rynke registrering"
diff --git a/custom_components/ha_washdata/translations/panel/de.json b/custom_components/ha_washdata/translations/panel/de.json
index 245d9f0..52e2ae4 100644
--- a/custom_components/ha_washdata/translations/panel/de.json
+++ b/custom_components/ha_washdata/translations/panel/de.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Möglicherweise gemischte Programme"
},
"pg_desc": {
- "abrupt_drop_watts": "Plötzlicher Abfall gilt als sofortiges Ende",
"completion_min_seconds": "Kürzester Lauf, der als echter Zyklus zählt",
"end_repeat_count": "Aufeinanderfolgende niedrige Messwerte vor dem Ende",
"interrupted_min_seconds": "Kurze Zyklen werden als unterbrochen markiert",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Ein Abfall, der größer als dieser Bruchteil der aktuellen Leistung ist, wird ebenfalls als abrupt behandelt (0,6 = ein Abfall von 60 %). Ergänzt den Watt-Grenzwert für alle Gerätegrößen.",
- "label": "Abruptes Abfallverhältnis"
- },
- "abrupt_drop_watts": {
- "doc": "Bei einem größeren Leistungsabfall wird der Zyklus als unterbrochen (manueller Abbruch) und nicht als natürliches Ende gekennzeichnet.",
- "label": "Abrupter Abfall"
- },
"anti_wrinkle_enabled": {
"doc": "Erkennen Sie die kurzen, stromsparenden Wäscheimpulse, die ein Trockner nach der Hauptheizphase abgibt, und behalten Sie im Zusammenhang mit dem abgeschlossenen Zyklus bei, anstatt sie als neue Zyklen zu lesen.",
"label": "Knitterschutzerkennung aktivieren"
diff --git a/custom_components/ha_washdata/translations/panel/el.json b/custom_components/ha_washdata/translations/panel/el.json
index 1e191c6..43d0394 100644
--- a/custom_components/ha_washdata/translations/panel/el.json
+++ b/custom_components/ha_washdata/translations/panel/el.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Μια πτώση μεγαλύτερη από αυτό το κλάσμα της τρέχουσας ισχύος αντιμετωπίζεται επίσης ως απότομη (0,6 = πτώση 60%). Συμπληρώνει το όριο watt σε όλα τα μεγέθη συσκευών.",
- "label": "Αναλογία Απότομης Πτώσης"
- },
- "abrupt_drop_watts": {
- "doc": "Μια πτώση ισχύος μεγαλύτερη από αυτήν επισημαίνει τον κύκλο ως Διακοπή (μη αυτόματη ακύρωση) και όχι ως φυσικό φινίρισμα.",
- "label": "Απότομη Πτώση"
- },
"anti_wrinkle_enabled": {
"doc": "Αναγνωρίστε τους σύντομους παλμούς χαμηλής ισχύος που εκπέμπει ένα στεγνωτήριο μετά την κύρια φάση θερμότητας και κρατήστε τους συνδεδεμένους στον τελικό κύκλο αντί να τους διαβάζετε ως νέους κύκλους.",
"label": "Ενεργοποίηση Ανίχνευσης Κατά Τσακίσματος"
@@ -1714,7 +1706,6 @@
"other": "Άλλο"
},
"pg_desc": {
- "abrupt_drop_watts": "Απότομη πτώση αντιμετωπίζεται ως άμεση λήξη",
"completion_min_seconds": "Η συντομότερη εκτέλεση που μετρά ως πραγματικός κύκλος",
"end_repeat_count": "Χαμηλές μετρήσεις στη σειρά πριν τη λήξη",
"interrupted_min_seconds": "Οι σύντομοι κύκλοι επισημαίνονται ως διακοπή",
diff --git a/custom_components/ha_washdata/translations/panel/en.json b/custom_components/ha_washdata/translations/panel/en.json
index fc863bd..6b45d29 100644
--- a/custom_components/ha_washdata/translations/panel/en.json
+++ b/custom_components/ha_washdata/translations/panel/en.json
@@ -337,6 +337,7 @@
"default_tab": "Default tab when opening the panel",
"delta": "Δ",
"description": "Description",
+ "drag_to_resize": "Drag to resize",
"dtw_blend_w": "Blend weight",
"dtw_blended": "Blended score",
"dtw_ddtw": "DDTW score",
@@ -780,6 +781,8 @@
"pg_analysis_hint": "Select a cycle and click ↺ to load analysis.",
"diagnostics_load_failed": "Could not load diagnostics: {error}",
"manual_duration_ref_hint": "Only used when no reference cycle is selected – a reference cycle sets the duration from its own length.",
+ "head_trim_hint": "Remove this many seconds from the start",
+ "tail_trim_hint": "Remove this many seconds from the end",
"advisory_poor_health": "'{name}' has a low fit score - its recent cycles vary a lot or match weakly. Review its cycles or re-record the profile so matching and time estimates stay accurate.",
"advisory_shape_drift": "'{name}' has drifted significantly from its original power shape. The appliance may have changed behaviour over time (e.g. limescale, wear). Consider re-recording this profile with recent cycles.",
"advisory_shape_drift_corr": "'{name}' has drifted significantly from its original power shape (correlation {corr}). The appliance may have changed behaviour over time (e.g. limescale, wear). Consider re-recording this profile with recent cycles.",
@@ -820,7 +823,6 @@
"share_profile_no_cycles": "No reference cycles – mark a cycle as ⭐ in the Cycles tab to include this profile"
},
"pg_desc": {
- "abrupt_drop_watts": "Sudden drop treated as immediate end",
"completion_min_seconds": "Shortest run that counts as a real cycle",
"end_repeat_count": "Low readings in a row before ending",
"interrupted_min_seconds": "Short cycles flagged as interrupted",
@@ -931,14 +933,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "A drop larger than this fraction of current power is also treated as abrupt (0.6 = a 60% drop). Complements the watts threshold across appliance sizes.",
- "label": "Abrupt Drop Ratio"
- },
- "abrupt_drop_watts": {
- "doc": "A power drop larger than this flags the cycle as Interrupted (manual cancel) rather than a natural finish.",
- "label": "Abrupt Drop"
- },
"anti_wrinkle_enabled": {
"doc": "Recognise the short low-power tumble pulses a dryer emits after the main heat phase and keep them attached to the finished cycle instead of reading them as new cycles.",
"label": "Enable Anti-Wrinkle Detection"
@@ -1831,4 +1825,4 @@
"show_guidance": "Show guidance"
}
}
-}
\ No newline at end of file
+}
diff --git a/custom_components/ha_washdata/translations/panel/es.json b/custom_components/ha_washdata/translations/panel/es.json
index 2cb7a6e..3cb2946 100644
--- a/custom_components/ha_washdata/translations/panel/es.json
+++ b/custom_components/ha_washdata/translations/panel/es.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Posibles programas mezclados"
},
"pg_desc": {
- "abrupt_drop_watts": "Caída repentina tratada como fin inmediato",
"completion_min_seconds": "Ejecución más corta que cuenta como ciclo real",
"end_repeat_count": "Lecturas bajas consecutivas antes de terminar",
"interrupted_min_seconds": "Ciclos cortos marcados como interrumpidos",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Una caída mayor que esta fracción de la potencia actual también se trata como abrupta (0,6 = una caída del 60%). Complementa el umbral de vatios en todos los tamaños de electrodomésticos.",
- "label": "Proporción de caída abrupta"
- },
- "abrupt_drop_watts": {
- "doc": "Una caída de energía mayor que esto indica que el ciclo está interrumpido (cancelación manual) en lugar de un final natural.",
- "label": "Caída abrupta"
- },
"anti_wrinkle_enabled": {
"doc": "Reconozca los impulsos cortos de baja potencia que emite una secadora después de la fase de calentamiento principal y manténgalos adjuntos al ciclo finalizado en lugar de leerlos como ciclos nuevos.",
"label": "Habilitar detección antiarrugas"
diff --git a/custom_components/ha_washdata/translations/panel/et.json b/custom_components/ha_washdata/translations/panel/et.json
index 98b6d95..60a7f12 100644
--- a/custom_components/ha_washdata/translations/panel/et.json
+++ b/custom_components/ha_washdata/translations/panel/et.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Võimalik programmide segu"
},
"pg_desc": {
- "abrupt_drop_watts": "Järsk langus käsitletakse kohese lõpuna",
"completion_min_seconds": "Lühim käivitus, mis loetakse päris tsükliks",
"end_repeat_count": "Madalate näitude arv järjest enne lõpetamist",
"interrupted_min_seconds": "Lühikesed tsüklid märgitakse katkestatuks",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Sellest vooluvõimsuse osast suuremat langust käsitletakse samuti järsuna (0,6 = 60% langus). Täiendab vattide künnist seadme suuruste lõikes.",
- "label": "Järsu Languse Suhe"
- },
- "abrupt_drop_watts": {
- "doc": "Sellest suurem võimsuslangus märgib tsükli pigem Katkestatud (käsitsi tühistamine) kui loomuliku viimistlusena.",
- "label": "Järsk Langus"
- },
"anti_wrinkle_enabled": {
"doc": "Tuvastage lühikesed väikese võimsusega trummelimpulsid, mida kuivati pärast peamist kuumutusfaasi kiirgab, ja hoidke need valmis tsükliga ühendatud, selle asemel, et lugeda neid uute tsüklitena.",
"label": "Luba Kortsudevastane Tuvastamine"
diff --git a/custom_components/ha_washdata/translations/panel/fi.json b/custom_components/ha_washdata/translations/panel/fi.json
index 739a78b..f002ed4 100644
--- a/custom_components/ha_washdata/translations/panel/fi.json
+++ b/custom_components/ha_washdata/translations/panel/fi.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Mahdollisesti sekoittuneet ohjelmat"
},
"pg_desc": {
- "abrupt_drop_watts": "Äkillinen pudotus tulkitaan välittömäksi lopuksi",
"completion_min_seconds": "Lyhin ajo, joka lasketaan oikeaksi sykliksi",
"end_repeat_count": "Peräkkäisten matalien lukemien määrä ennen päättymistä",
"interrupted_min_seconds": "Lyhyet syklit merkitään keskeytyneiksi",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Tätä suurempaa pudotusta virran tehosta käsitellään myös äkillisenä (0,6 = 60 % pudotus). Täydentää wattirajaa eri laitekokojen välillä.",
- "label": "Äkillisen pudotuksen suhde"
- },
- "abrupt_drop_watts": {
- "doc": "Tätä suurempi tehohäviö merkitsee syklin keskeytetyksi (manuaalinen peruutus) luonnollisen viimeistelyn sijaan.",
- "label": "Äkillinen pudotus"
- },
"anti_wrinkle_enabled": {
"doc": "Tunnista lyhyet vähätehoiset rumpupulssit, joita kuivausrumpu lähettää päälämpövaiheen jälkeen, ja pidä ne kiinni valmiissa jaksossa sen sijaan, että luet ne uusina jaksoina.",
"label": "Ota ryppyjenesto käyttöön"
diff --git a/custom_components/ha_washdata/translations/panel/fr.json b/custom_components/ha_washdata/translations/panel/fr.json
index 03bbab9..2b8905e 100644
--- a/custom_components/ha_washdata/translations/panel/fr.json
+++ b/custom_components/ha_washdata/translations/panel/fr.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Programmes peut-être mélangés"
},
"pg_desc": {
- "abrupt_drop_watts": "Chute soudaine traitée comme une fin immédiate",
"completion_min_seconds": "Plus courte exécution comptant comme un vrai cycle",
"end_repeat_count": "Lectures basses consécutives avant la fin",
"interrupted_min_seconds": "Cycles courts signalés comme interrompus",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Une chute supérieure à cette fraction de puissance actuelle est également traitée comme brusque (0,6 = une chute de 60 %). Complète le seuil de watts pour toutes les tailles d’appareils.",
- "label": "Ratio de chute brusque"
- },
- "abrupt_drop_watts": {
- "doc": "Une chute de puissance plus importante signale le cycle comme étant interrompu (annulation manuelle) plutôt que comme une finition naturelle.",
- "label": "Chute brusque"
- },
"anti_wrinkle_enabled": {
"doc": "Reconnaissez les courtes impulsions de culbutage de faible puissance qu'un sèche-linge émet après la phase de chauffage principale et conservez-les attachées au cycle terminé au lieu de les lire comme de nouveaux cycles.",
"label": "Activer la détection anti-froissement"
diff --git a/custom_components/ha_washdata/translations/panel/hr.json b/custom_components/ha_washdata/translations/panel/hr.json
index 1c56cbf..66dbc1d 100644
--- a/custom_components/ha_washdata/translations/panel/hr.json
+++ b/custom_components/ha_washdata/translations/panel/hr.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Pad veći od ovog udjela trenutne snage također se tretira kao nagli (0,6 = pad od 60%). Nadopunjuje prag u vatima za sve veličine uređaja.",
- "label": "Omjer naglog pada"
- },
- "abrupt_drop_watts": {
- "doc": "Pad snage veći od ovoga označava ciklus kao prekinut (ručno otkazivanje), a ne prirodni završetak.",
- "label": "Nagli pad (W)"
- },
"anti_wrinkle_enabled": {
"doc": "Prepoznajte kratke impulse male snage koje sušilica emitira nakon glavne faze zagrijavanja i držite ih vezanima uz završeni ciklus umjesto da ih čitate kao nove cikluse.",
"label": "Omogući otkrivanje anti-bora"
@@ -1714,7 +1706,6 @@
"other": "Ostalo"
},
"pg_desc": {
- "abrupt_drop_watts": "Nagli pad tretiran kao trenutni završetak",
"completion_min_seconds": "Najkraći rad koji se broji kao pravi ciklus",
"end_repeat_count": "Uzastopna niska očitanja prije završetka",
"interrupted_min_seconds": "Kratki ciklusi označeni kao prekinuti",
diff --git a/custom_components/ha_washdata/translations/panel/hu.json b/custom_components/ha_washdata/translations/panel/hu.json
index 8bb13e7..979ff85 100644
--- a/custom_components/ha_washdata/translations/panel/hu.json
+++ b/custom_components/ha_washdata/translations/panel/hu.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Az áramerősség ezen hányadánál nagyobb esést is hirtelennek kell tekinteni (0,6 = 60%-os csökkenés). Kiegészíti a watt-küszöböt a készülékek méretében.",
- "label": "Hirtelen esés aránya"
- },
- "abrupt_drop_watts": {
- "doc": "Az ennél nagyobb teljesítménycsökkenés a ciklust Megszakítottként (kézi törlés) jelzi, nem pedig természetes befejezésként.",
- "label": "Hirtelen esés"
- },
"anti_wrinkle_enabled": {
"doc": "Ismerje fel a szárítógép által a fő fűtési fázis után kibocsátott rövid, kis teljesítményű szárítópulzusokat, és tartsa őket a kész ciklushoz kapcsolva, ahelyett, hogy új ciklusként olvasná őket.",
"label": "Ránctalanítás érzékelés engedélyezése"
@@ -1714,7 +1706,6 @@
"other": "Egyéb"
},
"pg_desc": {
- "abrupt_drop_watts": "A hirtelen esést azonnali befejezésként kezeli",
"completion_min_seconds": "A legrövidebb futás, ami valódi ciklusnak számít",
"end_repeat_count": "Egymást követő alacsony leolvasások a befejezés előtt",
"interrupted_min_seconds": "A rövid ciklusokat megszakítottként jelöli",
diff --git a/custom_components/ha_washdata/translations/panel/is.json b/custom_components/ha_washdata/translations/panel/is.json
index e13ead3..19b2f13 100644
--- a/custom_components/ha_washdata/translations/panel/is.json
+++ b/custom_components/ha_washdata/translations/panel/is.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Hugsanlega blönduð forrit"
},
"pg_desc": {
- "abrupt_drop_watts": "Skyndilegt fall meðhöndlað sem tafarlaus endir",
"completion_min_seconds": "Stysta keyrsla sem telst raunveruleg lota",
"end_repeat_count": "Lágir álestrar í röð áður en lýkur",
"interrupted_min_seconds": "Stuttar lotur merktar sem truflaðar",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Fall sem er stærra en þetta brot af núverandi afli er einnig meðhöndlað sem snöggt (0,6 = 60% lækkun). Bætir wöttaþröskuldinum yfir stærðum tækja.",
- "label": "Hlutfall snöggrar niðurfellingar"
- },
- "abrupt_drop_watts": {
- "doc": "Aflfall sem er stærra en þetta merkir lotuna sem truflað (handvirkt hætta við) frekar en náttúrulega frágang.",
- "label": "Snögg niðurfelling"
- },
"anti_wrinkle_enabled": {
"doc": "Viðurkenndu stuttu lág-afls þurrkarapúlsana sem þurrkari gefur frá sér eftir aðalhitafasann og haltu þeim tengdum við lokið lotu í stað þess að lesa þá sem nýjar lotur.",
"label": "Virkja hrukkuleysi-greiningu"
diff --git a/custom_components/ha_washdata/translations/panel/it.json b/custom_components/ha_washdata/translations/panel/it.json
index 61b6ea4..6fcc951 100644
--- a/custom_components/ha_washdata/translations/panel/it.json
+++ b/custom_components/ha_washdata/translations/panel/it.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Possibili programmi misti"
},
"pg_desc": {
- "abrupt_drop_watts": "Calo improvviso trattato come fine immediata",
"completion_min_seconds": "Esecuzione più breve che conta come ciclo reale",
"end_repeat_count": "Letture basse consecutive prima della fine",
"interrupted_min_seconds": "Cicli brevi contrassegnati come interrotti",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Anche un calo maggiore di questa frazione della potenza attuale viene considerato brusco (0,6 = calo del 60%). Completa la soglia dei watt per tutte le dimensioni degli elettrodomestici.",
- "label": "Rapporto calo brusco"
- },
- "abrupt_drop_watts": {
- "doc": "Un calo di potenza maggiore segnala il ciclo come Interrotto (annullamento manuale) piuttosto che come completamento naturale.",
- "label": "Calo brusco"
- },
"anti_wrinkle_enabled": {
"doc": "Riconosci i brevi impulsi di asciugatrice a bassa potenza emessi da un'asciugatrice dopo la fase di riscaldamento principale e mantienili collegati al ciclo finito invece di leggerli come nuovi cicli.",
"label": "Abilita rilevamento antirughe"
diff --git a/custom_components/ha_washdata/translations/panel/ja.json b/custom_components/ha_washdata/translations/panel/ja.json
index aeb7591..7185de0 100644
--- a/custom_components/ha_washdata/translations/panel/ja.json
+++ b/custom_components/ha_washdata/translations/panel/ja.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ プログラム混在の可能性"
},
"pg_desc": {
- "abrupt_drop_watts": "急激な低下は即時終了として扱う",
"completion_min_seconds": "実サイクルとみなす最短の稼働時間",
"end_repeat_count": "終了前に連続する低い測定値の回数",
"interrupted_min_seconds": "短いサイクルは中断としてフラグ付け",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "現在の電力のこの割合よりも大きな低下も突然として扱われます (0.6 = 60% の低下)。アプライアンスのサイズ全体でワットしきい値を補完します。",
- "label": "急激な降下比率"
- },
- "abrupt_drop_watts": {
- "doc": "これよりも大きな電力低下は、自然な終了ではなく、サイクルに中断 (手動キャンセル) としてフラグを立てます。",
- "label": "急激な降下"
- },
"anti_wrinkle_enabled": {
"doc": "乾燥機が主加熱フェーズの後に発する短い低電力タンブル パルスを認識し、新しいサイクルとして読み取るのではなく、終了したサイクルに接続したままにします。",
"label": "しわ防止検出を有効化"
diff --git a/custom_components/ha_washdata/translations/panel/ko.json b/custom_components/ha_washdata/translations/panel/ko.json
index f5661cf..0fb11a8 100644
--- a/custom_components/ha_washdata/translations/panel/ko.json
+++ b/custom_components/ha_washdata/translations/panel/ko.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ 프로그램이 섞였을 수 있음"
},
"pg_desc": {
- "abrupt_drop_watts": "급격한 강하는 즉시 종료로 처리",
"completion_min_seconds": "실제 사이클로 인정되는 최단 실행",
"end_repeat_count": "종료 전 연속되는 낮은 측정값 횟수",
"interrupted_min_seconds": "짧은 사이클은 중단으로 표시",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "현재 전력의 이 부분보다 큰 하락도 갑작스러운 것으로 간주됩니다(0.6 = 60% 하락). 어플라이언스 크기 전반에 걸쳐 와트 임계값을 보완합니다.",
- "label": "급격한 강하 비율"
- },
- "abrupt_drop_watts": {
- "doc": "이보다 큰 전력 감소는 주기를 자연스러운 종료가 아닌 중단됨(수동 취소)으로 표시합니다.",
- "label": "급격한 강하"
- },
"anti_wrinkle_enabled": {
"doc": "건조기가 주요 가열 단계 후에 방출하는 짧은 저전력 텀블 펄스를 인식하고 이를 새 주기로 읽는 대신 완료된 주기에 연결해 두십시오.",
"label": "구김 방지 감지 활성화"
diff --git a/custom_components/ha_washdata/translations/panel/lt.json b/custom_components/ha_washdata/translations/panel/lt.json
index 29a2243..04dbe98 100644
--- a/custom_components/ha_washdata/translations/panel/lt.json
+++ b/custom_components/ha_washdata/translations/panel/lt.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Didesnis nei ši srovės galios sumažėjimas taip pat laikomas staigiu (0,6 = 60 % kritimas). Papildo vatų slenkstį įvairiems prietaisų dydžiams.",
- "label": "Staigaus kritimo santykis"
- },
- "abrupt_drop_watts": {
- "doc": "Jei galios sumažėjimas didesnis nei šis, ciklas pažymimas kaip Pertrauktas (rankinis atšaukimas), o ne kaip natūrali apdaila.",
- "label": "Staigus kritimas"
- },
"anti_wrinkle_enabled": {
"doc": "Atpažinkite trumpus mažos galios būgninius impulsus, kuriuos džiovyklė skleidžia po pagrindinės šildymo fazės, ir laikykite juos prijungtus prie baigto ciklo, o ne skaitykite juos kaip naujus ciklus.",
"label": "Įjungti apsaugą nuo raukšlių"
@@ -1714,7 +1706,6 @@
"other": "Kita"
},
"pg_desc": {
- "abrupt_drop_watts": "Staigus kritimas laikomas nedelsiama pabaiga",
"completion_min_seconds": "Trumpiausias veikimas, laikomas tikru ciklu",
"end_repeat_count": "Kiek žemų rodmenų iš eilės prieš pabaigą",
"interrupted_min_seconds": "Trumpi ciklai pažymimi kaip pertraukti",
diff --git a/custom_components/ha_washdata/translations/panel/lv.json b/custom_components/ha_washdata/translations/panel/lv.json
index fe2496d..3f1ed8a 100644
--- a/custom_components/ha_washdata/translations/panel/lv.json
+++ b/custom_components/ha_washdata/translations/panel/lv.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Kritums, kas ir lielāks par šo strāvas jaudas daļu, arī tiek uzskatīts par pēkšņu (0,6 = 60% kritums). Papildina vatu slieksni dažādiem ierīču izmēriem.",
- "label": "Pēkšņa krituma attiecība"
- },
- "abrupt_drop_watts": {
- "doc": "Jaudas kritums, kas ir lielāks par šo, norāda ciklu kā Pārtraukts (manuāla atcelšana), nevis dabisku apdari.",
- "label": "Pēkšņs kritums"
- },
"anti_wrinkle_enabled": {
"doc": "Atpazīstiet īsos mazjaudas veļas impulsus, ko žāvētājs izstaro pēc galvenās karsēšanas fāzes, un turiet tos pievienotus gatavajam ciklam, nevis nolasiet tos kā jaunus ciklus.",
"label": "Iespējot pretgrumbu noteikšanu"
@@ -1714,7 +1706,6 @@
"other": "Cits"
},
"pg_desc": {
- "abrupt_drop_watts": "Pēkšņs kritums tiek uzskatīts par tūlītējām beigām",
"completion_min_seconds": "Īsākais darbības laiks, kas skaitās par īstu ciklu",
"end_repeat_count": "Zemi rādījumi pēc kārtas pirms beigām",
"interrupted_min_seconds": "Īsi cikli tiek atzīmēti kā pārtraukti",
diff --git a/custom_components/ha_washdata/translations/panel/mk.json b/custom_components/ha_washdata/translations/panel/mk.json
index 3bb74c1..35680bd 100644
--- a/custom_components/ha_washdata/translations/panel/mk.json
+++ b/custom_components/ha_washdata/translations/panel/mk.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Пад поголем од овој дел од моменталната моќност исто така се третира како нагло (0,6 = пад од 60%). Го надополнува прагот на вати низ големини на апаратот.",
- "label": "Сооднос на нагол пад"
- },
- "abrupt_drop_watts": {
- "doc": "Пад на енергија поголем од ова го означува циклусот како Прекинат (рачно откажување) наместо како природен финиш.",
- "label": "Нагол пад"
- },
"anti_wrinkle_enabled": {
"doc": "Препознајте ги кратките пулсирања со мала моќност што ги испушта машината за сушење по главната топлинска фаза и држете ги прикачени на завршениот циклус наместо да ги читате како нови циклуси.",
"label": "Овозможи откривање против брчки"
@@ -1714,7 +1706,6 @@
"other": "Друго"
},
"pg_desc": {
- "abrupt_drop_watts": "Ненадеен пад се третира како непосреден крај",
"completion_min_seconds": "Најкратко извршување што се смета за вистински циклус",
"end_repeat_count": "Ниски отчитувања по ред пред завршување",
"interrupted_min_seconds": "Кратки циклуси означени како прекинати",
diff --git a/custom_components/ha_washdata/translations/panel/nb.json b/custom_components/ha_washdata/translations/panel/nb.json
index 6e50a8a..5e8cc05 100644
--- a/custom_components/ha_washdata/translations/panel/nb.json
+++ b/custom_components/ha_washdata/translations/panel/nb.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Muligens blandede programmer"
},
"pg_desc": {
- "abrupt_drop_watts": "Plutselig fall behandlet som umiddelbar slutt",
"completion_min_seconds": "Korteste kjøring som teller som en ekte syklus",
"end_repeat_count": "Lave avlesninger på rad før avslutning",
"interrupted_min_seconds": "Korte sykluser merkes som avbrutt",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Et fall som er større enn denne brøkdelen av gjeldende effekt, blir også behandlet som brå (0,6 = et fall på 60 %). Utfyller watt-terskelen på tvers av apparatstørrelser.",
- "label": "Bratt fall-forhold"
- },
- "abrupt_drop_watts": {
- "doc": "Et kraftfall større enn dette flagger syklusen som avbrutt (manuell kansellering) i stedet for en naturlig finish.",
- "label": "Bratt fall"
- },
"anti_wrinkle_enabled": {
"doc": "Gjenkjenn de korte laveffekttrommelpulsene en tørketrommel sender ut etter hovedvarmefasen, og hold dem festet til den ferdige syklusen i stedet for å lese dem som nye sykluser.",
"label": "Aktiver anti-rynke-deteksjon"
diff --git a/custom_components/ha_washdata/translations/panel/nl.json b/custom_components/ha_washdata/translations/panel/nl.json
index c83fb47..961ec2a 100644
--- a/custom_components/ha_washdata/translations/panel/nl.json
+++ b/custom_components/ha_washdata/translations/panel/nl.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Mogelijk gemengde programma's"
},
"pg_desc": {
- "abrupt_drop_watts": "Plotselinge daling behandeld als onmiddellijk einde",
"completion_min_seconds": "Kortste run die als een echte cyclus telt",
"end_repeat_count": "Opeenvolgende lage metingen voor het einde",
"interrupted_min_seconds": "Korte cycli gemarkeerd als onderbroken",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Een daling groter dan deze fractie van het huidige vermogen wordt ook als abrupt beschouwd (0,6 = een daling van 60%). Vult de wattdrempel aan voor alle apparaatformaten.",
- "label": "Ratio plotse daling"
- },
- "abrupt_drop_watts": {
- "doc": "Een stroomdaling groter dan dit markeert de cyclus als onderbroken (handmatige annulering) in plaats van als een natuurlijke afwerking.",
- "label": "Plotse daling"
- },
"anti_wrinkle_enabled": {
"doc": "Herken de korte tuimelpulsen met laag vermogen die een droger afgeeft na de hoofdverwarmingsfase en houd ze vast aan het voltooide programma in plaats van ze als nieuwe cycli te lezen.",
"label": "Anti-kreuk detectie inschakelen"
diff --git a/custom_components/ha_washdata/translations/panel/pl.json b/custom_components/ha_washdata/translations/panel/pl.json
index 9740ca7..4854378 100644
--- a/custom_components/ha_washdata/translations/panel/pl.json
+++ b/custom_components/ha_washdata/translations/panel/pl.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Spadek większy niż ten ułamek mocy prądu jest również traktowany jako gwałtowny (0,6 = spadek o 60%). Uzupełnia próg watów dla różnych rozmiarów urządzeń.",
- "label": "Współczynnik Nagłego Spadku"
- },
- "abrupt_drop_watts": {
- "doc": "Większy spadek mocy oznacza cykl jako przerwany (ręczne anulowanie), a nie jako naturalne zakończenie.",
- "label": "Nagły Spadek"
- },
"anti_wrinkle_enabled": {
"doc": "Rozpoznawaj krótkie impulsy suszenia o małej mocy, które suszarka emituje po głównej fazie nagrzewania, i pamiętaj o ich dołączeniu do zakończonego cyklu, zamiast odczytywać je jako nowe cykle.",
"label": "Włącz Wykrywanie Antygniotowe"
@@ -1714,7 +1706,6 @@
"other": "Inne"
},
"pg_desc": {
- "abrupt_drop_watts": "Nagły spadek traktowany jako natychmiastowy koniec",
"completion_min_seconds": "Najkrótszy przebieg liczony jako prawdziwy cykl",
"end_repeat_count": "Kolejne niskie odczyty przed zakończeniem",
"interrupted_min_seconds": "Krótkie cykle oznaczane jako przerwane",
diff --git a/custom_components/ha_washdata/translations/panel/pt-BR.json b/custom_components/ha_washdata/translations/panel/pt-BR.json
index 11c54c8..de2fe80 100644
--- a/custom_components/ha_washdata/translations/panel/pt-BR.json
+++ b/custom_components/ha_washdata/translations/panel/pt-BR.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Uma queda maior que esta fração da potência atual também é tratada como abrupta (0,6 = queda de 60%). Complementa o limite de watts em todos os tamanhos de aparelhos.",
- "label": "Proporção de Queda Abrupta"
- },
- "abrupt_drop_watts": {
- "doc": "Uma queda de energia maior que isso sinaliza o ciclo como Interrompido (cancelamento manual) em vez de um término natural.",
- "label": "Queda Abrupta"
- },
"anti_wrinkle_enabled": {
"doc": "Reconheça os pulsos curtos de baixa potência que uma secadora emite após a fase de aquecimento principal e mantenha-os anexados ao ciclo finalizado, em vez de lê-los como novos ciclos.",
"label": "Ativar Detecção Antiamasso"
@@ -1714,7 +1706,6 @@
"other": "Outro"
},
"pg_desc": {
- "abrupt_drop_watts": "Queda súbita tratada como fim imediato",
"completion_min_seconds": "Menor duração que conta como ciclo real",
"end_repeat_count": "Leituras baixas seguidas antes de terminar",
"interrupted_min_seconds": "Ciclos curtos marcados como interrompidos",
diff --git a/custom_components/ha_washdata/translations/panel/pt.json b/custom_components/ha_washdata/translations/panel/pt.json
index ceb3fa7..0589047 100644
--- a/custom_components/ha_washdata/translations/panel/pt.json
+++ b/custom_components/ha_washdata/translations/panel/pt.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Possíveis programas misturados"
},
"pg_desc": {
- "abrupt_drop_watts": "Queda repentina tratada como fim imediato",
"completion_min_seconds": "Execução mais curta que conta como um ciclo real",
"end_repeat_count": "Leituras baixas consecutivas antes de terminar",
"interrupted_min_seconds": "Ciclos curtos marcados como interrompidos",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Uma queda maior que esta fração da potência atual também é tratada como abrupta (0,6 = queda de 60%). Complementa o limite de watts em todos os tamanhos de aparelhos.",
- "label": "Rácio de Queda Abrupta"
- },
- "abrupt_drop_watts": {
- "doc": "Uma queda de energia maior que isso sinaliza o ciclo como Interrompido (cancelamento manual) em vez de um término natural.",
- "label": "Queda Abrupta"
- },
"anti_wrinkle_enabled": {
"doc": "Reconheça os pulsos curtos de baixa potência que uma secadora emite após a fase de aquecimento principal e mantenha-os anexados ao ciclo finalizado, em vez de lê-los como novos ciclos.",
"label": "Activar Detecção Anti-Vincos"
diff --git a/custom_components/ha_washdata/translations/panel/ro.json b/custom_components/ha_washdata/translations/panel/ro.json
index 95bef7c..f625dfa 100644
--- a/custom_components/ha_washdata/translations/panel/ro.json
+++ b/custom_components/ha_washdata/translations/panel/ro.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "O scădere mai mare decât această fracțiune a puterii curente este, de asemenea, tratată ca fiind bruscă (0,6 = o scădere de 60%). Completează pragul de wați în funcție de dimensiunile aparatului.",
- "label": "Raport Scădere Bruscă"
- },
- "abrupt_drop_watts": {
- "doc": "O scădere de putere mai mare decât aceasta indică ciclul ca întrerupt (anulare manuală), mai degrabă decât un finisaj natural.",
- "label": "Scădere Bruscă"
- },
"anti_wrinkle_enabled": {
"doc": "Recunoașteți impulsurile scurte de rufe de putere redusă pe care le emite un uscător după faza principală de căldură și mențineți-le atașate la ciclul terminat în loc să le citiți ca cicluri noi.",
"label": "Activați Detectarea Anti-Șifonare"
@@ -1714,7 +1706,6 @@
"other": "Altele"
},
"pg_desc": {
- "abrupt_drop_watts": "Scădere bruscă tratată ca sfârșit imediat",
"completion_min_seconds": "Cea mai scurtă rulare care contează ca ciclu real",
"end_repeat_count": "Citiri scăzute consecutive înainte de încheiere",
"interrupted_min_seconds": "Ciclurile scurte sunt marcate ca întrerupte",
diff --git a/custom_components/ha_washdata/translations/panel/ru.json b/custom_components/ha_washdata/translations/panel/ru.json
index 0689b17..a14584e 100644
--- a/custom_components/ha_washdata/translations/panel/ru.json
+++ b/custom_components/ha_washdata/translations/panel/ru.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Падение, превышающее эту долю текущей мощности, также считается резким (0,6 = падение на 60%). Дополняет пороговое значение мощности для устройств разных размеров.",
- "label": "Коэффициент Резкого Падения"
- },
- "abrupt_drop_watts": {
- "doc": "Падение мощности, превышающее это значение, помечает цикл как прерванный (отмена вручную), а не как естественное завершение.",
- "label": "Резкое Падение"
- },
"anti_wrinkle_enabled": {
"doc": "Распознавайте короткие импульсы низкой мощности, которые сушильная машина излучает после основной фазы нагрева, и сохраняйте их привязанными к завершенному циклу, а не воспринимайте их как новые циклы.",
"label": "Включить Защиту от Морщин"
@@ -1714,7 +1706,6 @@
"other": "Другое"
},
"pg_desc": {
- "abrupt_drop_watts": "Резкое падение считается немедленным завершением",
"completion_min_seconds": "Кратчайший запуск, считающийся настоящим циклом",
"end_repeat_count": "Сколько низких показаний подряд до завершения",
"interrupted_min_seconds": "Короткие циклы помечаются как прерванные",
diff --git a/custom_components/ha_washdata/translations/panel/sk.json b/custom_components/ha_washdata/translations/panel/sk.json
index 87b62e4..8a13743 100644
--- a/custom_components/ha_washdata/translations/panel/sk.json
+++ b/custom_components/ha_washdata/translations/panel/sk.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Pokles väčší ako tento zlomok prúdu sa tiež považuje za náhly (0,6 = 60 % pokles). Dopĺňa prahovú hodnotu vo wattoch naprieč veľkosťami spotrebičov.",
- "label": "Pomer náhleho poklesu"
- },
- "abrupt_drop_watts": {
- "doc": "Pokles výkonu väčší ako tento označí cyklus ako prerušený (manuálne zrušenie), a nie ako prirodzený koniec.",
- "label": "Náhly pokles"
- },
"anti_wrinkle_enabled": {
"doc": "Rozpoznajte krátke pulzy bubna s nízkym výkonom, ktoré sušička vydáva po hlavnej fáze ohrevu, a nechajte ich pripojené k dokončenému cyklu namiesto toho, aby ste ich čítali ako nové cykly.",
"label": "Povoliť detekciu ochrany pred krčením"
@@ -1714,7 +1706,6 @@
"other": "Iné"
},
"pg_desc": {
- "abrupt_drop_watts": "Náhly pokles považovaný za okamžitý koniec",
"completion_min_seconds": "Najkratší beh, ktorý sa počíta ako skutočný cyklus",
"end_repeat_count": "Nízke čítania za sebou pred ukončením",
"interrupted_min_seconds": "Krátke cykly označené ako prerušené",
diff --git a/custom_components/ha_washdata/translations/panel/sl.json b/custom_components/ha_washdata/translations/panel/sl.json
index 399aeb3..f7c684c 100644
--- a/custom_components/ha_washdata/translations/panel/sl.json
+++ b/custom_components/ha_washdata/translations/panel/sl.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Padec, večji od tega deleža trenutne moči, se prav tako obravnava kot nenaden (0,6 = 60-odstotni padec). Dopolnjuje mejno vrednost v vatih za vse velikosti aparatov.",
- "label": "Razmerje nenadnega padca"
- },
- "abrupt_drop_watts": {
- "doc": "Večji padec moči od tega označi cikel kot prekinjen (ročni preklic) in ne kot naravni zaključek.",
- "label": "Nenaden padec"
- },
"anti_wrinkle_enabled": {
"doc": "Prepoznajte kratke vrtilne impulze nizke moči, ki jih oddaja sušilni stroj po glavni fazi segrevanja, in jih ohranite povezane s končanim ciklom, namesto da jih berete kot nove cikle.",
"label": "Omogoči zaznavanje zaščite pred gubami"
@@ -1714,7 +1706,6 @@
"other": "Drugo"
},
"pg_desc": {
- "abrupt_drop_watts": "Nenaden padec, obravnavan kot takojšen konec",
"completion_min_seconds": "Najkrajši zagon, ki šteje kot pravi cikel",
"end_repeat_count": "Zaporedni nizki odčitki pred koncem",
"interrupted_min_seconds": "Kratki cikli, označeni kot prekinjeni",
diff --git a/custom_components/ha_washdata/translations/panel/sq.json b/custom_components/ha_washdata/translations/panel/sq.json
index cb8e4ba..bcfa5d5 100644
--- a/custom_components/ha_washdata/translations/panel/sq.json
+++ b/custom_components/ha_washdata/translations/panel/sq.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Një rënie më e madhe se kjo pjesë e fuqisë aktuale trajtohet gjithashtu si e papritur (0.6 = një rënie prej 60%). Plotëson pragun e vateve në përmasat e pajisjes.",
- "label": "Raporti i rënies së papritur"
- },
- "abrupt_drop_watts": {
- "doc": "Një rënie energjie më e madhe se kjo e shënon ciklin si të ndërprerë (anulim manual) dhe jo si përfundim natyral.",
- "label": "Rënie e papritur"
- },
"anti_wrinkle_enabled": {
"doc": "Njihni pulset e shkurtra me fuqi të ulët që lëshon një tharëse pas fazës kryesore të nxehtësisë dhe mbajini të lidhura me ciklin e përfunduar në vend që t'i lexoni si cikle të reja.",
"label": "Aktivizo zbulimin kundër rrudhave"
@@ -1714,7 +1706,6 @@
"other": "Tjetër"
},
"pg_desc": {
- "abrupt_drop_watts": "Rënia e papritur trajtohet si përfundim i menjëhershëm",
"completion_min_seconds": "Ekzekutimi më i shkurtër që llogaritet si cikël i vërtetë",
"end_repeat_count": "Lexime të ulëta radhazi para përfundimit",
"interrupted_min_seconds": "Ciklet e shkurtra shënohen si të ndërprera",
diff --git a/custom_components/ha_washdata/translations/panel/sr-Latn.json b/custom_components/ha_washdata/translations/panel/sr-Latn.json
index 4008042..30428c3 100644
--- a/custom_components/ha_washdata/translations/panel/sr-Latn.json
+++ b/custom_components/ha_washdata/translations/panel/sr-Latn.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Pad veći od ovog dela struje takođe se tretira kao nagli (0,6 = pad od 60%). Dopunjuje prag u vatima za različite veličine uređaja.",
- "label": "Odnos naglog pada"
- },
- "abrupt_drop_watts": {
- "doc": "Pad snage veći od ovog označava ciklus kao Prekinut (ručno otkazivanje), a ne kao prirodni završetak.",
- "label": "Nagli pad"
- },
"anti_wrinkle_enabled": {
"doc": "Prepoznajte kratke impulse male snage koje mašina za sušenje emituje nakon glavne faze grejanja i držite ih povezanim sa završenim ciklusom umesto da ih čitate kao nove cikluse.",
"label": "Omogući detekciju zaštite od gužvanja"
@@ -1714,7 +1706,6 @@
"other": "Ostalo"
},
"pg_desc": {
- "abrupt_drop_watts": "Nagli pad se tretira kao trenutni završetak",
"completion_min_seconds": "Najkraće pokretanje koje se računa kao pravi ciklus",
"end_repeat_count": "Niska očitavanja zaredom pre završetka",
"interrupted_min_seconds": "Kratki ciklusi označeni kao prekinuti",
diff --git a/custom_components/ha_washdata/translations/panel/sv.json b/custom_components/ha_washdata/translations/panel/sv.json
index 2626d1a..c8228f5 100644
--- a/custom_components/ha_washdata/translations/panel/sv.json
+++ b/custom_components/ha_washdata/translations/panel/sv.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Möjligen blandade program"
},
"pg_desc": {
- "abrupt_drop_watts": "Plötsligt fall som tolkas som omedelbart slut",
"completion_min_seconds": "Kortaste körning som räknas som en riktig cykel",
"end_repeat_count": "Antal låga avläsningar i rad innan avslut",
"interrupted_min_seconds": "Korta cykler markeras som avbrutna",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Ett fall som är större än denna andel av strömeffekten behandlas också som abrupt (0,6 = 60 % minskning). Kompletterar watt-tröskeln över apparatstorlekar.",
- "label": "Abrupt fallförhållande"
- },
- "abrupt_drop_watts": {
- "doc": "Ett effektfall större än detta flaggar cykeln som avbruten (manuell avbrytning) snarare än en naturlig finish.",
- "label": "Abrupt fall"
- },
"anti_wrinkle_enabled": {
"doc": "Känn igen de korta torktumlarpulser med låg effekt en torktumlare avger efter huvuduppvärmningsfasen och håll dem kopplade till den färdiga cykeln istället för att läsa dem som nya cykler.",
"label": "Aktivera skrynkelskyddsdetektering"
diff --git a/custom_components/ha_washdata/translations/panel/tr.json b/custom_components/ha_washdata/translations/panel/tr.json
index a4afae8..dad6a63 100644
--- a/custom_components/ha_washdata/translations/panel/tr.json
+++ b/custom_components/ha_washdata/translations/panel/tr.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Karışık olabilecek programlar"
},
"pg_desc": {
- "abrupt_drop_watts": "Ani düşüş anında bitiş sayılır",
"completion_min_seconds": "Gerçek döngü sayılan en kısa çalışma",
"end_repeat_count": "Bitmeden önce arka arkaya düşük okuma",
"interrupted_min_seconds": "Kısa döngüler kesintiye uğramış olarak işaretlenir",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Akım gücünün bu kısmından daha büyük bir düşüş de ani olarak değerlendirilir (0,6 = %60'lık bir düşüş). Cihaz boyutlarındaki watt eşiğini tamamlar.",
- "label": "Ani Düşüş Oranı"
- },
- "abrupt_drop_watts": {
- "doc": "Bundan daha büyük bir güç düşüşü, döngüyü doğal bir son yerine Kesintili (manuel iptal) olarak işaretler.",
- "label": "Ani Düşüş"
- },
"anti_wrinkle_enabled": {
"doc": "Kurutucunun ana ısıtma aşamasından sonra yaydığı kısa, düşük güçlü tambur darbelerini tanıyın ve bunları yeni döngüler olarak okumak yerine bitmiş döngüye bağlı tutun.",
"label": "Kırışıklık Önleme Algılamayı Etkinleştir"
diff --git a/custom_components/ha_washdata/translations/panel/uk.json b/custom_components/ha_washdata/translations/panel/uk.json
index eb8891a..5124f3d 100644
--- a/custom_components/ha_washdata/translations/panel/uk.json
+++ b/custom_components/ha_washdata/translations/panel/uk.json
@@ -954,14 +954,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "Падіння, яке перевищує цю частку поточної потужності, також вважається різким (0,6 = падіння на 60%). Доповнює порогове значення потужності для різних розмірів приладів.",
- "label": "Коефіцієнт різкого падіння"
- },
- "abrupt_drop_watts": {
- "doc": "Більше падіння потужності позначає цикл як перерваний (ручне скасування), а не як природне завершення.",
- "label": "Різке падіння"
- },
"anti_wrinkle_enabled": {
"doc": "Розпізнавайте короткі малопотужні імпульси обертання, які видає сушильна машина після основної фази нагрівання, і зберігайте їх прив’язаними до завершеного циклу замість того, щоб зчитувати їх як нові цикли.",
"label": "Увімкнути виявлення захисту від зминання"
@@ -1714,7 +1706,6 @@
"other": "Інше"
},
"pg_desc": {
- "abrupt_drop_watts": "Різке падіння вважається негайним завершенням",
"completion_min_seconds": "Найкоротший запуск, що вважається справжнім циклом",
"end_repeat_count": "Скільки низьких показань поспіль до завершення",
"interrupted_min_seconds": "Короткі цикли позначаються як перервані",
diff --git a/custom_components/ha_washdata/translations/panel/zh-Hans.json b/custom_components/ha_washdata/translations/panel/zh-Hans.json
index 5226e28..7e2d134 100644
--- a/custom_components/ha_washdata/translations/panel/zh-Hans.json
+++ b/custom_components/ha_washdata/translations/panel/zh-Hans.json
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ 可能混合了不同程序"
},
"pg_desc": {
- "abrupt_drop_watts": "骤降视为立即结束",
"completion_min_seconds": "计为真实周期的最短运行时间",
"end_repeat_count": "结束前连续的低读数次数",
"interrupted_min_seconds": "短周期标记为中断",
@@ -978,14 +977,6 @@
}
},
"setting": {
- "abrupt_drop_ratio": {
- "doc": "大于电流功率这一部分的下降也被视为突然下降(0.6 = 60% 下降)。补充不同设备尺寸的瓦特阈值。",
- "label": "突降比例"
- },
- "abrupt_drop_watts": {
- "doc": "功率下降大于此值会将循环标记为已中断(手动取消)而不是自然结束。",
- "label": "突降功率"
- },
"anti_wrinkle_enabled": {
"doc": "识别烘干机在主要加热阶段后发出的短的低功率翻滚脉冲,并将它们附加到完成的循环,而不是将它们视为新循环。",
"label": "启用防皱检测"
diff --git a/custom_components/ha_washdata/ws_api.py b/custom_components/ha_washdata/ws_api.py
index ab4517a..ba991d1 100644
--- a/custom_components/ha_washdata/ws_api.py
+++ b/custom_components/ha_washdata/ws_api.py
@@ -73,8 +73,10 @@ from .const import (
CONF_WATCHDOG_INTERVAL,
DEFAULT_DEVICE_TYPE,
DEFAULT_MAINTENANCE_REMINDER_CYCLES,
+ DEFAULT_MIN_POWER,
DEFAULT_OFF_DELAY,
DEFAULT_OFF_DELAY_BY_DEVICE,
+ DEFAULT_RUNNING_DEAD_ZONE,
DEVICE_TYPE_PUMP,
MAINTENANCE_EVENT_TYPES,
DEVICE_TYPES,
@@ -1838,7 +1840,9 @@ def ws_rebuild_envelopes(
entry_id, "rebuild", "Rebuilding envelopes",
label_key="task.rebuild.envelopes", label_params={},
)
- hass.async_create_task(_rebuild_envelopes_task(hass, task, entry_id))
+ _raw = hass.async_create_task(_rebuild_envelopes_task(hass, task, entry_id))
+ if _raw is not None:
+ reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "rebuild_envelopes", {"task_id": task.id})
@@ -1852,8 +1856,10 @@ async def _rebuild_envelopes_task(hass: HomeAssistant, task: Any, entry_id: str)
return
store = manager.profile_store
lock = _entry_write_lock(hass, entry_id)
- await lock.acquire()
+ acquired = False
try:
+ await lock.acquire()
+ acquired = True
names = list(store.get_profiles().keys())
reg.update(task, total=len(names), done=0)
rebuilt = 0
@@ -1873,11 +1879,15 @@ async def _rebuild_envelopes_task(hass: HomeAssistant, task: Any, entry_id: str)
state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
result={"success": True, "rebuilt": rebuilt},
)
+ except asyncio.CancelledError:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.warning("Rebuild-envelopes task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
- lock.release()
+ if acquired:
+ lock.release()
@websocket_api.websocket_command(
@@ -2703,12 +2713,22 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None
# retrains and rewrites the store, and must not interleave with a concurrent
# import / recording persist for the same entry.
lock = _entry_write_lock(hass, entry_id)
- await lock.acquire()
+ acquired = False
try:
+ await lock.acquire()
+ acquired = True
+ if task.cancel_requested:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ return
+
reg.update(task, total=5, done=0, label="Reprocessing: matching cycles",
label_key="task.reprocess.matching")
summary["count"] = await store.async_reprocess_all_data()
+ if task.cancel_requested:
+ reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary)
+ return
+
reg.update(task, done=1, label="Reprocessing: backfilling golden",
label_key="task.reprocess.golden")
try:
@@ -2716,6 +2736,10 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("golden backfill failed for %s: %s", entry_id, exc)
+ if task.cancel_requested:
+ reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary)
+ return
+
reg.update(task, done=2, label="Reprocessing: suggestions",
label_key="task.reprocess.suggestions")
learning = getattr(manager, "learning_manager", None)
@@ -2739,6 +2763,10 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("ML training failed for %s: %s", entry_id, exc)
+ if task.cancel_requested:
+ reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary)
+ return
+
reg.update(task, done=4, label="Reprocessing: cycle health",
label_key="task.reprocess.health")
# Recompute per-cycle health against the (possibly retrained) model.
@@ -2755,13 +2783,17 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None
if _get_manager(hass, entry_id) is manager:
manager.notify_update()
reg.finish(task, state=task_registry.STATE_DONE, result=summary)
+ except asyncio.CancelledError:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ raise
except Exception as exc: # pylint: disable=broad-exception-caught
# Task-level failure: log at WARNING so it is visible in the default HA log
# and the panel Logs view (sub-step failures above stay at debug on purpose).
_LOGGER.warning("Reprocess task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
- lock.release()
+ if acquired:
+ lock.release()
@websocket_api.websocket_command(
@@ -2783,7 +2815,9 @@ def ws_reprocess_history(
return
reg = task_registry.get_registry(hass)
task = reg.create(entry_id, "reprocess", "Reprocessing")
- hass.async_create_task(_reprocess_task(hass, task, entry_id))
+ _raw = hass.async_create_task(_reprocess_task(hass, task, entry_id))
+ if _raw is not None:
+ reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "reprocess_history", {"task_id": task.id})
@@ -3274,9 +3308,11 @@ def ws_trim_cycle(
task = reg.create(
entry_id, "trim", "Trimming cycle", label_key="task.trim.apply", label_params={},
)
- hass.async_create_task(_trim_task(
+ _raw = hass.async_create_task(_trim_task(
hass, task, entry_id, msg["cycle_id"], float(msg["start_s"]), float(msg["end_s"]),
))
+ if _raw is not None:
+ reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "trim_cycle", {"task_id": task.id})
@@ -3293,8 +3329,13 @@ async def _trim_task(
return
store = manager.profile_store
lock = _entry_write_lock(hass, entry_id)
- await lock.acquire()
+ acquired = False
try:
+ await lock.acquire()
+ acquired = True
+ if task.cancel_requested:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ return
reg.update(task, total=1, done=0)
ok = await store.trim_cycle_power_data(cycle_id, start_s, end_s)
reg.update(task, done=1)
@@ -3304,11 +3345,15 @@ async def _trim_task(
if _get_manager(hass, entry_id) is manager:
manager.notify_update()
reg.finish(task, state=task_registry.STATE_DONE, result={"success": True})
+ except asyncio.CancelledError:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.warning("Trim task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
- lock.release()
+ if acquired:
+ lock.release()
@websocket_api.websocket_command(
@@ -3426,7 +3471,9 @@ def ws_apply_split(
task = reg.create(
entry_id, "split", "Splitting cycle", label_key="task.split.apply", label_params={},
)
- hass.async_create_task(_apply_split_task(hass, task, entry_id, cycle_id, segments))
+ _raw = hass.async_create_task(_apply_split_task(hass, task, entry_id, cycle_id, segments))
+ if _raw is not None:
+ reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "apply_split", {"task_id": task.id})
@@ -3444,8 +3491,13 @@ async def _apply_split_task(
return
store = manager.profile_store
lock = _entry_write_lock(hass, entry_id)
- await lock.acquire()
+ acquired = False
try:
+ await lock.acquire()
+ acquired = True
+ if task.cancel_requested:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ return
reg.update(task, total=1, done=0)
new_ids = await store.apply_split_interactive(cycle_id, segments)
reg.update(task, done=1)
@@ -3455,9 +3507,15 @@ async def _apply_split_task(
task, state=task_registry.STATE_DONE,
result={"success": True, "new_ids": new_ids},
)
+ except asyncio.CancelledError:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.warning("Apply-split task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
+ finally:
+ if acquired:
+ lock.release()
@websocket_api.websocket_command(
@@ -3508,7 +3566,9 @@ def ws_apply_merge(
task = reg.create(
entry_id, "merge", "Merging cycles", label_key="task.merge.apply", label_params={},
)
- hass.async_create_task(_apply_merge_task(hass, task, entry_id, ids, target, new_name))
+ _raw = hass.async_create_task(_apply_merge_task(hass, task, entry_id, ids, target, new_name))
+ if _raw is not None:
+ reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "apply_merge", {"task_id": task.id})
@@ -3526,8 +3586,13 @@ async def _apply_merge_task(
return
store = manager.profile_store
lock = _entry_write_lock(hass, entry_id)
- await lock.acquire()
+ acquired = False
try:
+ await lock.acquire()
+ acquired = True
+ if task.cancel_requested:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ return
reg.update(task, total=1, done=0)
created_new = False
if new_name:
@@ -3553,11 +3618,15 @@ async def _apply_merge_task(
task, state=task_registry.STATE_DONE,
result={"success": True, "new_id": new_id},
)
+ except asyncio.CancelledError:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.warning("Apply-merge task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
- lock.release()
+ if acquired:
+ lock.release()
# ─── Profile envelope / member cycles ──────────────────────────────────────────
@@ -4620,17 +4689,23 @@ async def _ml_training_task(hass: HomeAssistant, task: Any, entry_id: str) -> No
# reprocess itself runs ML training): training rewrites the store, so two runs
# for the same entry must not interleave.
lock = _entry_write_lock(hass, entry_id)
- await lock.acquire()
+ acquired = False
try:
+ await lock.acquire()
+ acquired = True
summary = await manager.async_run_ml_training(force=True)
reg.finish(task, state=task_registry.STATE_DONE, result=summary)
+ except asyncio.CancelledError:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ raise
except Exception as exc: # pylint: disable=broad-exception-caught
# Task-level failure: log at WARNING so it surfaces in the default HA log and
# the panel Logs view (not swallowed at debug like a routine sub-step miss).
_LOGGER.warning("ML training task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
- lock.release()
+ if acquired:
+ lock.release()
@websocket_api.websocket_command(
@@ -4656,7 +4731,9 @@ def ws_trigger_ml_training(
return
reg = task_registry.get_registry(hass)
task = reg.create(entry_id, "ml_training", "Learning")
- hass.async_create_task(_ml_training_task(hass, task, entry_id))
+ _raw = hass.async_create_task(_ml_training_task(hass, task, entry_id))
+ if _raw is not None:
+ reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "trigger_ml_training", {"task_id": task.id})
@@ -4838,7 +4915,7 @@ def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig:
opts: dict[str, Any] = {}
if entry is not None:
opts = {**getattr(entry, "data", {}), **getattr(entry, "options", {})}
- min_power = float(opts.get(CONF_MIN_POWER, 5.0) or 5.0)
+ min_power = float(opts.get(CONF_MIN_POWER, DEFAULT_MIN_POWER) or DEFAULT_MIN_POWER)
return CycleDetectorConfig(
min_power=min_power,
off_delay=int(opts.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY)),
@@ -4846,7 +4923,7 @@ def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig:
completion_min_seconds=int(opts.get(CONF_COMPLETION_MIN_SECONDS, 600)),
end_repeat_count=int(opts.get(CONF_END_REPEAT_COUNT, 1)),
min_off_gap=int(opts.get(CONF_MIN_OFF_GAP, 60)),
- running_dead_zone=int(opts.get(CONF_RUNNING_DEAD_ZONE, 0)),
+ running_dead_zone=int(opts.get(CONF_RUNNING_DEAD_ZONE, DEFAULT_RUNNING_DEAD_ZONE)),
start_threshold_w=float(opts.get(CONF_START_THRESHOLD_W, min_power)),
stop_threshold_w=float(
opts.get(CONF_STOP_THRESHOLD_W, min_power * 0.6 if min_power else 2.0)
@@ -5186,6 +5263,9 @@ async def _pg_history_task(
else:
ids = [c.get("id") for c in past[-playground.DEFAULT_RECENT_CYCLES:]]
ids = [i for i in ids[:playground.MAX_BATCH_CYCLES] if i]
+ # Build match snapshots once — they are store-derived and identical for
+ # every chunk; rebuilding per chunk is O(n_profiles) wasted work.
+ prebuilt = await hass.async_add_executor_job(playground._build_match_snapshots, store)
reg.update(task, total=len(ids))
rows: list[dict[str, Any]] = []
base_rows: list[dict[str, Any]] = []
@@ -5195,7 +5275,7 @@ async def _pg_history_task(
chunk = ids[i:i + _PG_HISTORY_CHUNK]
r = await hass.async_add_executor_job(
playground.run_playground_history,
- store, chunk, base_config, override, options, price, len(chunk),
+ store, chunk, base_config, override, options, price, len(chunk), prebuilt,
)
rows.extend(r.get("rows") or [])
base_rows.extend(r.get("baseline_rows") or [])
@@ -5207,6 +5287,9 @@ async def _pg_history_task(
state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
result=payload,
)
+ except asyncio.CancelledError:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Playground history task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
@@ -5228,6 +5311,8 @@ async def _pg_sweep_task(
ids = [c.get("id") for c in past[-playground.DEFAULT_RECENT_CYCLES:] if isinstance(c, dict)]
ids = [i for i in ids[:playground.MAX_BATCH_CYCLES] if i]
n = max(1, len(ids))
+ # Build match snapshots once — identical for every sweep value/cell.
+ prebuilt = await hass.async_add_executor_job(playground._build_match_snapshots, store)
if param_y and values_y:
reg.update(task, total=len(values) * len(values_y))
grid: list[list[float | None]] = [[None] * len(values) for _ in values_y]
@@ -5242,7 +5327,7 @@ async def _pg_sweep_task(
r = await hass.async_add_executor_job(
playground.run_playground_sweep,
store, ids, base_config, param, [vx], objective,
- options, price, n, param_y, [vy],
+ options, price, n, param_y, [vy], prebuilt,
)
cell = (r.get("grid") or [[None]])[0]
grid[j][i] = cell[0] if cell else None
@@ -5263,6 +5348,7 @@ async def _pg_sweep_task(
r = await hass.async_add_executor_job(
playground.run_playground_sweep,
store, ids, base_config, param, [vx], objective, options, price, n,
+ None, None, prebuilt,
)
points.extend(r.get("points") or [])
if r.get("current_value") is not None:
@@ -5275,6 +5361,9 @@ async def _pg_sweep_task(
state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
result=payload,
)
+ except asyncio.CancelledError:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Playground sweep task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
@@ -5304,7 +5393,9 @@ def ws_start_playground_history(
task = reg.create(entry_id, "pg_history", "Test on history")
override = dict(msg.get("settings_override") or {}) or None
cycle_ids = list(msg.get("cycle_ids") or [])
- hass.async_create_task(_pg_history_task(hass, task, entry_id, cycle_ids, override))
+ _raw = hass.async_create_task(_pg_history_task(hass, task, entry_id, cycle_ids, override))
+ if _raw is not None:
+ reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "start_playground_history", {"task_id": task.id})
@@ -5340,10 +5431,12 @@ def ws_start_playground_sweep(
entry_id, "pg_sweep", f"Optimize: {msg['param']}",
label_key="task.pg_sweep.optimize", label_params={"param": msg["param"]},
)
- hass.async_create_task(_pg_sweep_task(
+ _raw = hass.async_create_task(_pg_sweep_task(
hass, task, entry_id, msg["param"], list(msg.get("values") or []),
msg["objective"], param_y, list(values_y) if values_y else None,
))
+ if _raw is not None:
+ reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "start_playground_sweep", {"task_id": task.id})
@@ -5393,6 +5486,9 @@ async def _pg_detail_task(
state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
result=payload,
)
+ except asyncio.CancelledError:
+ reg.finish(task, state=task_registry.STATE_CANCELLED)
+ raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Playground detail task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
@@ -5426,7 +5522,9 @@ def ws_start_playground_cycle_detail(
label_key="task.pg_detail.simulate", label_params={},
)
override = dict(msg.get("settings_override") or {})
- hass.async_create_task(
+ _raw = hass.async_create_task(
_pg_detail_task(hass, task, entry_id, msg["cycle_id"], override)
)
+ if _raw is not None:
+ reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "start_playground_cycle_detail", {"task_id": task.id})
diff --git a/custom_components/ha_washdata/www/ha-washdata-panel.js b/custom_components/ha_washdata/www/ha-washdata-panel.js
index cfbf656..7c5f54d 100644
--- a/custom_components/ha_washdata/www/ha-washdata-panel.js
+++ b/custom_components/ha_washdata/www/ha-washdata-panel.js
@@ -129,10 +129,6 @@ const _SETTINGS_SECTIONS = [
doc: 'Expected time between sensor readings - used to size the smoothing window and start debounce correctly. Every sensor update is captured regardless of this value; it only calibrates the downstream calculations. The suggestion engine measures your sensor\'s actual cadence from past cycles and sets this automatically.' },
{ key: 'smoothing_window', label: 'Smoothing Window', type: 'number', min: 1, def: 2,
doc: 'How much the raw power signal is smoothed. Low (2) is responsive but noisy; high (5) smooths spikes but adds lag.' },
- { key: 'abrupt_drop_watts', label: 'Abrupt Drop', unit: 'W', type: 'number', min: 0, def: 500,
- doc: 'A power drop larger than this flags the cycle as Interrupted (manual cancel) rather than a natural finish.' },
- { key: 'abrupt_drop_ratio', label: 'Abrupt Drop Ratio', type: 'number', step: 0.05, min: 0, max: 1, def: 0.6,
- doc: 'A drop larger than this fraction of current power is also treated as abrupt (0.6 = a 60% drop). Complements the watts threshold across appliance sizes.' },
] },
] },
{ id: 'matching', label: 'Matching', intro: 'How finished cycles are matched to learned profiles and labelled.', notDeviceTypes: ['other'], groups: [
@@ -1375,7 +1371,7 @@ function _field(f, value, extra) {
// Chip/pill multi-picker: existing values as removable pills + a combobox
// add-input. Managed by DOM (no re-render) and collected on save.
const vals = Array.isArray(value) ? value : (value ? [value] : []);
- const pills = vals.map(x => `${_esc(x)}`).join('');
+ const pills = vals.map(x => `${_esc(x)}`).join('');
input = `
${pills}` +
`
` +
`` +
@@ -1500,7 +1496,6 @@ const _DIAGRAM_BY_KEY = {
min_power: 'min_power', off_delay: 'off_delay', smoothing_window: 'smoothing',
start_threshold_w: 'hysteresis', stop_threshold_w: 'hysteresis',
start_energy_threshold: 'start_energy', running_dead_zone: 'dead_zone',
- abrupt_drop_watts: 'abrupt_drop', abrupt_drop_ratio: 'abrupt_drop',
profile_duration_tolerance: 'duration_tolerance',
profile_match_min_duration_ratio: 'match_ratios', profile_match_max_duration_ratio: 'match_ratios',
progress_reset_delay: 'progress_reset', completion_min_seconds: 'min_duration',
@@ -1578,11 +1573,6 @@ function _diagram(id) {
-tol+tolprofile`);
- case 'abrupt_drop':
- return wrap(`${base}
-
-
- abrupt`);
case 'match_ratios':
return wrap(`${base}
@@ -1701,6 +1691,8 @@ class HaWashdataPanel extends HTMLElement {
this._toastTimer = null;
this._hassUpdateThrottle = null;
this._evtUnsubs = [];
+ this._hoverRafId = null; // rAF handle for chart-hover coalescing
+ this._hoverPending = null; // last pending hover coords {px, py, id}
// Data
this._constants = { stateColors: {}, deviceTypes: [], mlLabEnabled: false, mlSuggestionsEnabled: false, mlTrainingAvailable: false, storeOnlineAvailable: false, storeOnlineEnabled: false, storeWebOrigin: '', storePrefs: {}, pgMatchDefaults: {} };
this._constantsLoaded = false;
@@ -1866,7 +1858,22 @@ class HaWashdataPanel extends HTMLElement {
set narrow(n) { this._narrow = n; }
connectedCallback() {
- if (this._initialized) this._startPoll();
+ if (this._initialized) {
+ this._startPoll();
+ // Restore WS push subscriptions (cycle events + task registry) that
+ // disconnectedCallback tore down. Without this, navigate-away/back leaves
+ // the panel relying only on the 30s fallback poll — live cycle transitions
+ // and task progress no longer update, and modal Escape/Tab are dead.
+ this._setupSubscriptions();
+ // Immediately refresh state so a navigate-away/back shows current data
+ // instead of waiting up to 30s for the next poll tick.
+ this._fetchAll();
+ // Re-attach the modal keyboard handler (removed on disconnect).
+ if (this.shadowRoot && !this._kbdHandler) {
+ this._kbdHandler = (e) => this._onKeydown(e);
+ this.shadowRoot.addEventListener('keydown', this._kbdHandler);
+ }
+ }
this._onResize = () => this._resizeLogsPage();
window.addEventListener('resize', this._onResize);
}
@@ -1907,6 +1914,14 @@ class HaWashdataPanel extends HTMLElement {
this._fetchAll();
this._startPoll();
});
+ this._setupSubscriptions();
+ }
+
+ _setupSubscriptions() {
+ // Tear down any existing subscriptions first so reconnect is idempotent.
+ this._evtUnsubs.forEach(u => { try { u(); } catch (_) {} });
+ this._evtUnsubs = [];
+ this._tasksSubscribed = false;
// Subscribe to WashData cycle events for immediate push-refresh.
// These fire when a cycle starts/ends so the UI updates instantly
// instead of waiting for the 30s fallback poll.
@@ -2176,6 +2191,11 @@ class HaWashdataPanel extends HTMLElement {
// Header activity cluster: one pill per running task (device · action · % · ✕).
_htmlTaskPills() {
const running = Object.values(this._tasks || {}).filter(t => t.state === 'running');
+ // Prune stale cancelling-task IDs: if a task is no longer running (evicted from
+ // the registry, finished while our WS subscription was down, etc.), its
+ // "Cancelling…" pill would never clear on its own — clean it up here.
+ const runningIds = new Set(running.map(t => t.id));
+ this._cancellingTasks.forEach(id => { if (!runningIds.has(id)) this._cancellingTasks.delete(id); });
if (!running.length) return '';
return running.map(t => {
const cancelling = this._cancellingTasks.has(t.id);
@@ -2394,6 +2414,13 @@ class HaWashdataPanel extends HTMLElement {
await this._fetchCycles(dev.entry_id);
await this._fetchSuggestions(dev.entry_id);
await this._fetchProfiles(dev.entry_id);
+ // Prime the Setup Card on the very first paint so it appears immediately
+ // without requiring a tab click or device switch.
+ if (this._tab === 'status') {
+ try {
+ this._setupStatus = await this._ws({ type: `${_DOMAIN}/get_setup_status`, entry_id: dev.entry_id });
+ } catch (_) { this._setupStatus = null; }
+ }
// The Store tab's visibility depends on this._onlineEnabled(),
// which is normally loaded per-tab. Prime it at boot ONLY when the backend
// exposes online features, so the tab can appear without visiting Settings.
@@ -2813,6 +2840,7 @@ class HaWashdataPanel extends HTMLElement {
this._profiles = []; this._profileHealth = {}; this._profileTrends = {}; this._coverageGaps = {}; this._profileAdvisories = []; this._opts = {}; this._suggestions = [];
this._cycles = []; this._refCycles = []; this._recState = null; this._diag = null; this._maintenance = null; this._phases = [];
this._mlTrainingStatus = null; // per-device; re-fetched by _fetchTabData
+ this._setupStatus = null; // per-device; re-fetched by _fetchTabData
this._deviceAutomations = []; // per-device; re-fetched on the settings tab
this._selectMode = false; this._cycleSel = new Set();
this._cycleFilter = { text: '', status: '' };
@@ -3445,7 +3473,7 @@ class HaWashdataPanel extends HTMLElement {
`;
- const burger = `