mirror of
https://github.com/home-assistant/core.git
synced 2025-12-02 14:08:12 +00:00
Compare commits
99 Commits
add-app-pa
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2acf30637 | ||
|
|
29631a2c5a | ||
|
|
1d31e6d0ea | ||
|
|
8109d9a39c | ||
|
|
e1abd451b8 | ||
|
|
2c72cd94f2 | ||
|
|
3bccb4b89c | ||
|
|
6d4fb30630 | ||
|
|
c04411f1bc | ||
|
|
753ea023de | ||
|
|
1ca1cf59eb | ||
|
|
5b01bb1a29 | ||
|
|
15c89d24eb | ||
|
|
b26b2347e6 | ||
|
|
7d54103c09 | ||
|
|
c705a1dc4b | ||
|
|
998bd23446 | ||
|
|
3a1a58d6ad | ||
|
|
f9219dd841 | ||
|
|
402ed7e0f3 | ||
|
|
7a1a5df89e | ||
|
|
df558fc1e7 | ||
|
|
ec66407ef1 | ||
|
|
6b99234a43 | ||
|
|
393be71009 | ||
|
|
12bc1687ec | ||
|
|
c59b322c0a | ||
|
|
e00266463d | ||
|
|
cbc8a33553 | ||
|
|
28582f75d4 | ||
|
|
39cccd212d | ||
|
|
329ea33337 | ||
|
|
521733c420 | ||
|
|
33e9f9a0ff | ||
|
|
5fda2bccbe | ||
|
|
ae75332656 | ||
|
|
b171785f96 | ||
|
|
ff3d6783c6 | ||
|
|
b1e579bea0 | ||
|
|
87241ea051 | ||
|
|
a871ec0bdf | ||
|
|
b8829b645a | ||
|
|
5b056a83d4 | ||
|
|
02a70123c1 | ||
|
|
5f6d2f537a | ||
|
|
5e04e9f04d | ||
|
|
56515ad7b5 | ||
|
|
a1fe2bf4fa | ||
|
|
b8fa8efd91 | ||
|
|
03557b5ef2 | ||
|
|
dafec8ce58 | ||
|
|
6ff3f74347 | ||
|
|
ddd8cf7fde | ||
|
|
1356eea52f | ||
|
|
6188e0e39b | ||
|
|
699fa1617d | ||
|
|
449f0fa5a5 | ||
|
|
2e008d2bb7 | ||
|
|
05dec2619d | ||
|
|
25a6778ba8 | ||
|
|
f564b8cb44 | ||
|
|
ce6bfdebfc | ||
|
|
f00a944ac1 | ||
|
|
3073a99ce6 | ||
|
|
8b04ce1328 | ||
|
|
39f76787ab | ||
|
|
e8acced335 | ||
|
|
758a30eebc | ||
|
|
faf94bea24 | ||
|
|
ff91c57228 | ||
|
|
3d2b506997 | ||
|
|
d3c1c28605 | ||
|
|
d4e1f7741d | ||
|
|
e713632eed | ||
|
|
060ad35ddc | ||
|
|
6c5dba40cd | ||
|
|
a04d595424 | ||
|
|
fe85eaf2a2 | ||
|
|
3551c4b01f | ||
|
|
e7edd51a65 | ||
|
|
0c4f2326ef | ||
|
|
81f4456d7c | ||
|
|
2b608bf15c | ||
|
|
972ed4b27f | ||
|
|
23c167da1b | ||
|
|
34d6938171 | ||
|
|
4bb8590076 | ||
|
|
5e0923b60d | ||
|
|
ad48f3c634 | ||
|
|
2bdd6854eb | ||
|
|
0bf906911c | ||
|
|
874d6f5613 | ||
|
|
43ba10eebd | ||
|
|
64bed19805 | ||
|
|
6357067f0f | ||
|
|
e328ba4045 | ||
|
|
332dbddce6 | ||
|
|
82d935a819 | ||
|
|
4b84998c0c |
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@02c6cc30ae592ce65ee356387748dfc2fd5f7993 # v2.0.3
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@02c6cc30ae592ce65ee356387748dfc2fd5f7993 # v2.0.3
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
2
.github/workflows/wheels.yml
vendored
2
.github/workflows/wheels.yml
vendored
@@ -136,7 +136,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: &home-assistant-wheels home-assistant/wheels@6066c17a2a4aafcf7bdfeae01717f63adfcdba98 # 2025.11.0
|
||||
uses: &home-assistant-wheels home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -539,6 +539,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/freebox/ @hacf-fr @Quentame
|
||||
/homeassistant/components/freedompro/ @stefano055415
|
||||
/tests/components/freedompro/ @stefano055415
|
||||
/homeassistant/components/fressnapf_tracker/ @eifinger
|
||||
/tests/components/fressnapf_tracker/ @eifinger
|
||||
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
/homeassistant/components/fritzbox/ @mib1185 @flabbamann
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any, Final
|
||||
from homeassistant.const import (
|
||||
EVENT_COMPONENT_LOADED,
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
EVENT_LABS_UPDATED,
|
||||
EVENT_LOVELACE_UPDATED,
|
||||
EVENT_PANELS_UPDATED,
|
||||
EVENT_RECORDER_5MIN_STATISTICS_GENERATED,
|
||||
@@ -45,6 +46,7 @@ SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = {
|
||||
EVENT_STATE_CHANGED,
|
||||
EVENT_THEMES_UPDATED,
|
||||
EVENT_LABEL_REGISTRY_UPDATED,
|
||||
EVENT_LABS_UPDATED,
|
||||
EVENT_CATEGORY_REGISTRY_UPDATED,
|
||||
EVENT_FLOOR_REGISTRY_UPDATED,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -174,6 +175,56 @@ class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication dialog."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
# Combine existing data with new password
|
||||
data = {
|
||||
CONF_HOST: reauth_entry.data[CONF_HOST],
|
||||
CONF_USERNAME: reauth_entry.data[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
}
|
||||
|
||||
try:
|
||||
await validate_input(self.hass, data)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"username": reauth_entry.data[CONF_USERNAME],
|
||||
"host": reauth_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
@@ -11,6 +11,7 @@ from pyairobotrest.exceptions import AirobotAuthError, AirobotConnectionError
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -53,7 +54,15 @@ class AirobotDataUpdateCoordinator(DataUpdateCoordinator[AirobotData]):
|
||||
try:
|
||||
status = await self.client.get_statuses()
|
||||
settings = await self.client.get_settings()
|
||||
except (AirobotAuthError, AirobotConnectionError) as err:
|
||||
raise UpdateFailed(f"Failed to communicate with device: {err}") from err
|
||||
except AirobotAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_failed",
|
||||
) from err
|
||||
except AirobotConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_failed",
|
||||
) from err
|
||||
|
||||
return AirobotData(status=status, settings=settings)
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyairobotrest"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyairobotrest==0.1.0"]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -14,15 +15,24 @@
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The thermostat password."
|
||||
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "Airobot thermostat {device_id} discovered at {host}. Enter the password to complete setup. Find the password in the thermostat settings menu under Connectivity → Mobile app."
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "The authentication for Airobot thermostat at {host} (Device ID: {username}) has expired. Please enter the password to reauthenticate. Find the password in the thermostat settings menu under Connectivity → Mobile app."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
"username": "Device ID"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Airobot thermostat.",
|
||||
@@ -34,6 +44,12 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed, please reauthenticate."
|
||||
},
|
||||
"connection_failed": {
|
||||
"message": "Failed to communicate with device."
|
||||
},
|
||||
"set_preset_mode_failed": {
|
||||
"message": "Failed to set preset mode to {preset_mode}."
|
||||
},
|
||||
|
||||
@@ -159,81 +159,74 @@
|
||||
"title": "Alarm control panel",
|
||||
"triggers": {
|
||||
"armed": {
|
||||
"description": "Triggers when an alarm is armed.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed::description%]",
|
||||
"description": "Triggers after one or more alarms become armed, regardless of the mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is armed"
|
||||
"name": "Alarm armed"
|
||||
},
|
||||
"armed_away": {
|
||||
"description": "Triggers when an alarm is armed away.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_away::description%]",
|
||||
"description": "Triggers after one or more alarms become armed in away mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is armed away"
|
||||
"name": "Alarm armed away"
|
||||
},
|
||||
"armed_home": {
|
||||
"description": "Triggers when an alarm is armed home.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_home::description%]",
|
||||
"description": "Triggers after one or more alarms become armed in home mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is armed home"
|
||||
"name": "Alarm armed home"
|
||||
},
|
||||
"armed_night": {
|
||||
"description": "Triggers when an alarm is armed night.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_night::description%]",
|
||||
"description": "Triggers after one or more alarms become armed in night mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is armed night"
|
||||
"name": "Alarm armed night"
|
||||
},
|
||||
"armed_vacation": {
|
||||
"description": "Triggers when an alarm is armed vacation.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_vacation::description%]",
|
||||
"description": "Triggers after one or more alarms become armed in vacation mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is armed vacation"
|
||||
"name": "Alarm armed vacation"
|
||||
},
|
||||
"disarmed": {
|
||||
"description": "Triggers when an alarm is disarmed.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::disarmed::description%]",
|
||||
"description": "Triggers after one or more alarms become disarmed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is disarmed"
|
||||
"name": "Alarm disarmed"
|
||||
},
|
||||
"triggered": {
|
||||
"description": "Triggers when an alarm is triggered.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::triggered::description%]",
|
||||
"description": "Triggers after one or more alarms become triggered.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an alarm is triggered"
|
||||
"name": "Alarm triggered"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"data_description": {
|
||||
"account_number": "Your account number found on your latest bill.",
|
||||
"password": "Your password",
|
||||
"username": "Username or email used to login to the Anglian Water website."
|
||||
"username": "Username or email used to log in to the Anglian Water website."
|
||||
},
|
||||
"description": "Enter your Anglian Water account credentials to connect to Home Assistant."
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["zeroconf"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyatv", "srptools"],
|
||||
"requirements": ["pyatv==0.16.1;python_version<'3.14'"],
|
||||
|
||||
@@ -112,48 +112,44 @@
|
||||
"title": "Assist satellite",
|
||||
"triggers": {
|
||||
"idle": {
|
||||
"description": "Triggers when an Assist satellite becomes idle.",
|
||||
"description_configured": "[%key:component::assist_satellite::triggers::idle::description%]",
|
||||
"description": "Triggers after one or more voice assistant satellites become idle after having processed a command.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an Assist satellite becomes idle"
|
||||
"name": "Satellite became idle"
|
||||
},
|
||||
"listening": {
|
||||
"description": "Triggers when an Assist satellite starts listening.",
|
||||
"description_configured": "[%key:component::assist_satellite::triggers::listening::description%]",
|
||||
"description": "Triggers after one or more voice assistant satellites start listening for a command from someone.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an Assist satellite starts listening"
|
||||
"name": "Satellite started listening"
|
||||
},
|
||||
"processing": {
|
||||
"description": "Triggers when an Assist satellite is processing.",
|
||||
"description_configured": "[%key:component::assist_satellite::triggers::processing::description%]",
|
||||
"description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an Assist satellite is processing"
|
||||
"name": "Satellite started processing"
|
||||
},
|
||||
"responding": {
|
||||
"description": "Triggers when an Assist satellite is responding.",
|
||||
"description_configured": "[%key:component::assist_satellite::triggers::responding::description%]",
|
||||
"description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When an Assist satellite is responding"
|
||||
"name": "Satellite started responding"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +69,10 @@
|
||||
},
|
||||
"preview_features": {
|
||||
"new_triggers_conditions": {
|
||||
"description": "Enables new intuitive triggers and conditions that are more user-friendly than technical state-based options.\n\nThese new automation features support targets across your entire home, letting you trigger automations for any entity, device, area, floor, or label (for example, when any light in your living room turned on). Integrations can now also provide their own intuitive triggers and conditions, just like actions.\n\nThis preview also includes a new tree view to help you navigate your home when adding triggers, conditions, and actions.",
|
||||
"disable_confirmation": "Disabling this preview will cause automations and scripts that use the new intuitive triggers and conditions to fail.\n\nBefore disabling, ensure that your automations or scripts do not rely on this feature.",
|
||||
"enable_confirmation": "This feature is still in development and may change. These new intuitive triggers and conditions are being refined based on user feedback and are not yet complete.\n\nBy enabling this preview, you'll have early access to these new capabilities, but be aware that they may be modified or updated in future releases.",
|
||||
"name": "Intuitive triggers and conditions"
|
||||
"description": "Enables new purpose-specific triggers and conditions that are more user-friendly than technical state-based options.\n\nThese new automation features support targets across your entire home, letting you trigger automations for any entity, device, area, floor, or label (for example, when any light in your living room turned on). Integrations can now also provide their own purpose-specific triggers and conditions, just like actions.\n\nThis preview also includes a new tree view to help you navigate your home when adding triggers, conditions, and actions.",
|
||||
"disable_confirmation": "Disabling this preview will cause automations and scripts that use the new purpose-specific triggers and conditions to fail.\n\nBefore disabling, ensure that your automations or scripts do not rely on this feature.",
|
||||
"enable_confirmation": "This feature is still in development and may change. These new purpose-specific triggers and conditions are being refined based on user feedback and are not yet complete.\n\nBy enabling this preview, you'll have early access to these new capabilities, but be aware that they may be modified or updated in future releases.",
|
||||
"name": "Purpose-specific triggers and conditions"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -68,9 +68,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
|
||||
config_entry_id=entry.entry_id,
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
|
||||
identifiers={(DOMAIN, entry.unique_id or entry.entry_id)},
|
||||
name=f"Bosch {panel.model}",
|
||||
name=f"Bosch {panel.model.name}",
|
||||
manufacturer="Bosch Security Systems",
|
||||
model=panel.model,
|
||||
model=panel.model.name,
|
||||
sw_version=panel.firmware_version,
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -83,7 +83,7 @@ async def try_connect(
|
||||
finally:
|
||||
await panel.disconnect()
|
||||
|
||||
return (panel.model, panel.serial_number)
|
||||
return (panel.model.name, panel.serial_number)
|
||||
|
||||
|
||||
class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
@@ -20,7 +20,8 @@ async def async_get_config_entry_diagnostics(
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": {
|
||||
"model": entry.runtime_data.model,
|
||||
"model": entry.runtime_data.model.name,
|
||||
"family": entry.runtime_data.model.family.name,
|
||||
"serial_number": entry.runtime_data.serial_number,
|
||||
"protocol_version": entry.runtime_data.protocol_version,
|
||||
"firmware_version": entry.runtime_data.firmware_version,
|
||||
|
||||
@@ -26,7 +26,7 @@ class BoschAlarmEntity(Entity):
|
||||
self._attr_should_poll = False
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=f"Bosch {panel.model}",
|
||||
name=f"Bosch {panel.model.name}",
|
||||
manufacturer="Bosch Security Systems",
|
||||
)
|
||||
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["bosch-alarm-mode2==0.4.6"]
|
||||
"requirements": ["bosch-alarm-mode2==0.4.10"]
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/broadlink",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["broadlink"],
|
||||
"requirements": ["broadlink==0.19.0"]
|
||||
|
||||
@@ -299,59 +299,54 @@
|
||||
"title": "Climate",
|
||||
"triggers": {
|
||||
"started_cooling": {
|
||||
"description": "Triggers when a climate started cooling.",
|
||||
"description_configured": "[%key:component::climate::triggers::started_cooling::description%]",
|
||||
"description": "Triggers after one or more climate-control devices start cooling.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a climate started cooling"
|
||||
"name": "Climate-control device started cooling"
|
||||
},
|
||||
"started_drying": {
|
||||
"description": "Triggers when a climate started drying.",
|
||||
"description_configured": "[%key:component::climate::triggers::started_drying::description%]",
|
||||
"description": "Triggers after one or more climate-control devices start drying.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a climate started drying"
|
||||
"name": "Climate-control device started drying"
|
||||
},
|
||||
"started_heating": {
|
||||
"description": "Triggers when a climate starts to heat.",
|
||||
"description_configured": "[%key:component::climate::triggers::started_heating::description%]",
|
||||
"description": "Triggers after one or more climate-control devices start heating.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a climate starts to heat"
|
||||
"name": "Climate-control device started heating"
|
||||
},
|
||||
"turned_off": {
|
||||
"description": "Triggers when a climate is turned off.",
|
||||
"description_configured": "[%key:component::climate::triggers::turned_off::description%]",
|
||||
"description": "Triggers after one or more climate-control devices turn off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a climate is turned off"
|
||||
"name": "Climate-control device turned off"
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers when a climate is turned on.",
|
||||
"description_configured": "[%key:component::climate::triggers::turned_on::description%]",
|
||||
"description": "Triggers after one or more climate-control devices turn on, regardless of the mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a climate is turned on"
|
||||
"name": "Climate-control device turned on"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,34 +108,5 @@
|
||||
"toggle_cover_tilt": {
|
||||
"service": "mdi:arrow-top-right-bottom-left"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"awning_opened": {
|
||||
"trigger": "mdi:awning-outline"
|
||||
},
|
||||
"blind_opened": {
|
||||
"trigger": "mdi:blinds-horizontal"
|
||||
},
|
||||
"curtain_opened": {
|
||||
"trigger": "mdi:curtains"
|
||||
},
|
||||
"door_opened": {
|
||||
"trigger": "mdi:door-open"
|
||||
},
|
||||
"garage_opened": {
|
||||
"trigger": "mdi:garage-open"
|
||||
},
|
||||
"gate_opened": {
|
||||
"trigger": "mdi:gate-open"
|
||||
},
|
||||
"shade_opened": {
|
||||
"trigger": "mdi:roller-shade"
|
||||
},
|
||||
"shutter_opened": {
|
||||
"trigger": "mdi:window-shutter-open"
|
||||
},
|
||||
"window_opened": {
|
||||
"trigger": "mdi:window-open"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description_awning": "The behavior of the targeted awnings to trigger on.",
|
||||
"trigger_behavior_description_blind": "The behavior of the targeted blinds to trigger on.",
|
||||
"trigger_behavior_description_curtain": "The behavior of the targeted curtains to trigger on.",
|
||||
"trigger_behavior_description_door": "The behavior of the targeted doors to trigger on.",
|
||||
"trigger_behavior_description_garage": "The behavior of the targeted garage doors to trigger on.",
|
||||
"trigger_behavior_description_gate": "The behavior of the targeted gates to trigger on.",
|
||||
"trigger_behavior_description_shade": "The behavior of the targeted shades to trigger on.",
|
||||
"trigger_behavior_description_shutter": "The behavior of the targeted shutters to trigger on.",
|
||||
"trigger_behavior_description_window": "The behavior of the targeted windows to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"close": "Close {entity_name}",
|
||||
@@ -94,15 +82,6 @@
|
||||
"name": "Window"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"close_cover": {
|
||||
"description": "Closes a cover.",
|
||||
@@ -157,142 +136,5 @@
|
||||
"name": "Toggle tilt"
|
||||
}
|
||||
},
|
||||
"title": "Cover",
|
||||
"triggers": {
|
||||
"awning_opened": {
|
||||
"description": "Triggers when an awning opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::awning_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_awning%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the awnings to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When an awning opens"
|
||||
},
|
||||
"blind_opened": {
|
||||
"description": "Triggers when a blind opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::blind_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_blind%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the blinds to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a blind opens"
|
||||
},
|
||||
"curtain_opened": {
|
||||
"description": "Triggers when a curtain opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::curtain_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_curtain%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the curtains to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a curtain opens"
|
||||
},
|
||||
"door_opened": {
|
||||
"description": "Triggers when a door opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::door_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_door%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the doors to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a door opens"
|
||||
},
|
||||
"garage_opened": {
|
||||
"description": "Triggers when a garage door opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::garage_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_garage%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the garage doors to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a garage door opens"
|
||||
},
|
||||
"gate_opened": {
|
||||
"description": "Triggers when a gate opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::gate_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_gate%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the gates to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a gate opens"
|
||||
},
|
||||
"shade_opened": {
|
||||
"description": "Triggers when a shade opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::shade_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_shade%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the shades to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a shade opens"
|
||||
},
|
||||
"shutter_opened": {
|
||||
"description": "Triggers when a shutter opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::shutter_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_shutter%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the shutters to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a shutter opens"
|
||||
},
|
||||
"window_opened": {
|
||||
"description": "Triggers when a window opens.",
|
||||
"description_configured": "[%key:component::cover::triggers::window_opened::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description_window%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
},
|
||||
"fully_opened": {
|
||||
"description": "Require the windows to be fully opened before triggering.",
|
||||
"name": "Fully opened"
|
||||
}
|
||||
},
|
||||
"name": "When a window opens"
|
||||
}
|
||||
}
|
||||
"title": "Cover"
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Provides triggers for covers."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_OPTIONS
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
TriggerConfig,
|
||||
)
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from . import ATTR_CURRENT_POSITION, CoverDeviceClass, CoverState
|
||||
from .const import DOMAIN
|
||||
|
||||
ATTR_FULLY_OPENED: Final = "fully_opened"
|
||||
|
||||
COVER_OPENED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(ATTR_FULLY_OPENED, default=False): bool,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_device_class_or_undefined(
|
||||
hass: HomeAssistant, entity_id: str
|
||||
) -> str | None | UndefinedType:
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
try:
|
||||
return get_device_class(hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
class CoverOpenedClosedTrigger(EntityTriggerBase):
|
||||
"""Class for cover opened and closed triggers."""
|
||||
|
||||
_attribute: str = ATTR_CURRENT_POSITION
|
||||
_attribute_value: int | None = None
|
||||
_device_class: CoverDeviceClass | None
|
||||
_domain: str = DOMAIN
|
||||
_to_states: set[str]
|
||||
|
||||
def is_to_state(self, state: State) -> bool:
|
||||
"""Check if the state matches the target state."""
|
||||
if state.state not in self._to_states:
|
||||
return False
|
||||
if (
|
||||
self._attribute_value is not None
|
||||
and (value := state.attributes.get(self._attribute)) is not None
|
||||
and value != self._attribute_value
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if get_device_class_or_undefined(self._hass, entity_id)
|
||||
== self._device_class
|
||||
}
|
||||
|
||||
|
||||
class CoverOpenedTrigger(CoverOpenedClosedTrigger):
|
||||
"""Class for cover opened triggers."""
|
||||
|
||||
_schema = COVER_OPENED_TRIGGER_SCHEMA
|
||||
_to_states = {CoverState.OPEN, CoverState.OPENING}
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the state trigger."""
|
||||
super().__init__(hass, config)
|
||||
if self._options.get(ATTR_FULLY_OPENED):
|
||||
self._attribute_value = 100
|
||||
|
||||
|
||||
def make_cover_opened_trigger(
|
||||
device_class: CoverDeviceClass | None,
|
||||
) -> type[CoverOpenedTrigger]:
|
||||
"""Create an entity state attribute trigger class."""
|
||||
|
||||
class CustomTrigger(CoverOpenedTrigger):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_device_class = device_class
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"awning_opened": make_cover_opened_trigger(CoverDeviceClass.AWNING),
|
||||
"blind_opened": make_cover_opened_trigger(CoverDeviceClass.BLIND),
|
||||
"curtain_opened": make_cover_opened_trigger(CoverDeviceClass.CURTAIN),
|
||||
"door_opened": make_cover_opened_trigger(CoverDeviceClass.DOOR),
|
||||
"garage_opened": make_cover_opened_trigger(CoverDeviceClass.GARAGE),
|
||||
"gate_opened": make_cover_opened_trigger(CoverDeviceClass.GATE),
|
||||
"shade_opened": make_cover_opened_trigger(CoverDeviceClass.SHADE),
|
||||
"shutter_opened": make_cover_opened_trigger(CoverDeviceClass.SHUTTER),
|
||||
"window_opened": make_cover_opened_trigger(CoverDeviceClass.WINDOW),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for covers."""
|
||||
return TRIGGERS
|
||||
@@ -1,79 +0,0 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
fully_opened:
|
||||
required: true
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
awning_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: awning
|
||||
|
||||
blind_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: blind
|
||||
|
||||
curtain_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: curtain
|
||||
|
||||
door_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: door
|
||||
|
||||
garage_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: garage
|
||||
|
||||
gate_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: gate
|
||||
|
||||
shade_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: shade
|
||||
|
||||
shutter_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: shutter
|
||||
|
||||
window_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: cover
|
||||
device_class: window
|
||||
@@ -6,6 +6,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client"],
|
||||
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["async-upnp-client==0.46.0"],
|
||||
"ssdp": [
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aioecowitt==2025.9.2"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==42.8.0",
|
||||
"aioesphomeapi==42.10.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
||||
@@ -166,26 +166,24 @@
|
||||
"title": "Fan",
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"description": "Triggers when a fan is turned off.",
|
||||
"description_configured": "[%key:component::fan::triggers::turned_off::description%]",
|
||||
"description": "Triggers after one or more fans turn off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::fan::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::fan::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a fan is turned off"
|
||||
"name": "Fan turned off"
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers when a fan is turned on.",
|
||||
"description_configured": "[%key:component::fan::triggers::turned_on::description%]",
|
||||
"description": "Triggers after one or more fans turn on.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::fan::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::fan::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a fan is turned on"
|
||||
"name": "Fan turned on"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
homeassistant/components/fressnapf_tracker/__init__.py
Normal file
49
homeassistant/components/fressnapf_tracker/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""The Fressnapf Tracker integration."""
|
||||
|
||||
from fressnapftracker import AuthClient
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import CONF_USER_ID
|
||||
from .coordinator import (
|
||||
FressnapfTrackerConfigEntry,
|
||||
FressnapfTrackerDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Fressnapf Tracker from a config entry."""
|
||||
auth_client = AuthClient(client=get_async_client(hass))
|
||||
devices = await auth_client.get_devices(
|
||||
user_id=entry.data[CONF_USER_ID],
|
||||
user_access_token=entry.data[CONF_ACCESS_TOKEN],
|
||||
)
|
||||
|
||||
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
|
||||
for device in devices:
|
||||
coordinator = FressnapfTrackerDataUpdateCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
device,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
coordinators.append(coordinator)
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
193
homeassistant/components/fressnapf_tracker/config_flow.py
Normal file
193
homeassistant/components/fressnapf_tracker/config_flow.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Config flow for the Fressnapf Tracker integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fressnapftracker import (
|
||||
AuthClient,
|
||||
FressnapfTrackerInvalidPhoneNumberError,
|
||||
FressnapfTrackerInvalidTokenError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import CONF_PHONE_NUMBER, CONF_SMS_CODE, CONF_USER_ID, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PHONE_NUMBER): str,
|
||||
}
|
||||
)
|
||||
STEP_SMS_CODE_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SMS_CODE): int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Fressnapf Tracker."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Init Config Flow."""
|
||||
self._context: dict[str, Any] = {}
|
||||
self._auth_client: AuthClient | None = None
|
||||
|
||||
@property
|
||||
def auth_client(self) -> AuthClient:
|
||||
"""Return the auth client, creating it if needed."""
|
||||
if self._auth_client is None:
|
||||
self._auth_client = AuthClient(client=get_async_client(self.hass))
|
||||
return self._auth_client
|
||||
|
||||
async def _async_request_sms_code(
|
||||
self, phone_number: str
|
||||
) -> tuple[dict[str, str], bool]:
|
||||
"""Request SMS code and return errors dict and success flag."""
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
response = await self.auth_client.request_sms_code(
|
||||
phone_number=phone_number
|
||||
)
|
||||
except FressnapfTrackerInvalidPhoneNumberError:
|
||||
errors["base"] = "invalid_phone_number"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
_LOGGER.debug("SMS code request response: %s", response)
|
||||
self._context[CONF_USER_ID] = response.id
|
||||
self._context[CONF_PHONE_NUMBER] = phone_number
|
||||
return errors, True
|
||||
return errors, False
|
||||
|
||||
async def _async_verify_sms_code(
|
||||
self, sms_code: int
|
||||
) -> tuple[dict[str, str], str | None]:
|
||||
"""Verify SMS code and return errors and access_token."""
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
verification_response = await self.auth_client.verify_phone_number(
|
||||
user_id=self._context[CONF_USER_ID],
|
||||
sms_code=sms_code,
|
||||
)
|
||||
except FressnapfTrackerInvalidTokenError:
|
||||
errors["base"] = "invalid_sms_code"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception during SMS code verification")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Phone number verification response: %s", verification_response
|
||||
)
|
||||
return errors, verification_response.user_token.access_token
|
||||
return errors, None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{CONF_PHONE_NUMBER: user_input[CONF_PHONE_NUMBER]}
|
||||
)
|
||||
errors, success = await self._async_request_sms_code(
|
||||
user_input[CONF_PHONE_NUMBER]
|
||||
)
|
||||
if success:
|
||||
await self.async_set_unique_id(str(self._context[CONF_USER_ID]))
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_sms_code()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_sms_code(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the SMS code step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
errors, access_token = await self._async_verify_sms_code(
|
||||
user_input[CONF_SMS_CODE]
|
||||
)
|
||||
if access_token:
|
||||
return self.async_create_entry(
|
||||
title=self._context[CONF_PHONE_NUMBER],
|
||||
data={
|
||||
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
|
||||
CONF_USER_ID: self._context[CONF_USER_ID],
|
||||
CONF_ACCESS_TOKEN: access_token,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="sms_code",
|
||||
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
errors, success = await self._async_request_sms_code(
|
||||
user_input[CONF_PHONE_NUMBER]
|
||||
)
|
||||
if success:
|
||||
if reconfigure_entry.data[CONF_USER_ID] != self._context[CONF_USER_ID]:
|
||||
errors["base"] = "account_change_not_allowed"
|
||||
else:
|
||||
return await self.async_step_reconfigure_sms_code()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_PHONE_NUMBER,
|
||||
default=reconfigure_entry.data.get(CONF_PHONE_NUMBER),
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure_sms_code(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the SMS code step during reconfiguration."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
errors, access_token = await self._async_verify_sms_code(
|
||||
user_input[CONF_SMS_CODE]
|
||||
)
|
||||
if access_token:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data={
|
||||
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
|
||||
CONF_USER_ID: self._context[CONF_USER_ID],
|
||||
CONF_ACCESS_TOKEN: access_token,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure_sms_code",
|
||||
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
6
homeassistant/components/fressnapf_tracker/const.py
Normal file
6
homeassistant/components/fressnapf_tracker/const.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Constants for the Fressnapf Tracker integration."""
|
||||
|
||||
DOMAIN = "fressnapf_tracker"
|
||||
CONF_PHONE_NUMBER = "phone_number"
|
||||
CONF_SMS_CODE = "sms_code"
|
||||
CONF_USER_ID = "user_id"
|
||||
50
homeassistant/components/fressnapf_tracker/coordinator.py
Normal file
50
homeassistant/components/fressnapf_tracker/coordinator.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Data update coordinator for Fressnapf Tracker integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from fressnapftracker import ApiClient, Device, FressnapfTrackerError, Tracker
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type FressnapfTrackerConfigEntry = ConfigEntry[
|
||||
list[FressnapfTrackerDataUpdateCoordinator]
|
||||
]
|
||||
|
||||
|
||||
class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
|
||||
"""Class to manage fetching data from the API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: FressnapfTrackerConfigEntry,
|
||||
device: Device,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=15),
|
||||
config_entry=config_entry,
|
||||
)
|
||||
self.device = device
|
||||
self.client = ApiClient(
|
||||
serial_number=device.serialnumber,
|
||||
device_token=device.token,
|
||||
client=get_async_client(hass),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> Tracker:
|
||||
try:
|
||||
return await self.client.get_tracker()
|
||||
except FressnapfTrackerError as exception:
|
||||
raise UpdateFailed(exception) from exception
|
||||
69
homeassistant/components/fressnapf_tracker/device_tracker.py
Normal file
69
homeassistant/components/fressnapf_tracker/device_tracker.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Device tracker platform for fressnapf_tracker."""
|
||||
|
||||
from homeassistant.components.device_tracker import SourceType
|
||||
from homeassistant.components.device_tracker.config_entry import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FressnapfTrackerConfigEntry, FressnapfTrackerDataUpdateCoordinator
|
||||
from .entity import FressnapfTrackerBaseEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FressnapfTrackerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the fressnapf_tracker device_trackers."""
|
||||
async_add_entities(
|
||||
FressnapfTrackerDeviceTracker(coordinator) for coordinator in entry.runtime_data
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerDeviceTracker(FressnapfTrackerBaseEntity, TrackerEntity):
|
||||
"""fressnapf_tracker device tracker."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_translation_key = "pet"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FressnapfTrackerDataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the device tracker."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = coordinator.device.serialnumber
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.coordinator.data.position is not None
|
||||
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return latitude value of the device."""
|
||||
if self.coordinator.data.position is not None:
|
||||
return self.coordinator.data.position.lat
|
||||
return None
|
||||
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return longitude value of the device."""
|
||||
if self.coordinator.data.position is not None:
|
||||
return self.coordinator.data.position.lng
|
||||
return None
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the source type, eg gps or router, of the device."""
|
||||
return SourceType.GPS
|
||||
|
||||
@property
|
||||
def location_accuracy(self) -> float:
|
||||
"""Return the location accuracy of the device.
|
||||
|
||||
Value in meters.
|
||||
"""
|
||||
if self.coordinator.data.position is not None:
|
||||
return float(self.coordinator.data.position.accuracy)
|
||||
return 0
|
||||
27
homeassistant/components/fressnapf_tracker/entity.py
Normal file
27
homeassistant/components/fressnapf_tracker/entity.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""fressnapf_tracker class."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import FressnapfTrackerDataUpdateCoordinator
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class FressnapfTrackerBaseEntity(
|
||||
CoordinatorEntity[FressnapfTrackerDataUpdateCoordinator]
|
||||
):
|
||||
"""Base entity for Fressnapf Tracker."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: FressnapfTrackerDataUpdateCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self.id = coordinator.device.serialnumber
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(self.id))},
|
||||
name=str(self.coordinator.data.name),
|
||||
model=str(self.coordinator.data.tracker_settings.generation),
|
||||
manufacturer="Fressnapf",
|
||||
serial_number=str(self.id),
|
||||
)
|
||||
9
homeassistant/components/fressnapf_tracker/icons.json
Normal file
9
homeassistant/components/fressnapf_tracker/icons.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"device_tracker": {
|
||||
"pet": {
|
||||
"default": "mdi:paw"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
homeassistant/components/fressnapf_tracker/manifest.json
Normal file
11
homeassistant/components/fressnapf_tracker/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "fressnapf_tracker",
|
||||
"name": "Fressnapf Tracker",
|
||||
"codeowners": ["@eifinger"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fressnapf_tracker",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fressnapftracker==0.1.2"]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: No entities to translate
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
49
homeassistant/components/fressnapf_tracker/strings.json
Normal file
49
homeassistant/components/fressnapf_tracker/strings.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"account_change_not_allowed": "Reconfiguring to a different account is not allowed. Please create a new entry instead.",
|
||||
"invalid_phone_number": "Please enter a valid phone number.",
|
||||
"invalid_sms_code": "The SMS code you entered is invalid.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data::phone_number%]"
|
||||
},
|
||||
"data_description": {
|
||||
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data_description::phone_number%]"
|
||||
},
|
||||
"description": "Re-authenticate with your Fressnapf Tracker account to refresh your credentials."
|
||||
},
|
||||
"reconfigure_sms_code": {
|
||||
"data": {
|
||||
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data::sms_code%]"
|
||||
},
|
||||
"data_description": {
|
||||
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data_description::sms_code%]"
|
||||
}
|
||||
},
|
||||
"sms_code": {
|
||||
"data": {
|
||||
"sms_code": "SMS code"
|
||||
},
|
||||
"data_description": {
|
||||
"sms_code": "Enter the SMS code you received on your phone."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"phone_number": "Phone number"
|
||||
},
|
||||
"data_description": {
|
||||
"phone_number": "Enter your phone number in international format (e.g., +4917612345678)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251127.0"]
|
||||
"requirements": ["home-assistant-frontend==20251201.0"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
@@ -15,7 +16,9 @@ from homeassistant.helpers import singleton
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage")
|
||||
DATA_STORAGE: HassKey[dict[str, asyncio.Future[UserStore]]] = HassKey(
|
||||
"frontend_storage"
|
||||
)
|
||||
DATA_SYSTEM_STORAGE: HassKey[SystemStore] = HassKey("frontend_system_storage")
|
||||
STORAGE_VERSION_USER_DATA = 1
|
||||
STORAGE_VERSION_SYSTEM_DATA = 1
|
||||
@@ -34,11 +37,18 @@ async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
|
||||
async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
|
||||
"""Access a user store."""
|
||||
stores = hass.data.setdefault(DATA_STORAGE, {})
|
||||
if (store := stores.get(user_id)) is None:
|
||||
store = stores[user_id] = UserStore(hass, user_id)
|
||||
await store.async_load()
|
||||
if (future := stores.get(user_id)) is None:
|
||||
future = stores[user_id] = hass.loop.create_future()
|
||||
store = UserStore(hass, user_id)
|
||||
try:
|
||||
await store.async_load()
|
||||
except BaseException as ex:
|
||||
del stores[user_id]
|
||||
future.set_exception(ex)
|
||||
raise
|
||||
future.set_result(store)
|
||||
|
||||
return store
|
||||
return await future
|
||||
|
||||
|
||||
class UserStore:
|
||||
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
from secrets import token_hex
|
||||
import shutil
|
||||
from tempfile import mkdtemp
|
||||
|
||||
from aiohttp import BasicAuth, ClientSession, UnixConnector
|
||||
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
|
||||
@@ -62,11 +63,11 @@ from .const import (
|
||||
CONF_DEBUG_UI,
|
||||
DEBUG_UI_URL_MESSAGE,
|
||||
DOMAIN,
|
||||
HA_MANAGED_UNIX_SOCKET,
|
||||
HA_MANAGED_URL,
|
||||
RECOMMENDED_VERSION,
|
||||
)
|
||||
from .server import Server
|
||||
from .util import get_go2rtc_unix_socket_path
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -154,10 +155,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
auth = BasicAuth(username, password)
|
||||
# HA will manage the binary
|
||||
temp_dir = mkdtemp(prefix="go2rtc-")
|
||||
# Manually created session (not using the helper) needs to be closed manually
|
||||
# See on_stop listener below
|
||||
session = ClientSession(
|
||||
connector=UnixConnector(path=HA_MANAGED_UNIX_SOCKET), auth=auth
|
||||
connector=UnixConnector(path=get_go2rtc_unix_socket_path(temp_dir)),
|
||||
auth=auth,
|
||||
)
|
||||
server = Server(
|
||||
hass,
|
||||
@@ -166,6 +169,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
enable_ui=domain_config.get(CONF_DEBUG_UI, False),
|
||||
username=username,
|
||||
password=password,
|
||||
working_dir=temp_dir,
|
||||
)
|
||||
try:
|
||||
await server.start()
|
||||
|
||||
@@ -6,7 +6,6 @@ CONF_DEBUG_UI = "debug_ui"
|
||||
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
|
||||
HA_MANAGED_API_PORT = 11984
|
||||
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
||||
HA_MANAGED_UNIX_SOCKET = "/run/go2rtc.sock"
|
||||
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
|
||||
# in script/hassfest/docker.py.
|
||||
RECOMMENDED_VERSION = "1.9.12"
|
||||
|
||||
@@ -12,13 +12,13 @@ from go2rtc_client import Go2RtcRestClient
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import HA_MANAGED_API_PORT, HA_MANAGED_UNIX_SOCKET, HA_MANAGED_URL
|
||||
from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL
|
||||
from .util import get_go2rtc_unix_socket_path
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_TERMINATE_TIMEOUT = 5
|
||||
_SETUP_TIMEOUT = 30
|
||||
_SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr="
|
||||
_LOCALHOST_IP = "127.0.0.1"
|
||||
_LOG_BUFFER_SIZE = 512
|
||||
_RESPAWN_COOLDOWN = 1
|
||||
|
||||
@@ -122,7 +122,9 @@ def _format_list_for_yaml(items: tuple[str, ...]) -> str:
|
||||
return f"[{formatted_items}]"
|
||||
|
||||
|
||||
def _create_temp_file(enable_ui: bool, username: str, password: str) -> str:
|
||||
def _create_temp_file(
|
||||
enable_ui: bool, username: str, password: str, working_dir: str
|
||||
) -> str:
|
||||
"""Create temporary config file."""
|
||||
app_modules: tuple[str, ...] = _APP_MODULES
|
||||
api_paths: tuple[str, ...] = _API_ALLOW_PATHS
|
||||
@@ -139,11 +141,13 @@ def _create_temp_file(enable_ui: bool, username: str, password: str) -> str:
|
||||
|
||||
# Set delete=False to prevent the file from being deleted when the file is closed
|
||||
# Linux is clearing tmp folder on reboot, so no need to delete it manually
|
||||
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
|
||||
with NamedTemporaryFile(
|
||||
prefix="go2rtc_", suffix=".yaml", dir=working_dir, delete=False
|
||||
) as file:
|
||||
file.write(
|
||||
_GO2RTC_CONFIG_FORMAT.format(
|
||||
listen_config=listen_config,
|
||||
unix_socket=HA_MANAGED_UNIX_SOCKET,
|
||||
unix_socket=get_go2rtc_unix_socket_path(working_dir),
|
||||
app_modules=_format_list_for_yaml(app_modules),
|
||||
api_allow_paths=_format_list_for_yaml(api_paths),
|
||||
username=username,
|
||||
@@ -165,6 +169,7 @@ class Server:
|
||||
enable_ui: bool = False,
|
||||
username: str,
|
||||
password: str,
|
||||
working_dir: str,
|
||||
) -> None:
|
||||
"""Initialize the server."""
|
||||
self._hass = hass
|
||||
@@ -173,6 +178,7 @@ class Server:
|
||||
self._enable_ui = enable_ui
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._working_dir = working_dir
|
||||
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._startup_complete = asyncio.Event()
|
||||
@@ -190,7 +196,11 @@ class Server:
|
||||
"""Start the server."""
|
||||
_LOGGER.debug("Starting go2rtc server")
|
||||
config_file = await self._hass.async_add_executor_job(
|
||||
_create_temp_file, self._enable_ui, self._username, self._password
|
||||
_create_temp_file,
|
||||
self._enable_ui,
|
||||
self._username,
|
||||
self._password,
|
||||
self._working_dir,
|
||||
)
|
||||
|
||||
self._startup_complete.clear()
|
||||
|
||||
12
homeassistant/components/go2rtc/util.py
Normal file
12
homeassistant/components/go2rtc/util.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Go2rtc utility functions."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_HA_MANAGED_UNIX_SOCKET_FILE = "go2rtc.sock"
|
||||
|
||||
|
||||
def get_go2rtc_unix_socket_path(path: str | Path) -> str:
|
||||
"""Get the Go2rtc unix socket path."""
|
||||
if not isinstance(path, Path):
|
||||
path = Path(path)
|
||||
return str(path / _HA_MANAGED_UNIX_SOCKET_FILE)
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/google",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google_air_quality_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["google_air_quality_api==1.1.2"]
|
||||
"requirements": ["google_air_quality_api==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_translate",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["gtts"],
|
||||
"requirements": ["gTTS==2.5.3"]
|
||||
|
||||
@@ -17,7 +17,7 @@ from aiohasupervisor.models import GreenOptions, YellowOptions # noqa: F401
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.components import frontend, panel_custom
|
||||
from homeassistant.components import panel_custom
|
||||
from homeassistant.components.homeassistant import async_set_stop_handler
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
@@ -329,7 +329,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
return False
|
||||
|
||||
async_load_websocket_api(hass)
|
||||
frontend.async_register_built_in_panel(hass, "app")
|
||||
|
||||
host = os.environ["SUPERVISOR"]
|
||||
websession = async_get_clientsession(hass)
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.23.1"],
|
||||
"requirements": ["aiohomeconnect==0.24.0"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -33,13 +33,14 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .const import OTBR_DOMAIN, Z2M_EMBER_DOCS_URL, ZHA_DOMAIN
|
||||
from .const import DOMAIN, OTBR_DOMAIN, Z2M_EMBER_DOCS_URL, ZHA_DOMAIN
|
||||
from .util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
OwningAddon,
|
||||
OwningIntegration,
|
||||
ResetTarget,
|
||||
async_firmware_flashing_context,
|
||||
async_flash_silabs_firmware,
|
||||
get_otbr_addon_manager,
|
||||
guess_firmware_info,
|
||||
@@ -228,83 +229,95 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
# Keep track of the firmware we're working with, for error messages
|
||||
self.installing_firmware_name = firmware_name
|
||||
|
||||
# Installing new firmware is only truly required if the wrong type is
|
||||
# installed: upgrading to the latest release of the current firmware type
|
||||
# isn't strictly necessary for functionality.
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||
self._device,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
)
|
||||
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
||||
)
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = FirmwareUpdateClient(fw_update_url, session)
|
||||
|
||||
try:
|
||||
manifest = await client.async_update_data()
|
||||
fw_manifest = next(
|
||||
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
|
||||
# For the duration of firmware flashing, hint to other integrations (i.e. ZHA)
|
||||
# that the hardware is in use and should not be accessed. This is separate from
|
||||
# locking the serial port itself, since a momentary release of the port may
|
||||
# still allow for ZHA to reclaim the device.
|
||||
async with async_firmware_flashing_context(self.hass, self._device, DOMAIN):
|
||||
# Installing new firmware is only truly required if the wrong type is
|
||||
# installed: upgrading to the latest release of the current firmware type
|
||||
# isn't strictly necessary for functionality.
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||
self._device,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
)
|
||||
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
|
||||
_LOGGER.warning("Failed to fetch firmware update manifest", exc_info=True)
|
||||
|
||||
# Not having internet access should not prevent setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug("Skipping firmware upgrade due to index download failure")
|
||||
return
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type
|
||||
!= expected_installed_firmware_type
|
||||
)
|
||||
|
||||
raise AbortFlow(
|
||||
reason="fw_download_failed",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
) from err
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = FirmwareUpdateClient(fw_update_url, session)
|
||||
|
||||
if not firmware_install_required:
|
||||
assert self._probed_firmware_info is not None
|
||||
|
||||
# Make sure we do not downgrade the firmware
|
||||
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
|
||||
fw_version = fw_metadata.get_public_version()
|
||||
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
|
||||
|
||||
if probed_fw_version >= fw_version:
|
||||
_LOGGER.debug(
|
||||
"Not downgrading firmware, installed %s is newer than available %s",
|
||||
probed_fw_version,
|
||||
fw_version,
|
||||
try:
|
||||
manifest = await client.async_update_data()
|
||||
fw_manifest = next(
|
||||
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
|
||||
)
|
||||
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch firmware update manifest", exc_info=True
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
fw_data = await client.async_fetch_firmware(fw_manifest)
|
||||
except (TimeoutError, ClientError, ValueError) as err:
|
||||
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
|
||||
# Not having internet access should not prevent setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug(
|
||||
"Skipping firmware upgrade due to index download failure"
|
||||
)
|
||||
return
|
||||
|
||||
raise AbortFlow(
|
||||
reason="fw_download_failed",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
) from err
|
||||
|
||||
# If we cannot download new firmware, we shouldn't block setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug("Skipping firmware upgrade due to image download failure")
|
||||
return
|
||||
assert self._probed_firmware_info is not None
|
||||
|
||||
# Otherwise, fail
|
||||
raise AbortFlow(
|
||||
reason="fw_download_failed",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
) from err
|
||||
# Make sure we do not downgrade the firmware
|
||||
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
|
||||
fw_version = fw_metadata.get_public_version()
|
||||
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
|
||||
|
||||
self._probed_firmware_info = await async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=self._device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
progress_callback=lambda offset, total: self.async_update_progress(
|
||||
offset / total
|
||||
),
|
||||
)
|
||||
if probed_fw_version >= fw_version:
|
||||
_LOGGER.debug(
|
||||
"Not downgrading firmware, installed %s is newer than available %s",
|
||||
probed_fw_version,
|
||||
fw_version,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
fw_data = await client.async_fetch_firmware(fw_manifest)
|
||||
except (TimeoutError, ClientError, ValueError) as err:
|
||||
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
|
||||
|
||||
# If we cannot download new firmware, we shouldn't block setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug(
|
||||
"Skipping firmware upgrade due to image download failure"
|
||||
)
|
||||
return
|
||||
|
||||
# Otherwise, fail
|
||||
raise AbortFlow(
|
||||
reason="fw_download_failed",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
) from err
|
||||
|
||||
self._probed_firmware_info = await async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=self._device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
progress_callback=lambda offset, total: self.async_update_progress(
|
||||
offset / total
|
||||
),
|
||||
)
|
||||
|
||||
async def _configure_and_start_otbr_addon(self) -> None:
|
||||
"""Configure and start the OTBR addon."""
|
||||
|
||||
@@ -26,6 +26,7 @@ from .util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
async_firmware_flashing_context,
|
||||
async_flash_silabs_firmware,
|
||||
)
|
||||
|
||||
@@ -274,16 +275,18 @@ class BaseFirmwareUpdateEntity(
|
||||
)
|
||||
|
||||
try:
|
||||
firmware_info = await async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=self._current_device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
progress_callback=self._update_progress,
|
||||
domain=self._config_entry.domain,
|
||||
)
|
||||
async with async_firmware_flashing_context(
|
||||
self.hass, self._current_device, self._config_entry.domain
|
||||
):
|
||||
firmware_info = await async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=self._current_device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
progress_callback=self._update_progress,
|
||||
)
|
||||
finally:
|
||||
self._attr_in_progress = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -26,7 +26,6 @@ from homeassistant.helpers.singleton import singleton
|
||||
|
||||
from . import DATA_COMPONENT
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
OTBR_ADDON_MANAGER_DATA,
|
||||
OTBR_ADDON_NAME,
|
||||
OTBR_ADDON_SLUG,
|
||||
@@ -366,6 +365,22 @@ async def probe_silabs_firmware_type(
|
||||
return fw_info.firmware_type
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_firmware_flashing_context(
|
||||
hass: HomeAssistant, device: str, source_domain: str
|
||||
) -> AsyncIterator[None]:
|
||||
"""Register a device as having its firmware being actively interacted with."""
|
||||
async with async_firmware_update_context(hass, device, source_domain):
|
||||
firmware_info = await guess_firmware_info(hass, device)
|
||||
_LOGGER.debug("Guessed firmware info before update: %s", firmware_info)
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
for owner in firmware_info.owners:
|
||||
await stack.enter_async_context(owner.temporarily_stop(hass))
|
||||
|
||||
yield
|
||||
|
||||
|
||||
async def async_flash_silabs_firmware(
|
||||
hass: HomeAssistant,
|
||||
device: str,
|
||||
@@ -374,10 +389,11 @@ async def async_flash_silabs_firmware(
|
||||
bootloader_reset_methods: Sequence[ResetTarget],
|
||||
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||
progress_callback: Callable[[int, int], None] | None = None,
|
||||
*,
|
||||
domain: str = DOMAIN,
|
||||
) -> FirmwareInfo:
|
||||
"""Flash firmware to the SiLabs device."""
|
||||
"""Flash firmware to the SiLabs device.
|
||||
|
||||
This function is meant to be used within a firmware update context.
|
||||
"""
|
||||
if not any(
|
||||
method == expected_installed_firmware_type
|
||||
for method, _ in application_probe_methods
|
||||
@@ -387,54 +403,44 @@ async def async_flash_silabs_firmware(
|
||||
f" not in application probe methods {application_probe_methods!r}"
|
||||
)
|
||||
|
||||
async with async_firmware_update_context(hass, device, domain):
|
||||
firmware_info = await guess_firmware_info(hass, device)
|
||||
_LOGGER.debug("Identified firmware info: %s", firmware_info)
|
||||
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
|
||||
|
||||
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
|
||||
flasher = Flasher(
|
||||
device=device,
|
||||
probe_methods=tuple(
|
||||
(m.as_flasher_application_type(), baudrate)
|
||||
for m, baudrate in application_probe_methods
|
||||
),
|
||||
bootloader_reset=tuple(
|
||||
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||
),
|
||||
)
|
||||
|
||||
flasher = Flasher(
|
||||
device=device,
|
||||
probe_methods=tuple(
|
||||
(m.as_flasher_application_type(), baudrate)
|
||||
for m, baudrate in application_probe_methods
|
||||
),
|
||||
bootloader_reset=tuple(
|
||||
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||
),
|
||||
)
|
||||
try:
|
||||
# Enter the bootloader with indeterminate progress
|
||||
await flasher.enter_bootloader()
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
for owner in firmware_info.owners:
|
||||
await stack.enter_async_context(owner.temporarily_stop(hass))
|
||||
# Flash the firmware, with progress
|
||||
await flasher.flash_firmware(fw_image, progress_callback=progress_callback)
|
||||
except PermissionError as err:
|
||||
raise HomeAssistantError(
|
||||
"Failed to flash firmware: Device is used by another application"
|
||||
) from err
|
||||
except Exception as err:
|
||||
raise HomeAssistantError("Failed to flash firmware") from err
|
||||
|
||||
try:
|
||||
# Enter the bootloader with indeterminate progress
|
||||
await flasher.enter_bootloader()
|
||||
probed_firmware_info = await probe_silabs_firmware_info(
|
||||
device,
|
||||
bootloader_reset_methods=bootloader_reset_methods,
|
||||
# Only probe for the expected installed firmware type
|
||||
application_probe_methods=[
|
||||
(method, baudrate)
|
||||
for method, baudrate in application_probe_methods
|
||||
if method == expected_installed_firmware_type
|
||||
],
|
||||
)
|
||||
|
||||
# Flash the firmware, with progress
|
||||
await flasher.flash_firmware(
|
||||
fw_image, progress_callback=progress_callback
|
||||
)
|
||||
except PermissionError as err:
|
||||
raise HomeAssistantError(
|
||||
"Failed to flash firmware: Device is used by another application"
|
||||
) from err
|
||||
except Exception as err:
|
||||
raise HomeAssistantError("Failed to flash firmware") from err
|
||||
if probed_firmware_info is None:
|
||||
raise HomeAssistantError("Failed to probe the firmware after flashing")
|
||||
|
||||
probed_firmware_info = await probe_silabs_firmware_info(
|
||||
device,
|
||||
bootloader_reset_methods=bootloader_reset_methods,
|
||||
# Only probe for the expected installed firmware type
|
||||
application_probe_methods=[
|
||||
(method, baudrate)
|
||||
for method, baudrate in application_probe_methods
|
||||
if method == expected_installed_firmware_type
|
||||
],
|
||||
)
|
||||
|
||||
if probed_firmware_info is None:
|
||||
raise HomeAssistantError("Failed to probe the firmware after flashing")
|
||||
|
||||
return probed_firmware_info
|
||||
return probed_firmware_info
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters", "zeroconf"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohomekit", "commentjson"],
|
||||
"requirements": ["aiohomekit==3.2.20"],
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/homewizard",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["homewizard_energy"],
|
||||
"quality_scale": "platinum",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"not_implemented": "This integration can only be setup via discovery."
|
||||
"not_implemented": "This integration can only be set up via discovery."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ibeacon",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["bleak"],
|
||||
"requirements": ["ibeacon-ble==1.2.0"],
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/konnected",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["konnected"],
|
||||
"requirements": ["konnected==1.2.0"],
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
|
||||
from homeassistant.const import EVENT_LABS_UPDATED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.generated.labs import LABS_PREVIEW_FEATURES
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -17,7 +18,7 @@ from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import async_get_custom_components
|
||||
|
||||
from .const import DOMAIN, EVENT_LABS_UPDATED, LABS_DATA, STORAGE_KEY, STORAGE_VERSION
|
||||
from .const import DOMAIN, LABS_DATA, STORAGE_KEY, STORAGE_VERSION
|
||||
from .models import (
|
||||
EventLabsUpdatedData,
|
||||
LabPreviewFeature,
|
||||
|
||||
@@ -11,6 +11,4 @@ DOMAIN = "labs"
|
||||
STORAGE_KEY = "core.labs"
|
||||
STORAGE_VERSION = 1
|
||||
|
||||
EVENT_LABS_UPDATED = "labs_updated"
|
||||
|
||||
LABS_DATA: HassKey[LabsData] = HassKey(DOMAIN)
|
||||
|
||||
@@ -8,9 +8,10 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.backup import async_get_manager
|
||||
from homeassistant.const import EVENT_LABS_UPDATED
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import EVENT_LABS_UPDATED, LABS_DATA
|
||||
from .const import LABS_DATA
|
||||
from .models import EventLabsUpdatedData
|
||||
|
||||
|
||||
|
||||
@@ -41,48 +41,44 @@
|
||||
"title": "Lawn mower",
|
||||
"triggers": {
|
||||
"docked": {
|
||||
"description": "Triggers when a lawn mower has docked.",
|
||||
"description_configured": "[%key:component::lawn_mower::triggers::docked::description%]",
|
||||
"description": "Triggers after one or more lawn mowers return to dock.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a lawn mower has docked"
|
||||
"name": "Lawn mower returned to dock"
|
||||
},
|
||||
"errored": {
|
||||
"description": "Triggers when a lawn mower has errored.",
|
||||
"description_configured": "[%key:component::lawn_mower::triggers::errored::description%]",
|
||||
"description": "Triggers after one or more lawn mowers encounter an error.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a lawn mower has errored"
|
||||
"name": "Lawn mower encountered an error"
|
||||
},
|
||||
"paused_mowing": {
|
||||
"description": "Triggers when a lawn mower has paused mowing.",
|
||||
"description_configured": "[%key:component::lawn_mower::triggers::paused_mowing::description%]",
|
||||
"description": "Triggers after one or more lawn mowers pause mowing.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a lawn mower has paused mowing"
|
||||
"name": "Lawn mower paused mowing"
|
||||
},
|
||||
"started_mowing": {
|
||||
"description": "Triggers when a lawn mower has started mowing.",
|
||||
"description_configured": "[%key:component::lawn_mower::triggers::started_mowing::description%]",
|
||||
"description": "Triggers after one or more lawn mowers start mowing.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a lawn mower has started mowing"
|
||||
"name": "Lawn mower started mowing"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/lg_thinq",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["thinqconnect"],
|
||||
"requirements": ["thinqconnect==1.0.8"]
|
||||
"requirements": ["thinqconnect==1.0.9"]
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"conditions": {
|
||||
"is_off": {
|
||||
"description": "Test if a light is off.",
|
||||
"description_configured": "[%key:component::light::conditions::is_off::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::light::common::condition_behavior_description%]",
|
||||
@@ -54,7 +53,6 @@
|
||||
},
|
||||
"is_on": {
|
||||
"description": "Test if a light is on.",
|
||||
"description_configured": "[%key:component::light::conditions::is_on::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::light::common::condition_behavior_description%]",
|
||||
@@ -512,26 +510,24 @@
|
||||
"title": "Light",
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"description": "Triggers when a light is turned off.",
|
||||
"description_configured": "[%key:component::light::triggers::turned_off::description%]",
|
||||
"description": "Triggers after one or more lights turn off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::light::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::light::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a light is turned off"
|
||||
"name": "Light turned off"
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers when a light is turned on.",
|
||||
"description_configured": "[%key:component::light::triggers::turned_on::description%]",
|
||||
"description": "Triggers after one or more lights turn on.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::light::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::light::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a light is turned on"
|
||||
"name": "Light turned on"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ post:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
visibility:
|
||||
selector:
|
||||
select:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["websocket_api"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["python-matter-server==8.1.0"],
|
||||
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.10.22"],
|
||||
"requirements": ["yt-dlp[default]==2025.11.12"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -381,15 +381,14 @@
|
||||
"title": "Media player",
|
||||
"triggers": {
|
||||
"stopped_playing": {
|
||||
"description": "Triggers when a media player stops playing.",
|
||||
"description_configured": "[%key:component::media_player::triggers::stopped_playing::description%]",
|
||||
"description": "Triggers after one or more media players stop playing media.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::media_player::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "When a media player stops playing"
|
||||
"name": "Media player stopped playing"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@danielhiversen"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/met",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["metno"],
|
||||
"requirements": ["PyMetno==0.13.0"]
|
||||
|
||||
@@ -48,6 +48,7 @@ CONDITION_CLASSES: dict[str, list[str]] = {
|
||||
"Brouillard givrant",
|
||||
"Bancs de Brouillard",
|
||||
"Brouillard dense",
|
||||
"Brouillard dense givrant",
|
||||
],
|
||||
ATTR_CONDITION_HAIL: ["Risque de grêle", "Averses de grêle"],
|
||||
ATTR_CONDITION_LIGHTNING: ["Risque d'orages", "Orages", "Orage avec grêle"],
|
||||
|
||||
@@ -174,15 +174,15 @@ class ProgramPhaseWashingMachine(MieleEnum, missing_to_none=True):
|
||||
not_running = 0, 256, 65535
|
||||
pre_wash = 257, 259
|
||||
soak = 258
|
||||
main_wash = 260
|
||||
rinse = 261
|
||||
main_wash = 260, 11004
|
||||
rinse = 261, 11005
|
||||
rinse_hold = 262
|
||||
cleaning = 263
|
||||
cooling_down = 264
|
||||
drain = 265
|
||||
spin = 266
|
||||
anti_crease = 267
|
||||
finished = 268
|
||||
spin = 266, 11010
|
||||
anti_crease = 267, 11029
|
||||
finished = 268, 11012
|
||||
venting = 269
|
||||
starch_stop = 270
|
||||
freshen_up_and_moisten = 271
|
||||
@@ -190,6 +190,7 @@ class ProgramPhaseWashingMachine(MieleEnum, missing_to_none=True):
|
||||
hygiene = 279
|
||||
drying = 280
|
||||
disinfecting = 285
|
||||
flex_load_active = 11047
|
||||
|
||||
|
||||
class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True):
|
||||
@@ -481,8 +482,9 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
|
||||
express_20 = 122
|
||||
down_filled_items = 129
|
||||
cottons_eco = 133
|
||||
quick_power_wash = 146
|
||||
eco_40_60 = 190
|
||||
quick_power_wash = 146, 10031
|
||||
eco_40_60 = 190, 10007
|
||||
normal = 10001
|
||||
|
||||
|
||||
class DishWasherProgramId(MieleEnum, missing_to_none=True):
|
||||
|
||||
@@ -954,6 +954,7 @@
|
||||
"extra_dry": "Extra dry",
|
||||
"final_rinse": "Final rinse",
|
||||
"finished": "Finished",
|
||||
"flex_load_active": "FlexLoad active",
|
||||
"freshen_up_and_moisten": "Freshen up & moisten",
|
||||
"going_to_target_area": "Going to target area",
|
||||
"grinding": "Grinding",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/motion_blinds",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["motionblinds"],
|
||||
"requirements": ["motionblinds==0.6.30"]
|
||||
|
||||
@@ -378,31 +378,33 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate the options from config entry data."""
|
||||
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
|
||||
data: dict[str, Any] = dict(entry.data)
|
||||
options: dict[str, Any] = dict(entry.options)
|
||||
if entry.version > 1:
|
||||
if entry.version > 2 or (entry.version == 2 and entry.minor_version > 1):
|
||||
# This means the user has downgraded from a future version
|
||||
# We allow read support for version 2.1
|
||||
return False
|
||||
|
||||
if entry.version == 1 and entry.minor_version < 2:
|
||||
# Can be removed when config entry is bumped to version 2.1
|
||||
# with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1
|
||||
# From 2026.1 we will write version 2.1
|
||||
# Can be removed when the config entry is bumped to version 2.1
|
||||
# with HA Core 2026.7.0. Read support for version 2.1 is expected with 2026.1
|
||||
# From 2026.7 we will write version 2.1
|
||||
for key in ENTRY_OPTION_FIELDS:
|
||||
if key not in data:
|
||||
continue
|
||||
options[key] = data.pop(key)
|
||||
# Write version 1.2 for backwards compatibility
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=data,
|
||||
options=options,
|
||||
version=CONFIG_ENTRY_VERSION,
|
||||
minor_version=CONFIG_ENTRY_MINOR_VERSION,
|
||||
version=1,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
"Migration to version %s.%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -3952,9 +3952,8 @@ REAUTH_SCHEMA = vol.Schema(
|
||||
class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
|
||||
# Can be bumped to version 2.1 with HA Core 2026.1.0
|
||||
VERSION = CONFIG_ENTRY_VERSION # 1
|
||||
MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION # 2
|
||||
VERSION = CONFIG_ENTRY_VERSION # 2
|
||||
MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION # 1
|
||||
|
||||
_hassio_discovery: dict[str, Any] | None = None
|
||||
_addon_manager: AddonManager
|
||||
|
||||
@@ -381,13 +381,13 @@ MQTT_PROCESSED_SUBSCRIPTIONS = "mqtt_processed_subscriptions"
|
||||
PAYLOAD_EMPTY_JSON = "{}"
|
||||
PAYLOAD_NONE = "None"
|
||||
|
||||
CONFIG_ENTRY_VERSION = 1
|
||||
CONFIG_ENTRY_MINOR_VERSION = 2
|
||||
CONFIG_ENTRY_VERSION = 2
|
||||
CONFIG_ENTRY_MINOR_VERSION = 1
|
||||
|
||||
# Split mqtt entry data and options
|
||||
# Can be removed when config entry is bumped to version 2.1
|
||||
# with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1
|
||||
# From 2026.1 we will write version 2.1
|
||||
# with HA Core 2026.7.0. Read support for version 2.1 is expected from 2026.1
|
||||
# From 2026.7 we will write version 2.1
|
||||
ENTRY_OPTION_FIELDS = (
|
||||
CONF_DISCOVERY,
|
||||
CONF_DISCOVERY_PREFIX,
|
||||
|
||||
@@ -1562,8 +1562,7 @@
|
||||
},
|
||||
"triggers": {
|
||||
"_": {
|
||||
"description": "When a specific message is received on a given MQTT topic.",
|
||||
"description_configured": "When an MQTT message has been received",
|
||||
"description": "Triggers after a specific message is received on a given MQTT topic.",
|
||||
"fields": {
|
||||
"payload": {
|
||||
"description": "The payload to trigger on.",
|
||||
@@ -1574,7 +1573,7 @@
|
||||
"name": "Topic"
|
||||
}
|
||||
},
|
||||
"name": "MQTT"
|
||||
"name": "MQTT message received"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["auth"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["music_assistant"],
|
||||
"quality_scale": "bronze",
|
||||
|
||||
@@ -10,6 +10,47 @@
|
||||
"is_going": {
|
||||
"default": "mdi:bell-cancel-outline"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"arrival_platform_actual": {
|
||||
"default": "mdi:logout"
|
||||
},
|
||||
"arrival_platform_planned": {
|
||||
"default": "mdi:logout"
|
||||
},
|
||||
"arrival_time_actual": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
"arrival_time_planned": {
|
||||
"default": "mdi:calendar-clock"
|
||||
},
|
||||
"departure": {
|
||||
"default": "mdi:train"
|
||||
},
|
||||
"departure_platform_actual": {
|
||||
"default": "mdi:login"
|
||||
},
|
||||
"departure_platform_planned": {
|
||||
"default": "mdi:login"
|
||||
},
|
||||
"departure_time_actual": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
"departure_time_planned": {
|
||||
"default": "mdi:calendar-clock"
|
||||
},
|
||||
"next_departure_time": {
|
||||
"default": "mdi:train"
|
||||
},
|
||||
"route": {
|
||||
"default": "mdi:transit-connection-variant"
|
||||
},
|
||||
"status": {
|
||||
"default": "mdi:information"
|
||||
},
|
||||
"transfers": {
|
||||
"default": "mdi:swap-horizontal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -13,9 +15,10 @@ from homeassistant.components.sensor import (
|
||||
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_API_KEY, CONF_NAME
|
||||
from homeassistant.const import CONF_API_KEY, CONF_NAME, EntityCategory
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
@@ -24,9 +27,10 @@ from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .binary_sensor import get_delay
|
||||
from .const import (
|
||||
CONF_FROM,
|
||||
CONF_ROUTES,
|
||||
@@ -40,7 +44,7 @@ from .const import (
|
||||
from .coordinator import NSConfigEntry, NSDataUpdateCoordinator
|
||||
|
||||
|
||||
def _get_departure_time(trip: Trip | None) -> datetime | None:
|
||||
def get_departure_time(trip: Trip | None) -> datetime | None:
|
||||
"""Get next departure time from trip data."""
|
||||
return trip.departure_time_actual or trip.departure_time_planned if trip else None
|
||||
|
||||
@@ -61,13 +65,15 @@ def _get_route(trip: Trip | None) -> list[str]:
|
||||
return route
|
||||
|
||||
|
||||
def _get_delay(planned: datetime | None, actual: datetime | None) -> bool:
|
||||
"""Return True if delay is present, False otherwise."""
|
||||
return bool(planned and actual and planned != actual)
|
||||
|
||||
TRIP_STATUS = {
|
||||
"NORMAL": "normal",
|
||||
"CANCELLED": "cancelled",
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0 # since we use coordinator pattern
|
||||
|
||||
ROUTE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
@@ -85,6 +91,110 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class NSSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Nederlandse Spoorwegen sensor entity."""
|
||||
|
||||
is_next: bool = False
|
||||
value_fn: Callable[[Trip], datetime | str | int | None]
|
||||
entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC
|
||||
|
||||
|
||||
# Entity descriptions for all the different sensors we create per route
|
||||
SENSOR_DESCRIPTIONS: tuple[NSSensorEntityDescription, ...] = (
|
||||
NSSensorEntityDescription(
|
||||
key="actual_departure",
|
||||
translation_key="departure",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=None,
|
||||
value_fn=get_departure_time,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="next_departure",
|
||||
translation_key="next_departure_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
is_next=True,
|
||||
value_fn=get_departure_time,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# Platform information
|
||||
NSSensorEntityDescription(
|
||||
key="departure_platform_planned",
|
||||
translation_key="departure_platform_planned",
|
||||
value_fn=lambda trip: getattr(trip, "departure_platform_planned", None),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="departure_platform_actual",
|
||||
translation_key="departure_platform_actual",
|
||||
value_fn=lambda trip: trip.departure_platform_actual,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="arrival_platform_planned",
|
||||
translation_key="arrival_platform_planned",
|
||||
value_fn=lambda trip: trip.arrival_platform_planned,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="arrival_platform_actual",
|
||||
translation_key="arrival_platform_actual",
|
||||
value_fn=lambda trip: trip.arrival_platform_actual,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="departure_time_planned",
|
||||
translation_key="departure_time_planned",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda trip: trip.departure_time_planned,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="departure_time_actual",
|
||||
translation_key="departure_time_actual",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda trip: trip.departure_time_actual,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="arrival_time_planned",
|
||||
translation_key="arrival_time_planned",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda trip: trip.arrival_time_planned,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="arrival_time_actual",
|
||||
translation_key="arrival_time_actual",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda trip: trip.arrival_time_actual,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# Trip information
|
||||
NSSensorEntityDescription(
|
||||
key="status",
|
||||
translation_key="status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(TRIP_STATUS.values()),
|
||||
value_fn=lambda trip: TRIP_STATUS.get(trip.status),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="transfers",
|
||||
translation_key="transfers",
|
||||
value_fn=lambda trip: trip.nr_transfers if trip else 0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# Route info sensors
|
||||
NSSensorEntityDescription(
|
||||
key="route",
|
||||
translation_key="route",
|
||||
value_fn=lambda trip: ", ".join(_get_route(trip)),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -144,58 +254,61 @@ async def async_setup_entry(
|
||||
coordinators = config_entry.runtime_data
|
||||
|
||||
for subentry_id, coordinator in coordinators.items():
|
||||
# Build entity from coordinator fields directly
|
||||
entity = NSDepartureSensor(
|
||||
subentry_id,
|
||||
coordinator,
|
||||
async_add_entities(
|
||||
[
|
||||
NSSensor(coordinator, subentry_id, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
],
|
||||
config_subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
# Add entity with proper subentry association
|
||||
async_add_entities([entity], config_subentry_id=subentry_id)
|
||||
|
||||
class NSSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity):
|
||||
"""Generic NS sensor based on entity description."""
|
||||
|
||||
class NSDepartureSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity):
|
||||
"""Implementation of a NS Departure Sensor (legacy)."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_has_entity_name = True
|
||||
_attr_attribution = "Data provided by NS"
|
||||
_attr_icon = "mdi:train"
|
||||
entity_description: NSSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subentry_id: str,
|
||||
coordinator: NSDataUpdateCoordinator,
|
||||
subentry_id: str,
|
||||
description: NSSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._name = coordinator.name
|
||||
self.entity_description = description
|
||||
self._attr_entity_category = description.entity_category
|
||||
self._subentry_id = subentry_id
|
||||
self._attr_unique_id = f"{subentry_id}-actual_departure"
|
||||
|
||||
self._attr_unique_id = f"{subentry_id}-{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._subentry_id)},
|
||||
name=self._name,
|
||||
identifiers={(DOMAIN, subentry_id)},
|
||||
name=coordinator.name,
|
||||
manufacturer=INTEGRATION_TITLE,
|
||||
model=ROUTE_MODEL,
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | None:
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the native value of the sensor."""
|
||||
route_data = self.coordinator.data
|
||||
if not route_data.first_trip:
|
||||
data = (
|
||||
self.coordinator.data.first_trip
|
||||
if not self.entity_description.is_next
|
||||
else self.coordinator.data.next_trip
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
first_trip = route_data.first_trip
|
||||
return _get_departure_time(first_trip)
|
||||
return self.entity_description.value_fn(data)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the state attributes."""
|
||||
if self.entity_description.key != "actual_departure":
|
||||
return None
|
||||
|
||||
first_trip = self.coordinator.data.first_trip
|
||||
next_trip = self.coordinator.data.next_trip
|
||||
|
||||
@@ -204,11 +317,12 @@ class NSDepartureSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity
|
||||
|
||||
status = first_trip.status
|
||||
|
||||
# Static attributes
|
||||
return {
|
||||
"going": first_trip.going,
|
||||
"departure_time_planned": _get_time_str(first_trip.departure_time_planned),
|
||||
"departure_time_actual": _get_time_str(first_trip.departure_time_actual),
|
||||
"departure_delay": _get_delay(
|
||||
"departure_delay": get_delay(
|
||||
first_trip.departure_time_planned,
|
||||
first_trip.departure_time_actual,
|
||||
),
|
||||
@@ -216,13 +330,13 @@ class NSDepartureSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity
|
||||
"departure_platform_actual": first_trip.departure_platform_actual,
|
||||
"arrival_time_planned": _get_time_str(first_trip.arrival_time_planned),
|
||||
"arrival_time_actual": _get_time_str(first_trip.arrival_time_actual),
|
||||
"arrival_delay": _get_delay(
|
||||
"arrival_delay": get_delay(
|
||||
first_trip.arrival_time_planned,
|
||||
first_trip.arrival_time_actual,
|
||||
),
|
||||
"arrival_platform_planned": first_trip.arrival_platform_planned,
|
||||
"arrival_platform_actual": first_trip.arrival_platform_actual,
|
||||
"next": _get_time_str(_get_departure_time(next_trip)),
|
||||
"next": _get_time_str(get_departure_time(next_trip)),
|
||||
"status": status.lower() if status else None,
|
||||
"transfers": first_trip.nr_transfers,
|
||||
"route": _get_route(first_trip),
|
||||
|
||||
@@ -75,6 +75,57 @@
|
||||
"is_going": {
|
||||
"name": "Going"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"arrival_platform_actual": {
|
||||
"name": "Actual arrival platform"
|
||||
},
|
||||
"arrival_platform_planned": {
|
||||
"name": "Planned arrival platform"
|
||||
},
|
||||
"arrival_time_actual": {
|
||||
"name": "Actual arrival time"
|
||||
},
|
||||
"arrival_time_planned": {
|
||||
"name": "Planned arrival time"
|
||||
},
|
||||
"departure": {
|
||||
"name": "Departure"
|
||||
},
|
||||
"departure_platform_actual": {
|
||||
"name": "Actual departure platform"
|
||||
},
|
||||
"departure_platform_planned": {
|
||||
"name": "Planned departure platform"
|
||||
},
|
||||
"departure_time_actual": {
|
||||
"name": "Actual departure time"
|
||||
},
|
||||
"departure_time_planned": {
|
||||
"name": "Planned departure time"
|
||||
},
|
||||
"next_departure_time": {
|
||||
"name": "Next departure"
|
||||
},
|
||||
"route": {
|
||||
"name": "Route"
|
||||
},
|
||||
"route_from": {
|
||||
"name": "Route from"
|
||||
},
|
||||
"route_to": {
|
||||
"name": "Route to"
|
||||
},
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"cancelled": "Cancelled",
|
||||
"normal": "Normal"
|
||||
}
|
||||
},
|
||||
"transfers": {
|
||||
"name": "Transfers"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["google_nest_sdm"],
|
||||
"requirements": ["google-nest-sdm==9.1.0"]
|
||||
"requirements": ["google-nest-sdm==9.1.1"]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/nuki",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pynuki"],
|
||||
"requirements": ["pynuki==1.6.3"]
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
"""Constants for the onboarding component."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefaultArea:
|
||||
"""Default area definition."""
|
||||
|
||||
key: str
|
||||
icon: str
|
||||
|
||||
|
||||
DOMAIN = "onboarding"
|
||||
STEP_USER = "user"
|
||||
STEP_CORE_CONFIG = "core_config"
|
||||
@@ -8,4 +19,8 @@ STEP_ANALYTICS = "analytics"
|
||||
|
||||
STEPS = [STEP_USER, STEP_CORE_CONFIG, STEP_ANALYTICS, STEP_INTEGRATION]
|
||||
|
||||
DEFAULT_AREAS = ("living_room", "kitchen", "bedroom")
|
||||
DEFAULT_AREAS = (
|
||||
DefaultArea(key="living_room", icon="mdi:sofa"),
|
||||
DefaultArea(key="kitchen", icon="mdi:stove"),
|
||||
DefaultArea(key="bedroom", icon="mdi:bed"),
|
||||
)
|
||||
|
||||
@@ -208,11 +208,11 @@ class UserOnboardingView(_BaseOnboardingStepView):
|
||||
area_registry = ar.async_get(hass)
|
||||
|
||||
for area in DEFAULT_AREAS:
|
||||
name = translations[f"component.onboarding.area.{area}"]
|
||||
name = translations[f"component.onboarding.area.{area.key}"]
|
||||
# Guard because area might have been created by an automatically
|
||||
# set up integration.
|
||||
if not area_registry.async_get_area_by_name(name):
|
||||
area_registry.async_create(name)
|
||||
area_registry.async_create(name, icon=area.icon)
|
||||
|
||||
await self._async_mark_done(hass)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@jpbede"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ping",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["icmplib"],
|
||||
"quality_scale": "internal",
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import UnitOfTemperature, UnitOfVolumeFlowRate
|
||||
from homeassistant.const import UnitOfTemperature, UnitOfVolume, UnitOfVolumeFlowRate
|
||||
|
||||
DOMAIN = "pooldose"
|
||||
MANUFACTURER = "SEKO"
|
||||
|
||||
# Mapping of device units to Home Assistant units
|
||||
# Mapping of device units (upper case) to Home Assistant units
|
||||
UNIT_MAPPING: dict[str, str] = {
|
||||
# Temperature units
|
||||
"°C": UnitOfTemperature.CELSIUS,
|
||||
"°F": UnitOfTemperature.FAHRENHEIT,
|
||||
# Volume flow rate units
|
||||
"m3/h": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||
"L/s": UnitOfVolumeFlowRate.LITERS_PER_SECOND,
|
||||
"M3/H": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||
"L/S": UnitOfVolumeFlowRate.LITERS_PER_SECOND,
|
||||
# Volume units
|
||||
"L": UnitOfVolume.LITERS,
|
||||
"M3": UnitOfVolume.CUBIC_METERS,
|
||||
}
|
||||
|
||||
@@ -119,6 +119,9 @@
|
||||
},
|
||||
"ph_type_dosing": {
|
||||
"default": "mdi:beaker"
|
||||
},
|
||||
"water_meter_total_permanent": {
|
||||
"default": "mdi:counter"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/pooldose",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-pooldose==0.8.0"]
|
||||
"requirements": ["python-pooldose==0.8.1"]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
@@ -58,6 +59,13 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
use_dynamic_unit=True,
|
||||
),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="water_meter_total_permanent",
|
||||
translation_key="water_meter_total_permanent",
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
use_dynamic_unit=True,
|
||||
),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ph_type_dosing",
|
||||
translation_key="ph_type_dosing",
|
||||
@@ -223,8 +231,8 @@ class PooldoseSensor(PooldoseEntity, SensorEntity):
|
||||
and (data := self.get_data()) is not None
|
||||
and (device_unit := data.get("unit"))
|
||||
):
|
||||
# Map device unit to Home Assistant unit, return None if unknown
|
||||
return UNIT_MAPPING.get(device_unit)
|
||||
# Map device unit (upper case) to Home Assistant unit, return None if unknown
|
||||
return UNIT_MAPPING.get(device_unit.upper())
|
||||
|
||||
# Fall back to static unit from entity description
|
||||
return super().native_unit_of_measurement
|
||||
|
||||
@@ -160,6 +160,9 @@
|
||||
"acid": "pH-",
|
||||
"alcalyne": "pH+"
|
||||
}
|
||||
},
|
||||
"water_meter_total_permanent": {
|
||||
"name": "Totalizer"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/reolink",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "RESTful",
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/rest",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["jsonpath==0.82.2", "xmltodict==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ring",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ring_doorbell"],
|
||||
"quality_scale": "bronze",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/roborock",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
|
||||
@@ -14,6 +14,7 @@ from .coordinator import LeilSaunaCoordinator
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator]
|
||||
|
||||
9
homeassistant/components/saunum/icons.json
Normal file
9
homeassistant/components/saunum/icons.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"heater_elements_active": {
|
||||
"default": "mdi:radiator"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user