mirror of
https://github.com/home-assistant/core.git
synced 2025-12-02 14:08:12 +00:00
Compare commits
70 Commits
services_f
...
negative_r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc2b6fe43 | ||
|
|
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 | ||
|
|
e10c1ebcf6 | ||
|
|
0174bad182 | ||
|
|
d5be623684 | ||
|
|
d006b044c8 | ||
|
|
fdd9571623 | ||
|
|
4f4c5152b9 | ||
|
|
b031a082cd | ||
|
|
a1132195fd | ||
|
|
708b3dc8b2 | ||
|
|
8ae0216135 | ||
|
|
1472281cd5 | ||
|
|
ceaa71d198 |
@@ -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
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
|
||||
|
||||
@@ -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}."
|
||||
},
|
||||
|
||||
@@ -160,7 +160,6 @@
|
||||
"triggers": {
|
||||
"armed": {
|
||||
"description": "Triggers when an alarm is armed.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
@@ -171,7 +170,6 @@
|
||||
},
|
||||
"armed_away": {
|
||||
"description": "Triggers when an alarm is armed away.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_away::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
@@ -182,7 +180,6 @@
|
||||
},
|
||||
"armed_home": {
|
||||
"description": "Triggers when an alarm is armed home.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_home::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
@@ -193,7 +190,6 @@
|
||||
},
|
||||
"armed_night": {
|
||||
"description": "Triggers when an alarm is armed night.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_night::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
@@ -204,7 +200,6 @@
|
||||
},
|
||||
"armed_vacation": {
|
||||
"description": "Triggers when an alarm is armed vacation.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_vacation::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
@@ -215,7 +210,6 @@
|
||||
},
|
||||
"disarmed": {
|
||||
"description": "Triggers when an alarm is disarmed.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::disarmed::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
@@ -226,7 +220,6 @@
|
||||
},
|
||||
"triggered": {
|
||||
"description": "Triggers when an alarm is triggered.",
|
||||
"description_configured": "[%key:component::alarm_control_panel::triggers::triggered::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from pyanglianwater import AnglianWater
|
||||
from pyanglianwater.auth import MSOB2CAuth
|
||||
from pyanglianwater.exceptions import (
|
||||
@@ -18,7 +19,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
|
||||
from .coordinator import AnglianWaterConfigEntry, AnglianWaterUpdateCoordinator
|
||||
@@ -33,7 +34,10 @@ async def async_setup_entry(
|
||||
auth = MSOB2CAuth(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=async_get_clientsession(hass),
|
||||
session=async_create_clientsession(
|
||||
hass,
|
||||
cookie_jar=CookieJar(quote_cookie=False),
|
||||
),
|
||||
refresh_token=entry.data[CONF_ACCESS_TOKEN],
|
||||
account_number=entry.data[CONF_ACCOUNT_NUMBER],
|
||||
)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -421,6 +421,8 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
if model_alias.endswith(("haiku", "opus", "sonnet")):
|
||||
model_alias += "-latest"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.73.0"]
|
||||
"requirements": ["anthropic==0.75.0"]
|
||||
}
|
||||
|
||||
@@ -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'"],
|
||||
|
||||
@@ -113,7 +113,6 @@
|
||||
"triggers": {
|
||||
"idle": {
|
||||
"description": "Triggers when an Assist satellite becomes idle.",
|
||||
"description_configured": "[%key:component::assist_satellite::triggers::idle::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
@@ -124,7 +123,6 @@
|
||||
},
|
||||
"listening": {
|
||||
"description": "Triggers when an Assist satellite starts listening.",
|
||||
"description_configured": "[%key:component::assist_satellite::triggers::listening::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
@@ -135,7 +133,6 @@
|
||||
},
|
||||
"processing": {
|
||||
"description": "Triggers when an Assist satellite is processing.",
|
||||
"description_configured": "[%key:component::assist_satellite::triggers::processing::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
@@ -146,7 +143,6 @@
|
||||
},
|
||||
"responding": {
|
||||
"description": "Triggers when an Assist satellite is responding.",
|
||||
"description_configured": "[%key:component::assist_satellite::triggers::responding::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -300,7 +300,6 @@
|
||||
"triggers": {
|
||||
"started_cooling": {
|
||||
"description": "Triggers when a climate started cooling.",
|
||||
"description_configured": "[%key:component::climate::triggers::started_cooling::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
@@ -311,7 +310,6 @@
|
||||
},
|
||||
"started_drying": {
|
||||
"description": "Triggers when a climate started drying.",
|
||||
"description_configured": "[%key:component::climate::triggers::started_drying::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
@@ -322,7 +320,6 @@
|
||||
},
|
||||
"started_heating": {
|
||||
"description": "Triggers when a climate starts to heat.",
|
||||
"description_configured": "[%key:component::climate::triggers::started_heating::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
@@ -333,7 +330,6 @@
|
||||
},
|
||||
"turned_off": {
|
||||
"description": "Triggers when a climate is turned off.",
|
||||
"description_configured": "[%key:component::climate::triggers::turned_off::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
@@ -344,7 +340,6 @@
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers when a climate is turned on.",
|
||||
"description_configured": "[%key:component::climate::triggers::turned_on::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
|
||||
@@ -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
|
||||
@@ -17,7 +17,7 @@ DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
|
||||
|
||||
DEFAULT_PORT: Final = 6053
|
||||
|
||||
STABLE_BLE_VERSION_STR = "2025.8.0"
|
||||
STABLE_BLE_VERSION_STR = "2025.11.0"
|
||||
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
|
||||
PROJECT_URLS = {
|
||||
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
"title": "[%key:component::assist_pipeline::issues::assist_in_progress_deprecated::title%]"
|
||||
},
|
||||
"ble_firmware_outdated": {
|
||||
"description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device from ESPHome earlier than 2022.12.0, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme.",
|
||||
"description": "ESPHome {version} introduces ultra-low latency event processing, reducing BLE event delays from 0-16 milliseconds to approximately 12 microseconds. This resolves stability issues when pairing, connecting, or handshaking with devices that require low latency, and makes Bluetooth proxy operations rival or exceed local adapters. We highly recommend updating {name} to take advantage of these improvements.",
|
||||
"title": "Update {name} with ESPHome {version} or later"
|
||||
},
|
||||
"device_conflict": {
|
||||
|
||||
@@ -167,7 +167,6 @@
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"description": "Triggers when a fan is turned off.",
|
||||
"description_configured": "[%key:component::fan::triggers::turned_off::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::fan::common::trigger_behavior_description%]",
|
||||
@@ -178,7 +177,6 @@
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers when a fan is turned on.",
|
||||
"description_configured": "[%key:component::fan::triggers::turned_on::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::fan::common::trigger_behavior_description%]",
|
||||
|
||||
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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
"triggers": {
|
||||
"docked": {
|
||||
"description": "Triggers when a lawn mower has docked.",
|
||||
"description_configured": "[%key:component::lawn_mower::triggers::docked::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
|
||||
@@ -53,7 +52,6 @@
|
||||
},
|
||||
"errored": {
|
||||
"description": "Triggers when a lawn mower has errored.",
|
||||
"description_configured": "[%key:component::lawn_mower::triggers::errored::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
|
||||
@@ -64,7 +62,6 @@
|
||||
},
|
||||
"paused_mowing": {
|
||||
"description": "Triggers when a lawn mower has paused mowing.",
|
||||
"description_configured": "[%key:component::lawn_mower::triggers::paused_mowing::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
|
||||
@@ -75,7 +72,6 @@
|
||||
},
|
||||
"started_mowing": {
|
||||
"description": "Triggers when a lawn mower has started mowing.",
|
||||
"description_configured": "[%key:component::lawn_mower::triggers::started_mowing::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
|
||||
|
||||
@@ -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%]",
|
||||
@@ -513,7 +511,6 @@
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"description": "Triggers when a light is turned off.",
|
||||
"description_configured": "[%key:component::light::triggers::turned_off::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::light::common::trigger_behavior_description%]",
|
||||
@@ -524,7 +521,6 @@
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers when a light is turned on.",
|
||||
"description_configured": "[%key:component::light::triggers::turned_on::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::light::common::trigger_behavior_description%]",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -382,7 +382,6 @@
|
||||
"triggers": {
|
||||
"stopped_playing": {
|
||||
"description": "Triggers when a media player stops playing.",
|
||||
"description_configured": "[%key:component::media_player::triggers::stopped_playing::description%]",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::media_player::common::trigger_behavior_description%]",
|
||||
|
||||
@@ -174,14 +174,14 @@ 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
|
||||
spin = 266, 11010
|
||||
anti_crease = 267, 11029
|
||||
finished = 268
|
||||
venting = 269
|
||||
starch_stop = 270
|
||||
@@ -483,6 +483,7 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
|
||||
cottons_eco = 133
|
||||
quick_power_wash = 146
|
||||
eco_40_60 = 190
|
||||
normal = 10001
|
||||
|
||||
|
||||
class DishWasherProgramId(MieleEnum, missing_to_none=True):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1486,6 +1486,7 @@ class MqttEntity(
|
||||
entity_registry.async_update_entity(
|
||||
self.entity_id, new_entity_id=self._update_registry_entity_id
|
||||
)
|
||||
self._update_registry_entity_id = None
|
||||
|
||||
await super().async_added_to_hass()
|
||||
self._subscriptions = {}
|
||||
|
||||
@@ -729,8 +729,8 @@
|
||||
"data_description": {
|
||||
"payload_reset_percentage": "A special payload that resets the fan speed percentage state attribute to unknown when received at the percentage state topic.",
|
||||
"percentage_command_template": "A [template]({command_templating_url}) to compose the payload to be published at the percentage command topic.",
|
||||
"percentage_command_topic": "The MQTT topic to publish commands to change the fan speed state based on a percentage. [Learn more.]({url}#percentage_command_topic)",
|
||||
"percentage_state_topic": "The MQTT topic subscribed to receive fan speed based on percentage. [Learn more.]({url}#percentage_state_topic)",
|
||||
"percentage_command_topic": "The MQTT topic to publish commands to change the fan speed state based on a percentage setting. The value shall be in the range from \"speed range min\" to \"speed range max\". [Learn more.]({url}#percentage_command_topic)",
|
||||
"percentage_state_topic": "The MQTT topic subscribed to receive fan speed state. This is a value in the range from \"speed range min\" to \"speed range max\". [Learn more.]({url}#percentage_state_topic)",
|
||||
"percentage_value_template": "Defines a [template]({value_templating_url}) to extract the speed percentage value.",
|
||||
"speed_range_max": "The maximum of numeric output range (representing 100 %). The percentage step is 100 / number of speeds within the \"speed range\".",
|
||||
"speed_range_min": "The minimum of numeric output range (off not included, so speed_range_min - 1 represents 0 %). The percentage step is 100 / the number of speeds within the \"speed range\"."
|
||||
@@ -1563,7 +1563,6 @@
|
||||
"triggers": {
|
||||
"_": {
|
||||
"description": "When a specific message is received on a given MQTT topic.",
|
||||
"description_configured": "When an MQTT message has been received",
|
||||
"fields": {
|
||||
"payload": {
|
||||
"description": "The payload to trigger on.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -17,7 +17,6 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
SchemaConfigFlowHandler,
|
||||
@@ -60,8 +59,8 @@ def _generate_schema(domain: str, flow_type: _FlowType) -> vol.Schema:
|
||||
if domain == Platform.SENSOR:
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(CONF_MINIMUM, default=DEFAULT_MIN): cv.positive_int,
|
||||
vol.Optional(CONF_MAXIMUM, default=DEFAULT_MAX): cv.positive_int,
|
||||
vol.Optional(CONF_MINIMUM, default=DEFAULT_MIN): vol.Coerce(int),
|
||||
vol.Optional(CONF_MAXIMUM, default=DEFAULT_MAX): vol.Coerce(int),
|
||||
vol.Optional(CONF_DEVICE_CLASS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==3.8.1",
|
||||
"python-roborock==3.8.4",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,10 +60,10 @@ rules:
|
||||
comment: Integration controls a single device; no dynamic device discovery needed.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
@@ -74,5 +74,7 @@ rules:
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: Integration uses Modbus TCP protocol and does not make HTTP requests.
|
||||
strict-typing: todo
|
||||
|
||||
99
homeassistant/components/saunum/sensor.py
Normal file
99
homeassistant/components/saunum/sensor.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Sensor platform for Saunum Leil Sauna Control Unit integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pysaunum import SaunumData
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import LeilSaunaConfigEntry
|
||||
from .entity import LeilSaunaEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .coordinator import LeilSaunaCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class LeilSaunaSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Leil Sauna sensor entity."""
|
||||
|
||||
value_fn: Callable[[SaunumData], float | int | None]
|
||||
|
||||
|
||||
SENSORS: tuple[LeilSaunaSensorEntityDescription, ...] = (
|
||||
LeilSaunaSensorEntityDescription(
|
||||
key="current_temperature",
|
||||
translation_key="current_temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.current_temperature,
|
||||
),
|
||||
LeilSaunaSensorEntityDescription(
|
||||
key="heater_elements_active",
|
||||
translation_key="heater_elements_active",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.heater_elements_active,
|
||||
),
|
||||
LeilSaunaSensorEntityDescription(
|
||||
key="on_time",
|
||||
translation_key="on_time",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda data: data.on_time,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LeilSaunaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Saunum Leil Sauna sensors from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
LeilSaunaSensorEntity(coordinator, description)
|
||||
for description in SENSORS
|
||||
if description.value_fn(coordinator.data) is not None
|
||||
)
|
||||
|
||||
|
||||
class LeilSaunaSensorEntity(LeilSaunaEntity, SensorEntity):
|
||||
"""Representation of a Saunum Leil Sauna sensor."""
|
||||
|
||||
entity_description: LeilSaunaSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: LeilSaunaCoordinator,
|
||||
description: LeilSaunaSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | int | None:
|
||||
"""Return the value reported by the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
@@ -34,6 +34,15 @@
|
||||
"light": {
|
||||
"name": "[%key:component::light::title%]"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"heater_elements_active": {
|
||||
"name": "Heater elements active",
|
||||
"unit_of_measurement": "heater elements"
|
||||
},
|
||||
"on_time": {
|
||||
"name": "Total time turned on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -30,7 +30,7 @@ from .entity import (
|
||||
ShellyRpcAttributeEntity,
|
||||
ShellySleepingBlockAttributeEntity,
|
||||
ShellySleepingRpcAttributeEntity,
|
||||
async_setup_entry_attribute_entities,
|
||||
async_setup_entry_block,
|
||||
async_setup_entry_rest,
|
||||
async_setup_entry_rpc,
|
||||
)
|
||||
@@ -127,7 +127,7 @@ class RpcBluTrvBinarySensor(RpcBinarySensor):
|
||||
)
|
||||
|
||||
|
||||
SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
|
||||
BLOCK_SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
|
||||
("device", "overtemp"): BlockBinarySensorDescription(
|
||||
key="device|overtemp",
|
||||
translation_key="overheating",
|
||||
@@ -372,19 +372,19 @@ def _async_setup_block_entry(
|
||||
) -> None:
|
||||
"""Set up entities for BLOCK device."""
|
||||
if config_entry.data[CONF_SLEEP_PERIOD]:
|
||||
async_setup_entry_attribute_entities(
|
||||
async_setup_entry_block(
|
||||
hass,
|
||||
config_entry,
|
||||
async_add_entities,
|
||||
SENSORS,
|
||||
BLOCK_SENSORS,
|
||||
BlockSleepingBinarySensor,
|
||||
)
|
||||
else:
|
||||
async_setup_entry_attribute_entities(
|
||||
async_setup_entry_block(
|
||||
hass,
|
||||
config_entry,
|
||||
async_add_entities,
|
||||
SENSORS,
|
||||
BLOCK_SENSORS,
|
||||
BlockBinarySensor,
|
||||
)
|
||||
async_setup_entry_rest(
|
||||
|
||||
@@ -341,3 +341,5 @@ MODEL_TOP_EV_CHARGER_EVE01 = "EVE01"
|
||||
MODEL_FRANKEVER_IRRIGATION_CONTROLLER = "Irrigation"
|
||||
|
||||
ROLE_GENERIC = "generic"
|
||||
|
||||
TRV_CHANNEL = 0
|
||||
|
||||
@@ -27,7 +27,7 @@ from .entity import (
|
||||
RpcEntityDescription,
|
||||
ShellyBlockAttributeEntity,
|
||||
ShellyRpcAttributeEntity,
|
||||
async_setup_entry_attribute_entities,
|
||||
async_setup_entry_block,
|
||||
async_setup_entry_rpc,
|
||||
rpc_call,
|
||||
)
|
||||
@@ -81,7 +81,7 @@ def _async_setup_block_entry(
|
||||
coordinator = config_entry.runtime_data.block
|
||||
assert coordinator
|
||||
|
||||
async_setup_entry_attribute_entities(
|
||||
async_setup_entry_block(
|
||||
hass, config_entry, async_add_entities, BLOCK_COVERS, BlockShellyCover
|
||||
)
|
||||
|
||||
|
||||
@@ -34,14 +34,14 @@ from .utils import (
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_entry_attribute_entities(
|
||||
def async_setup_entry_block(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ShellyConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
sensors: Mapping[tuple[str, str], BlockEntityDescription],
|
||||
sensor_class: Callable,
|
||||
) -> None:
|
||||
"""Set up entities for attributes."""
|
||||
"""Set up block entities."""
|
||||
coordinator = config_entry.runtime_data.block
|
||||
assert coordinator
|
||||
if coordinator.device.initialized:
|
||||
@@ -150,7 +150,7 @@ def async_setup_entry_rpc(
|
||||
sensors: Mapping[str, RpcEntityDescription],
|
||||
sensor_class: Callable,
|
||||
) -> None:
|
||||
"""Set up entities for RPC sensors."""
|
||||
"""Set up RPC entities."""
|
||||
coordinator = config_entry.runtime_data.rpc
|
||||
assert coordinator
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ from homeassistant.components.event import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
BASIC_INPUTS_EVENTS_TYPES,
|
||||
@@ -26,7 +25,7 @@ from .const import (
|
||||
SHIX3_1_INPUTS_EVENTS_TYPES,
|
||||
)
|
||||
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .entity import ShellyBlockEntity, get_entity_rpc_device_info
|
||||
from .entity import ShellyBlockEntity, ShellyRpcEntity
|
||||
from .utils import (
|
||||
async_remove_orphaned_entities,
|
||||
async_remove_shelly_entity,
|
||||
@@ -136,7 +135,7 @@ def _async_setup_rpc_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up entities for RPC device."""
|
||||
entities: list[ShellyRpcEvent] = []
|
||||
entities: list[ShellyRpcEvent | ShellyRpcScriptEvent] = []
|
||||
|
||||
coordinator = config_entry.runtime_data.rpc
|
||||
if TYPE_CHECKING:
|
||||
@@ -162,7 +161,9 @@ def _async_setup_rpc_entry(
|
||||
continue
|
||||
|
||||
if script_events and (event_types := script_events[get_rpc_key_id(script)]):
|
||||
entities.append(ShellyRpcScriptEvent(coordinator, script, event_types))
|
||||
entities.append(
|
||||
ShellyRpcScriptEvent(coordinator, script, SCRIPT_EVENT, event_types)
|
||||
)
|
||||
|
||||
# If a script is removed, from the device configuration, we need to remove orphaned entities
|
||||
async_remove_orphaned_entities(
|
||||
@@ -227,7 +228,7 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity):
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity):
|
||||
class ShellyRpcEvent(ShellyRpcEntity, EventEntity):
|
||||
"""Represent RPC event entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -240,25 +241,19 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity):
|
||||
description: ShellyRpcEventDescription,
|
||||
) -> None:
|
||||
"""Initialize Shelly entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = get_entity_rpc_device_info(coordinator, key)
|
||||
self._attr_unique_id = f"{coordinator.mac}-{key}"
|
||||
super().__init__(coordinator, key)
|
||||
self.entity_description = description
|
||||
|
||||
if description.key == "input":
|
||||
_, component, component_id = get_rpc_key(key)
|
||||
if custom_name := get_rpc_custom_name(coordinator.device, key):
|
||||
self._attr_name = custom_name
|
||||
else:
|
||||
self._attr_translation_placeholders = {
|
||||
"input_number": component_id
|
||||
if get_rpc_number_of_channels(coordinator.device, component) > 1
|
||||
else ""
|
||||
}
|
||||
self.event_id = int(component_id)
|
||||
elif description.key == "script":
|
||||
self._attr_name = get_rpc_custom_name(coordinator.device, key)
|
||||
self.event_id = get_rpc_key_id(key)
|
||||
_, component, component_id = get_rpc_key(key)
|
||||
if custom_name := get_rpc_custom_name(coordinator.device, key):
|
||||
self._attr_name = custom_name
|
||||
else:
|
||||
self._attr_translation_placeholders = {
|
||||
"input_number": component_id
|
||||
if get_rpc_number_of_channels(coordinator.device, component) > 1
|
||||
else ""
|
||||
}
|
||||
self.event_id = int(component_id)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
@@ -270,30 +265,36 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity):
|
||||
|
||||
@callback
|
||||
def _async_handle_event(self, event: dict[str, Any]) -> None:
|
||||
"""Handle the demo button event."""
|
||||
"""Handle the event."""
|
||||
if event["id"] == self.event_id:
|
||||
self._trigger_event(event["event"])
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class ShellyRpcScriptEvent(ShellyRpcEvent):
|
||||
class ShellyRpcScriptEvent(ShellyRpcEntity, EventEntity):
|
||||
"""Represent RPC script event entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: ShellyRpcEventDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyRpcCoordinator,
|
||||
key: str,
|
||||
description: ShellyRpcEventDescription,
|
||||
event_types: list[str],
|
||||
) -> None:
|
||||
"""Initialize Shelly script event entity."""
|
||||
super().__init__(coordinator, key, SCRIPT_EVENT)
|
||||
|
||||
self.component = key
|
||||
super().__init__(coordinator, key)
|
||||
self.entity_description = description
|
||||
self._attr_event_types = event_types
|
||||
|
||||
self._attr_name = get_rpc_custom_name(coordinator.device, key)
|
||||
self.event_id = get_rpc_key_id(key)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super(CoordinatorEntity, self).async_added_to_hass()
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_subscribe_events(self._async_handle_event)
|
||||
@@ -302,7 +303,7 @@ class ShellyRpcScriptEvent(ShellyRpcEvent):
|
||||
@callback
|
||||
def _async_handle_event(self, event: dict[str, Any]) -> None:
|
||||
"""Handle script event."""
|
||||
if event.get("component") == self.component:
|
||||
if event.get("component") == self.key:
|
||||
event_type = event.get("event")
|
||||
if event_type not in self.event_types:
|
||||
# This can happen if we didn't find this event type in the script
|
||||
|
||||
@@ -44,7 +44,7 @@ from .entity import (
|
||||
RpcEntityDescription,
|
||||
ShellyBlockAttributeEntity,
|
||||
ShellyRpcAttributeEntity,
|
||||
async_setup_entry_attribute_entities,
|
||||
async_setup_entry_block,
|
||||
async_setup_entry_rpc,
|
||||
)
|
||||
from .utils import (
|
||||
@@ -101,7 +101,7 @@ def _async_setup_block_entry(
|
||||
coordinator = config_entry.runtime_data.block
|
||||
assert coordinator
|
||||
|
||||
async_setup_entry_attribute_entities(
|
||||
async_setup_entry_block(
|
||||
hass, config_entry, async_add_entities, BLOCK_LIGHTS, BlockShellyLight
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioshelly==13.21.0"],
|
||||
"requirements": ["aioshelly==13.22.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "shelly*",
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
|
||||
from aioshelly.block_device import Block
|
||||
from aioshelly.const import RPC_GENERATIONS
|
||||
@@ -34,6 +34,7 @@ from .const import (
|
||||
MODEL_LINKEDGO_ST1820_THERMOSTAT,
|
||||
MODEL_TOP_EV_CHARGER_EVE01,
|
||||
ROLE_GENERIC,
|
||||
TRV_CHANNEL,
|
||||
VIRTUAL_NUMBER_MODE_MAP,
|
||||
)
|
||||
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
|
||||
@@ -42,7 +43,7 @@ from .entity import (
|
||||
RpcEntityDescription,
|
||||
ShellyRpcAttributeEntity,
|
||||
ShellySleepingBlockAttributeEntity,
|
||||
async_setup_entry_attribute_entities,
|
||||
async_setup_entry_block,
|
||||
async_setup_entry_rpc,
|
||||
rpc_call,
|
||||
)
|
||||
@@ -62,9 +63,6 @@ PARALLEL_UPDATES = 0
|
||||
class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription):
|
||||
"""Class to describe a BLOCK sensor."""
|
||||
|
||||
rest_path: str = ""
|
||||
rest_arg: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class RpcNumberDescription(RpcEntityDescription, NumberEntityDescription):
|
||||
@@ -178,7 +176,7 @@ class RpcBluTrvExtTempNumber(RpcBluTrvNumber):
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
|
||||
BLOCK_NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
|
||||
("device", "valvePos"): BlockNumberDescription(
|
||||
key="device|valvepos",
|
||||
translation_key="valve_position",
|
||||
@@ -189,8 +187,6 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
mode=NumberMode.SLIDER,
|
||||
rest_path="thermostat/0",
|
||||
rest_arg="pos",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -353,11 +349,11 @@ def _async_setup_block_entry(
|
||||
) -> None:
|
||||
"""Set up entities for BLOCK device."""
|
||||
if config_entry.data[CONF_SLEEP_PERIOD]:
|
||||
async_setup_entry_attribute_entities(
|
||||
async_setup_entry_block(
|
||||
hass,
|
||||
config_entry,
|
||||
async_add_entities,
|
||||
NUMBERS,
|
||||
BLOCK_NUMBERS,
|
||||
BlockSleepingNumber,
|
||||
)
|
||||
|
||||
@@ -426,18 +422,11 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set value."""
|
||||
# Example for Shelly Valve: http://192.168.188.187/thermostat/0?pos=13.0
|
||||
await self._set_state_full_path(
|
||||
self.entity_description.rest_path,
|
||||
{self.entity_description.rest_arg: value},
|
||||
LOGGER.debug(
|
||||
"Setting thermostat position for entity %s to %s", self.name, value
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _set_state_full_path(self, path: str, params: Any) -> Any:
|
||||
"""Set block state (HTTP request)."""
|
||||
LOGGER.debug("Setting state for entity %s, state: %s", self.name, params)
|
||||
try:
|
||||
return await self.coordinator.device.http_request("get", path, params)
|
||||
await self.coordinator.device.set_thermostat_state(TRV_CHANNEL, pos=value)
|
||||
except DeviceConnectionError as err:
|
||||
self.coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
@@ -450,3 +439,4 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
|
||||
) from err
|
||||
except InvalidAuthError:
|
||||
await self.coordinator.async_shutdown_device_and_start_reauth()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -53,7 +53,7 @@ from .entity import (
|
||||
ShellyRpcAttributeEntity,
|
||||
ShellySleepingBlockAttributeEntity,
|
||||
ShellySleepingRpcAttributeEntity,
|
||||
async_setup_entry_attribute_entities,
|
||||
async_setup_entry_block,
|
||||
async_setup_entry_rest,
|
||||
async_setup_entry_rpc,
|
||||
get_entity_rpc_device_info,
|
||||
@@ -198,7 +198,7 @@ class RpcBluTrvSensor(RpcSensor):
|
||||
)
|
||||
|
||||
|
||||
SENSORS: dict[tuple[str, str], BlockSensorDescription] = {
|
||||
BLOCK_SENSORS: dict[tuple[str, str], BlockSensorDescription] = {
|
||||
("device", "battery"): BlockSensorDescription(
|
||||
key="device|battery",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
@@ -525,7 +525,6 @@ RPC_SENSORS: Final = {
|
||||
"power_rgbcct": RpcSensorDescription(
|
||||
key="rgbcct",
|
||||
sub_key="apower",
|
||||
name="Power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -963,7 +962,6 @@ RPC_SENSORS: Final = {
|
||||
"energy_rgbcct": RpcSensorDescription(
|
||||
key="rgbcct",
|
||||
sub_key="aenergy",
|
||||
name="Energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value=lambda status, _: status["total"],
|
||||
@@ -1736,19 +1734,19 @@ def _async_setup_block_entry(
|
||||
) -> None:
|
||||
"""Set up entities for BLOCK device."""
|
||||
if config_entry.data[CONF_SLEEP_PERIOD]:
|
||||
async_setup_entry_attribute_entities(
|
||||
async_setup_entry_block(
|
||||
hass,
|
||||
config_entry,
|
||||
async_add_entities,
|
||||
SENSORS,
|
||||
BLOCK_SENSORS,
|
||||
BlockSleepingSensor,
|
||||
)
|
||||
else:
|
||||
async_setup_entry_attribute_entities(
|
||||
async_setup_entry_block(
|
||||
hass,
|
||||
config_entry,
|
||||
async_add_entities,
|
||||
SENSORS,
|
||||
BLOCK_SENSORS,
|
||||
BlockSensor,
|
||||
)
|
||||
async_setup_entry_rest(
|
||||
|
||||
@@ -36,7 +36,7 @@ from .entity import (
|
||||
ShellyBlockAttributeEntity,
|
||||
ShellyRpcAttributeEntity,
|
||||
ShellySleepingBlockAttributeEntity,
|
||||
async_setup_entry_attribute_entities,
|
||||
async_setup_entry_block,
|
||||
async_setup_entry_rpc,
|
||||
rpc_call,
|
||||
)
|
||||
@@ -337,11 +337,11 @@ def _async_setup_block_entry(
|
||||
coordinator = config_entry.runtime_data.block
|
||||
assert coordinator
|
||||
|
||||
async_setup_entry_attribute_entities(
|
||||
async_setup_entry_block(
|
||||
hass, config_entry, async_add_entities, BLOCK_RELAY_SWITCHES, BlockRelaySwitch
|
||||
)
|
||||
|
||||
async_setup_entry_attribute_entities(
|
||||
async_setup_entry_block(
|
||||
hass,
|
||||
config_entry,
|
||||
async_add_entities,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/sonos",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["soco", "sonos_websocket"],
|
||||
"quality_scale": "bronze",
|
||||
|
||||
@@ -33,9 +33,6 @@
|
||||
},
|
||||
"solar_elevation": {
|
||||
"default": "mdi:theme-light-dark"
|
||||
},
|
||||
"solar_rising": {
|
||||
"default": "mdi:sun-clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED
|
||||
@@ -100,13 +95,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=DEGREE,
|
||||
signal=SIGNAL_POSITION_CHANGED,
|
||||
),
|
||||
SunSensorEntityDescription(
|
||||
key="solar_rising",
|
||||
translation_key="solar_rising",
|
||||
value_fn=lambda data: data.rising,
|
||||
entity_registry_enabled_default=False,
|
||||
signal=SIGNAL_EVENTS_CHANGED,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -155,20 +143,6 @@ class SunSensor(SensorEntity):
|
||||
"""Register signal listener when added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if self.entity_description.key == "solar_rising":
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"deprecated_sun_solar_rising",
|
||||
breaks_in_ha_version="2026.1.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_sun_solar_rising",
|
||||
translation_placeholders={
|
||||
"entity": self.entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
@@ -176,9 +150,3 @@ class SunSensor(SensorEntity):
|
||||
self.async_write_ha_state,
|
||||
)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Call when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if self.entity_description.key == "solar_rising":
|
||||
async_delete_issue(self.hass, DOMAIN, "deprecated_sun_solar_rising")
|
||||
|
||||
@@ -24,8 +24,7 @@
|
||||
"next_rising": { "name": "Next rising" },
|
||||
"next_setting": { "name": "Next setting" },
|
||||
"solar_azimuth": { "name": "Solar azimuth" },
|
||||
"solar_elevation": { "name": "Solar elevation" },
|
||||
"solar_rising": { "name": "Solar rising" }
|
||||
"solar_elevation": { "name": "Solar elevation" }
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
@@ -37,11 +36,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_sun_solar_rising": {
|
||||
"description": "The 'Solar rising' sensor of the Sun integration is being deprecated; an equivalent 'Solar rising' binary sensor has been made available as a replacement. To resolve this issue, disable {entity}.",
|
||||
"title": "Deprecated 'Solar rising' sensor"
|
||||
}
|
||||
},
|
||||
"title": "Sun"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Coroutine
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -133,6 +134,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
entry, (entry.options["template_type"],)
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_labs_listen(
|
||||
hass,
|
||||
AUTOMATION_DOMAIN,
|
||||
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG,
|
||||
partial(hass.config_entries.async_schedule_reload, entry.entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.const import (
|
||||
CONF_ICON,
|
||||
CONF_ICON_TEMPLATE,
|
||||
CONF_NAME,
|
||||
CONF_PLATFORM,
|
||||
CONF_STATE,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
@@ -257,6 +258,7 @@ def create_legacy_template_issue(
|
||||
deprecation_list.append(issue_id)
|
||||
|
||||
try:
|
||||
config.pop(CONF_PLATFORM, None)
|
||||
modified_yaml = format_migration_config(config)
|
||||
yaml_config = yaml_util.dump({DOMAIN: [{domain: [modified_yaml]}]})
|
||||
# Format to show up properly in a numbered bullet on the repair.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@Bre77"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/tessie",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tessie", "tesla-fleet-api"],
|
||||
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.5"]
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"description": "Triggers when the text changes.",
|
||||
"description_configured": "[%key:component::text::triggers::changed::description%]",
|
||||
"name": "When the text changes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,20 +184,20 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
"""Last change triggered by."""
|
||||
if self._changed_by_wrapper is None:
|
||||
return None
|
||||
return self._changed_by_wrapper.read_device_status(self.device)
|
||||
return self._read_wrapper(self._changed_by_wrapper)
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send Disarm command."""
|
||||
await self._async_send_dpcode_update(self._mode_wrapper, "disarm")
|
||||
await self._async_send_wrapper_updates(self._mode_wrapper, "disarm")
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send Home command."""
|
||||
await self._async_send_dpcode_update(self._mode_wrapper, "arm_home")
|
||||
await self._async_send_wrapper_updates(self._mode_wrapper, "arm_home")
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send Arm command."""
|
||||
await self._async_send_dpcode_update(self._mode_wrapper, "arm_away")
|
||||
await self._async_send_wrapper_updates(self._mode_wrapper, "arm_away")
|
||||
|
||||
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
||||
"""Send SOS command."""
|
||||
await self._async_send_dpcode_update(self._mode_wrapper, "trigger")
|
||||
await self._async_send_wrapper_updates(self._mode_wrapper, "trigger")
|
||||
|
||||
@@ -372,7 +372,7 @@ class _CustomDPCodeWrapper(DPCodeWrapper):
|
||||
_valid_values: set[bool | float | int | str]
|
||||
|
||||
def __init__(
|
||||
self, dpcode: DPCode, valid_values: set[bool | float | int | str]
|
||||
self, dpcode: str, valid_values: set[bool | float | int | str]
|
||||
) -> None:
|
||||
"""Init CustomDPCodeBooleanWrapper."""
|
||||
super().__init__(dpcode)
|
||||
@@ -390,7 +390,7 @@ def _get_dpcode_wrapper(
|
||||
description: TuyaBinarySensorEntityDescription,
|
||||
) -> DPCodeWrapper | None:
|
||||
"""Get DPCode wrapper for an entity description."""
|
||||
dpcode = description.dpcode or DPCode(description.key)
|
||||
dpcode = description.dpcode or description.key
|
||||
if description.bitmap_key is not None:
|
||||
return DPCodeBitmapBitWrapper.find_dpcode(
|
||||
device, dpcode, bitmap_key=description.bitmap_key
|
||||
@@ -461,4 +461,4 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if sensor is on."""
|
||||
return self._dpcode_wrapper.read_device_status(self.device)
|
||||
return self._read_wrapper(self._dpcode_wrapper)
|
||||
|
||||
@@ -117,4 +117,4 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity):
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
|
||||
await self._async_send_wrapper_updates(self._dpcode_wrapper, True)
|
||||
|
||||
@@ -118,8 +118,8 @@ class TuyaCameraEntity(TuyaEntity, CameraEntity):
|
||||
|
||||
async def async_enable_motion_detection(self) -> None:
|
||||
"""Enable motion detection in the camera."""
|
||||
await self._async_send_dpcode_update(self._motion_detection_switch, True)
|
||||
await self._async_send_wrapper_updates(self._motion_detection_switch, True)
|
||||
|
||||
async def async_disable_motion_detection(self) -> None:
|
||||
"""Disable motion detection in camera."""
|
||||
await self._async_send_dpcode_update(self._motion_detection_switch, False)
|
||||
await self._async_send_wrapper_updates(self._motion_detection_switch, False)
|
||||
|
||||
@@ -345,14 +345,14 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
"""Set new target hvac mode."""
|
||||
commands = []
|
||||
if self._switch_wrapper:
|
||||
commands.append(
|
||||
self._switch_wrapper.get_update_command(
|
||||
commands.extend(
|
||||
self._switch_wrapper.get_update_commands(
|
||||
self.device, hvac_mode != HVACMode.OFF
|
||||
)
|
||||
)
|
||||
if self._hvac_mode_wrapper and hvac_mode in self._hvac_to_tuya:
|
||||
commands.append(
|
||||
self._hvac_mode_wrapper.get_update_command(
|
||||
commands.extend(
|
||||
self._hvac_mode_wrapper.get_update_commands(
|
||||
self.device, self._hvac_to_tuya[hvac_mode]
|
||||
)
|
||||
)
|
||||
@@ -360,34 +360,34 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target preset mode."""
|
||||
await self._async_send_dpcode_update(self._hvac_mode_wrapper, preset_mode)
|
||||
await self._async_send_wrapper_updates(self._hvac_mode_wrapper, preset_mode)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await self._async_send_dpcode_update(self._fan_mode_wrapper, fan_mode)
|
||||
await self._async_send_wrapper_updates(self._fan_mode_wrapper, fan_mode)
|
||||
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Set new target humidity."""
|
||||
await self._async_send_dpcode_update(self._target_humidity_wrapper, humidity)
|
||||
await self._async_send_wrapper_updates(self._target_humidity_wrapper, humidity)
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set new target swing operation."""
|
||||
commands = []
|
||||
if self._swing_wrapper:
|
||||
commands.append(
|
||||
self._swing_wrapper.get_update_command(
|
||||
commands.extend(
|
||||
self._swing_wrapper.get_update_commands(
|
||||
self.device, swing_mode == SWING_ON
|
||||
)
|
||||
)
|
||||
if self._swing_v_wrapper:
|
||||
commands.append(
|
||||
self._swing_v_wrapper.get_update_command(
|
||||
commands.extend(
|
||||
self._swing_v_wrapper.get_update_commands(
|
||||
self.device, swing_mode in (SWING_BOTH, SWING_VERTICAL)
|
||||
)
|
||||
)
|
||||
if self._swing_h_wrapper:
|
||||
commands.append(
|
||||
self._swing_h_wrapper.get_update_command(
|
||||
commands.extend(
|
||||
self._swing_h_wrapper.get_update_commands(
|
||||
self.device, swing_mode in (SWING_BOTH, SWING_HORIZONTAL)
|
||||
)
|
||||
)
|
||||
@@ -396,7 +396,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
await self._async_send_dpcode_update(
|
||||
await self._async_send_wrapper_updates(
|
||||
self._set_temperature, kwargs[ATTR_TEMPERATURE]
|
||||
)
|
||||
|
||||
@@ -475,8 +475,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the device on, retaining current HVAC (if supported)."""
|
||||
await self._async_send_dpcode_update(self._switch_wrapper, True)
|
||||
await self._async_send_wrapper_updates(self._switch_wrapper, True)
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the device on, retaining current HVAC (if supported)."""
|
||||
await self._async_send_dpcode_update(self._switch_wrapper, False)
|
||||
await self._async_send_wrapper_updates(self._switch_wrapper, False)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user