Compare commits

..

78 Commits

Author SHA1 Message Date
abmantis d6caa86ab3 Add support for trigger descriptions with relative keys 2025-08-03 16:25:36 +01:00
Andrew Jackson 4318e29ce8 Bump aiomealie to 0.10.1 (#149890) 2025-08-03 14:18:13 +02:00
Martin Hjelmare fea5c63bba Fix Z-Wave handling of driver ready event (#149879) 2025-08-03 11:23:01 +02:00
Åke Strandberg b2349ac2bd Improve miele climate test coverage (#149859) 2025-08-03 11:19:08 +02:00
Marc Mueller 08f7b708a4 Update pytest warnings filter (#149839) 2025-08-03 09:25:17 +02:00
Martin Hjelmare 1236801b7d Fix Z-Wave config entry state conditions in listen task (#149841) 2025-08-02 23:07:16 +02:00
Thomas D 72d9dbf39d Add scopes in config flow auth request for Volvo integration (#149813) 2025-08-02 22:17:13 +02:00
Thomas D 755864f9f3 Add sensor platform to Qbus integration (#149389)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-08-02 20:01:58 +02:00
peteS-UK fa476d4e34 Fix initialisation of Apps and Radios list for Squeezebox (#149834) 2025-08-02 20:01:02 +02:00
Manu 018197e41a Add notifiers to send direct messages to friends in PlayStation Network (#149844) 2025-08-02 19:55:45 +02:00
Brett Adams 7dd2b9e422 Make history coordinator more reliable in Tesla Fleet (#149854) 2025-08-02 19:54:19 +02:00
hahn-th 3e615fd373 Improve code quality for garage door modules in homematicip_cloud (#149856) 2025-08-02 19:51:08 +02:00
Oliver c0bf167e10 Update denonavr to 1.1.2 (#149842) 2025-08-02 19:44:01 +02:00
Andrea Turri 45f6778ff4 Fix Miele hob translation keys (#149865) 2025-08-02 18:37:57 +02:00
Jamin bddd4d621a Bump VoIP utils to 0.3.4 (#149786) 2025-08-01 20:37:45 +01:00
Norbert Rittel b0e75e9ee4 Update reference for volatile_organic_compounds_parts in template (#149831) 2025-08-01 20:36:10 +01:00
Norbert Rittel d45c03a795 Update reference for volatile_organic_compounds_parts in random (#149832) 2025-08-01 20:35:04 +01:00
Norbert Rittel 8562c8d32f Add translations for recently introduced device classes to scrape (#149822) 2025-08-01 20:34:31 +01:00
Norbert Rittel ae42d71123 Add translations for recently introduced device classes to sql (#149821) 2025-08-01 20:33:47 +01:00
Alexandre CUER 9616c8cd7b Bump pyemoncms to 0.1.2 (#149825) 2025-08-01 20:04:16 +01:00
kizovinh 9394546668 Add EZVIZ battery camera power status and online status sensor (#146822) 2025-08-01 20:00:53 +01:00
Norbert Rittel d43f21c2e2 Fix descriptions for template number fields (#149804) 2025-08-01 20:35:48 +02:00
Norbert Rittel 8d68fee9f8 Add translation for absolute_humidity device class to template (#149814) 2025-08-01 18:30:59 +01:00
Willem-Jan van Rootselaar b4a4e218ec Add re-authentication to BSBLan (#146280)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-08-01 16:42:59 +02:00
Norbert Rittel fb2d62d692 Add translation for absolute_humidity device class to mqtt (#149818) 2025-08-01 15:57:47 +02:00
Erik Montnemery f538807d6e Make device suggested_area only influence new devices (#149758)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-08-01 14:54:58 +02:00
Joost Lekkerkerker a08c3c9f44 Improve Tado binary sensor tests (#149807) 2025-08-01 14:38:12 +02:00
Joost Lekkerkerker 506431c75f Improve Tado water heater tests (#149806) 2025-08-01 14:38:02 +02:00
Joost Lekkerkerker 37579440e6 Improve Tado climate tests (#149808) 2025-08-01 14:37:12 +02:00
Joost Lekkerkerker 5ce2729dc2 Improve Tado sensor tests (#149809) 2025-08-01 14:36:57 +02:00
Joost Lekkerkerker b5e4ae4a53 Improve Tado switch tests (#149810)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-01 14:36:37 +02:00
Norbert Rittel 3d4386ea6d Add translation for absolute_humidity device class to random (#149815) 2025-08-01 14:32:14 +02:00
Alexandre CUER 9f1cec893e emoncms - fix missing data descriptions (#149733) 2025-08-01 13:22:46 +02:00
starkillerOG bc87140a6f Update after Motion Blinds tilt change (#149779) 2025-08-01 11:15:49 +02:00
Erik Montnemery d77a3fca83 Exclude is_new from DeviceEntry snapshots (#149801) 2025-08-01 11:01:26 +02:00
Joakim Sørensen 924a86dfb6 Add nameservers to supervisor system health response (#149749) 2025-08-01 10:51:48 +02:00
Erik Montnemery 0d7608f7c5 Deprecate DeviceEntry.suggested_area (#149730) 2025-08-01 10:34:34 +02:00
Tom 22e054f4cd Add diagnostics to UISP AirOS (#149631) 2025-08-01 09:24:22 +02:00
epenet 8b53b26333 Fix tuya light supported color modes (#149793)
Co-authored-by: Erik <erik@montnemery.com>
2025-08-01 09:13:53 +02:00
Erik Montnemery 4d59e8cd80 Fix flaky velbus test (#149743) 2025-08-01 07:49:51 +02:00
Fabian Leutgeb 61396d92a5 Homekit valve duration characteristics (#149698)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-07-31 15:21:48 -10:00
Philippe Lafoucrière c72c600de4 Fix bootstrap script path resolution (#149721) 2025-07-31 23:47:25 +01:00
J. Nick Koston b86b0c10bd Bump aioesphomeapi to 37.2.2 (#149755) 2025-07-31 12:23:24 -10:00
starkillerOG eb222f6c5d Bump motionblinds to 0.6.30 (#149764) 2025-08-01 01:09:20 +03:00
Manu 4b5fe424ed Hide configuration URL when Uptime Kuma is installed locally (#149781) 2025-08-01 01:07:56 +03:00
Nathan Spencer 61ca42e923 Bump pylitterbot to 2024.2.3 (#149763) 2025-07-31 21:04:23 +02:00
Copilot 21c1427abf Fix ZHA ContextVar deprecation by passing config_entry (#149748)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joostlek <7083755+joostlek@users.noreply.github.com>
Co-authored-by: puddly <32534428+puddly@users.noreply.github.com>
Co-authored-by: TheJulianJES <6409465+TheJulianJES@users.noreply.github.com>
2025-07-31 14:52:17 -04:00
karwosts aa6b37bc7c Fix add_suggested_values_to_schema when the schema has sections (#149718)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-07-31 20:50:26 +02:00
Marc Mueller bbc1466cfc Update rpds-py to 0.26.0 (#149753) 2025-07-31 17:51:10 +01:00
Bram Kragten 21a9799060 Update frontend to 20250731.0 (#149757) 2025-07-31 18:46:10 +02:00
Erik Montnemery f7d54b46ec Improve test of FlowHandler.add_suggested_values_to_schema (#149759) 2025-07-31 17:55:15 +02:00
Erik Montnemery 6ad1b8dcb1 Fix kitchen_sink option flow (#149760) 2025-07-31 17:49:09 +02:00
Abílio Costa 5f6b1212a3 Remove data flow step_id deprecation note (#149714) 2025-07-31 16:04:09 +02:00
dependabot[bot] 58dc6a952e Bump home-assistant/wheels from 2025.03.0 to 2025.07.0 (#149741) 2025-07-31 15:35:55 +02:00
Petro31 59d8df142d Nitpick default translations for template integration (#149740) 2025-07-31 15:19:43 +02:00
Petro31 04fb86b4ba Fix unique_id in config validation for legacy weather platform (#149742) 2025-07-31 15:19:37 +02:00
Erik Montnemery 3d744f032f Make _EventDeviceRegistryUpdatedData_Remove JSON serializable (#149734) 2025-07-31 12:35:13 +02:00
J. Nick Koston f7c8cdb3a7 Bump aioesphomeapi to 37.2.0 (#149732) 2025-07-31 12:10:23 +02:00
Copilot 3952544822 Fix ContextVar deprecation warning in homeassistant_hardware integration (#149687)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joostlek <7083755+joostlek@users.noreply.github.com>
Co-authored-by: mib1185 <35783820+mib1185@users.noreply.github.com>
2025-07-31 12:06:04 +02:00
Erik Montnemery 42101dd432 Remove result from FlowResult (#149202) 2025-07-31 10:58:36 +02:00
L. f7eacaa48d Bump xiaomi-ble to 1.2.0 (#149711) 2025-07-31 09:01:06 +02:00
johanzander ad0db5c83a Update growattServer to version 1.7.1 (#149716) 2025-07-31 08:17:33 +02:00
J. Nick Koston 63216b77c2 Bump aioesphomeapi to 37.1.6 (#149715) 2025-07-30 13:54:18 -10:00
Åke Strandberg 7a55373b0b Fix bug when interpreting miele action response (#149710) 2025-07-31 01:07:12 +02:00
J. Nick Koston f9e7459901 Fix ESPHome unnecessary probing on DHCP discovery (#149713) 2025-07-31 01:06:08 +02:00
starkillerOG 94dc2e2ea3 Bump reolink-aio to 0.14.5 (#149700) 2025-07-30 22:54:32 +01:00
Åke Strandberg 2cf144fb25 Add missing translations for miele dishwasher (#149702) 2025-07-30 22:45:05 +01:00
Jan Bouwhuis f318766021 Fix inconsistent use of the term 'target' and a typo in MQTT translation strings (#149703) 2025-07-30 22:42:53 +01:00
Andrea Turri ec7fb140ac Fix Miele induction hob empty state (#149706) 2025-07-30 22:38:11 +01:00
Petro31 2706c7d67d Add translations for all fields in template integration (#149692)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-07-30 22:30:05 +01:00
Roman Sivriver b4e50902eb Fix typo in backup log message (#149705) 2025-07-30 22:29:26 +01:00
Åke Strandberg 1ead01bc9a Explicitly pass config_entry to miele coordinator (#149691) 2025-07-30 20:19:01 +02:00
puddly 389a1251a1 Bump ZHA to 0.0.64 (#149683)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
Co-authored-by: abmantis <amfcalt@gmail.com>
2025-07-30 18:59:41 +01:00
Manu 8d27ca1e21 Fix KeyError in friends coordinator (#149684) 2025-07-30 19:59:01 +02:00
Michael Hansen a76af50c10 Bump intents to 2025.7.30 (#149678) 2025-07-30 19:57:59 +02:00
Renat Sibgatulin 09b91bd76a Clean airq tests (#149682) 2025-07-30 18:48:36 +01:00
Jan Bouwhuis 736d582d04 Fix translation string reference for MQTT climate subentry option (#149673) 2025-07-30 18:53:21 +02:00
Bram Kragten 8114df4219 Bump version to 2025.9.0 (#149680) 2025-07-30 18:36:20 +02:00
260 changed files with 8860 additions and 2577 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 4
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.8"
HA_SHORT_VERSION: "2025.9"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version
+2 -2
View File
@@ -159,7 +159,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@2025.03.0
uses: home-assistant/wheels@2025.07.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -219,7 +219,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@2025.03.0
uses: home-assistant/wheels@2025.07.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
+4 -1
View File
@@ -33,7 +33,10 @@ class AuthFlowContext(FlowContext, total=False):
redirect_uri: str
AuthFlowResult = FlowResult[AuthFlowContext, tuple[str, str]]
class AuthFlowResult(FlowResult[AuthFlowContext, tuple[str, str]], total=False):
"""Typed result dict for auth flow."""
result: Credentials # Only present if type is CREATE_ENTRY
@attr.s(slots=True)
@@ -0,0 +1,33 @@
"""Diagnostics support for airOS."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .coordinator import AirOSConfigEntry
IP_REDACT = ["addr", "ipaddr", "ip6addr", "lastip"] # IP related
HW_REDACT = ["apmac", "hwaddr", "mac"] # MAC address
TO_REDACT_HA = [CONF_HOST, CONF_PASSWORD]
TO_REDACT_AIROS = [
"hostname", # Prevent leaking device naming
"essid", # Network SSID
"lat", # GPS latitude to prevent exposing location data.
"lon", # GPS longitude to prevent exposing location data.
*HW_REDACT,
*IP_REDACT,
]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AirOSConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT_HA),
"data": async_redact_data(entry.runtime_data.data.to_dict(), TO_REDACT_AIROS),
}
@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info: todo
discovery: todo
docs-data-update: done
@@ -430,7 +430,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
"model": device.model,
"sw_version": device.sw_version,
"hw_version": device.hw_version,
"has_suggested_area": device.suggested_area is not None,
"has_configuration_url": device.configuration_url is not None,
"via_device": None,
}
+3 -2
View File
@@ -268,7 +268,7 @@ class LoginFlowBaseView(HomeAssistantView):
result.pop("data")
result.pop("context")
result_obj: Credentials = result.pop("result")
result_obj = result.pop("result")
# Result can be None if credential was never linked to a user before.
user = await hass.auth.async_get_user_by_credentials(result_obj)
@@ -281,7 +281,8 @@ class LoginFlowBaseView(HomeAssistantView):
)
process_success_login(request)
result["result"] = self._store_result(client_id, result_obj)
# We overwrite the Credentials object with the string code to retrieve it.
result["result"] = self._store_result(client_id, result_obj) # type: ignore[typeddict-item]
return self.json(result)
+167 -20
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from bsblan import BSBLAN, BSBLANConfig, BSBLANError
from bsblan import BSBLAN, BSBLANAuthError, BSBLANConfig, BSBLANError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -45,7 +46,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
self.username = user_input.get(CONF_USERNAME)
self.password = user_input.get(CONF_PASSWORD)
return await self._validate_and_create()
return await self._validate_and_create(user_input)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
@@ -128,14 +129,29 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
self.username = user_input.get(CONF_USERNAME)
self.password = user_input.get(CONF_PASSWORD)
return await self._validate_and_create(is_discovery=True)
return await self._validate_and_create(user_input, is_discovery=True)
async def _validate_and_create(
self, is_discovery: bool = False
self, user_input: dict[str, Any], is_discovery: bool = False
) -> ConfigFlowResult:
"""Validate device connection and create entry."""
try:
await self._get_bsblan_info(is_discovery=is_discovery)
await self._get_bsblan_info()
except BSBLANAuthError:
if is_discovery:
return self.async_show_form(
step_id="discovery_confirm",
data_schema=vol.Schema(
{
vol.Optional(CONF_PASSKEY): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
}
),
errors={"base": "invalid_auth"},
description_placeholders={"host": str(self.host)},
)
return self._show_setup_form({"base": "invalid_auth"}, user_input)
except BSBLANError:
if is_discovery:
return self.async_show_form(
@@ -154,18 +170,145 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
return self._async_create_entry()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth flow."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirmation flow."""
existing_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
assert existing_entry
if user_input is None:
# Preserve existing values as defaults
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=existing_entry.data.get(
CONF_PASSKEY, vol.UNDEFINED
),
): str,
vol.Optional(
CONF_USERNAME,
default=existing_entry.data.get(
CONF_USERNAME, vol.UNDEFINED
),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
)
# Use existing host and port, update auth credentials
self.host = existing_entry.data[CONF_HOST]
self.port = existing_entry.data[CONF_PORT]
self.passkey = user_input.get(CONF_PASSKEY) or existing_entry.data.get(
CONF_PASSKEY
)
self.username = user_input.get(CONF_USERNAME) or existing_entry.data.get(
CONF_USERNAME
)
self.password = user_input.get(CONF_PASSWORD)
try:
await self._get_bsblan_info(raise_on_progress=False, is_reauth=True)
except BSBLANAuthError:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
): str,
vol.Optional(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
errors={"base": "invalid_auth"},
)
except BSBLANError:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
): str,
vol.Optional(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
errors={"base": "cannot_connect"},
)
# Update the config entry with new auth data
data_updates = {}
if self.passkey is not None:
data_updates[CONF_PASSKEY] = self.passkey
if self.username is not None:
data_updates[CONF_USERNAME] = self.username
if self.password is not None:
data_updates[CONF_PASSWORD] = self.password
return self.async_update_reload_and_abort(
existing_entry, data_updates=data_updates, reason="reauth_successful"
)
@callback
def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult:
def _show_setup_form(
self, errors: dict | None = None, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the setup form to the user."""
# Preserve user input if provided, otherwise use defaults
defaults = user_input or {}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
vol.Optional(CONF_PASSKEY): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
vol.Required(
CONF_HOST, default=defaults.get(CONF_HOST, vol.UNDEFINED)
): str,
vol.Optional(
CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)
): int,
vol.Optional(
CONF_PASSKEY, default=defaults.get(CONF_PASSKEY, vol.UNDEFINED)
): str,
vol.Optional(
CONF_USERNAME,
default=defaults.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=defaults.get(CONF_PASSWORD, vol.UNDEFINED),
): str,
}
),
errors=errors or {},
@@ -186,7 +329,9 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def _get_bsblan_info(
self, raise_on_progress: bool = True, is_discovery: bool = False
self,
raise_on_progress: bool = True,
is_reauth: bool = False,
) -> None:
"""Get device information from a BSBLAN device."""
config = BSBLANConfig(
@@ -209,11 +354,13 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
format_mac(self.mac), raise_on_progress=raise_on_progress
)
# Always allow updating host/port for both user and discovery flows
# This ensures connectivity is maintained when devices change IP addresses
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self.host,
CONF_PORT: self.port,
}
)
# Skip unique_id configuration check during reauth to prevent "already_configured" abort
if not is_reauth:
# Always allow updating host/port for both user and discovery flows
# This ensures connectivity is maintained when devices change IP addresses
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self.host,
CONF_PORT: self.port,
}
)
+13 -1
View File
@@ -4,11 +4,19 @@ from dataclasses import dataclass
from datetime import timedelta
from random import randint
from bsblan import BSBLAN, BSBLANConnectionError, HotWaterState, Sensor, State
from bsblan import (
BSBLAN,
BSBLANAuthError,
BSBLANConnectionError,
HotWaterState,
Sensor,
State,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
@@ -62,6 +70,10 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
state = await self.client.state()
sensor = await self.client.sensor()
dhw = await self.client.hot_water_state()
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
"Authentication failed for BSB-Lan device"
) from err
except BSBLANConnectionError as err:
host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown"
raise UpdateFailed(
+13 -2
View File
@@ -33,14 +33,25 @@
"username": "[%key:component::bsblan::config::step::user::data_description::username%]",
"password": "[%key:component::bsblan::config::step::user::data_description::password%]"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The BSB-Lan integration needs to re-authenticate with {name}",
"data": {
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"exceptions": {
@@ -146,8 +146,9 @@ def _prepare_config_flow_result_json(
return prepare_result_json(result)
data = result.copy()
entry: config_entries.ConfigEntry = data["result"]
data["result"] = entry.as_json_fragment
entry: config_entries.ConfigEntry = data["result"] # type: ignore[typeddict-item]
# We overwrite the ConfigEntry object with its json representation.
data["result"] = entry.as_json_fragment # type: ignore[typeddict-unknown-key]
data.pop("data")
data.pop("context")
return data
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/denonavr",
"iot_class": "local_push",
"loggers": ["denonavr"],
"requirements": ["denonavr==1.1.1"],
"requirements": ["denonavr==1.1.2"],
"ssdp": [
{
"manufacturer": "Denon",
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/emoncms",
"iot_class": "local_polling",
"requirements": ["pyemoncms==0.1.1"]
"requirements": ["pyemoncms==0.1.2"]
}
+20 -3
View File
@@ -12,12 +12,26 @@
},
"data_description": {
"url": "Server URL starting with the protocol (http or https)",
"api_key": "Your 32 bits API key"
"api_key": "Your 32 bits API key",
"sync_mode": "Pick your feeds manually (default) or synchronize them at once"
}
},
"choose_feeds": {
"data": {
"include_only_feed_id": "Choose feeds to include"
},
"data_description": {
"include_only_feed_id": "Pick the feeds you want to synchronize"
}
},
"reconfigure": {
"data": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"url": "[%key:component::emoncms::config::step::user::data_description::url%]",
"api_key": "[%key:component::emoncms::config::step::user::data_description::api_key%]"
}
}
},
@@ -30,8 +44,8 @@
"selector": {
"sync_mode": {
"options": {
"auto": "Synchronize all available Feeds",
"manual": "Select which Feeds to synchronize"
"auto": "Synchronize all available feeds",
"manual": "Select which feeds to synchronize"
}
}
},
@@ -89,6 +103,9 @@
"init": {
"data": {
"include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data::include_only_feed_id%]"
},
"data_description": {
"include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data_description::include_only_feed_id%]"
}
}
}
@@ -116,6 +116,9 @@ async def async_get_config_entry_diagnostics(
entities.append({"entity": entity_dict, "state": state_dict})
device_dict = asdict(device)
device_dict.pop("_cache", None)
# This can be removed when suggested_area is removed from DeviceEntry
device_dict.pop("_suggested_area")
device_dict.pop("is_new", None)
device_entities.append({"device": device_dict, "entities": entities})
# remove envoy serial
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==37.2.0",
"aioesphomeapi==37.2.2",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.1.0"
],
+38 -8
View File
@@ -66,6 +66,26 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
key="last_alarm_type_name",
translation_key="last_alarm_type_name",
),
"Record_Mode": SensorEntityDescription(
key="Record_Mode",
translation_key="record_mode",
entity_registry_enabled_default=False,
),
"battery_camera_work_mode": SensorEntityDescription(
key="battery_camera_work_mode",
translation_key="battery_camera_work_mode",
entity_registry_enabled_default=False,
),
"powerStatus": SensorEntityDescription(
key="powerStatus",
translation_key="power_status",
entity_registry_enabled_default=False,
),
"OnlineStatus": SensorEntityDescription(
key="OnlineStatus",
translation_key="online_status",
entity_registry_enabled_default=False,
),
}
@@ -76,16 +96,26 @@ async def async_setup_entry(
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
coordinator = entry.runtime_data
entities: list[EzvizSensor] = []
async_add_entities(
[
for camera, sensors in coordinator.data.items():
entities.extend(
EzvizSensor(coordinator, camera, sensor)
for camera in coordinator.data
for sensor, value in coordinator.data[camera].items()
if sensor in SENSOR_TYPES
if value is not None
]
)
for sensor, value in sensors.items()
if sensor in SENSOR_TYPES and value is not None
)
optionals = sensors.get("optionals", {})
entities.extend(
EzvizSensor(coordinator, camera, optional_key)
for optional_key in ("powerStatus", "OnlineStatus")
if optional_key in optionals
)
if "mode" in optionals.get("Record_Mode", {}):
entities.append(EzvizSensor(coordinator, camera, "mode"))
async_add_entities(entities)
class EzvizSensor(EzvizEntity, SensorEntity):
@@ -147,6 +147,18 @@
},
"last_alarm_type_name": {
"name": "Last alarm type name"
},
"record_mode": {
"name": "Record mode"
},
"battery_camera_work_mode": {
"name": "Battery work mode"
},
"power_status": {
"name": "Power status"
},
"online_status": {
"name": "Online status"
}
},
"switch": {
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/growatt_server",
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"requirements": ["growattServer==1.6.0"]
"requirements": ["growattServer==1.7.1"]
}
@@ -9,6 +9,7 @@
"healthy": "Healthy",
"host_os": "Host operating system",
"installed_addons": "Installed add-ons",
"nameservers": "Nameservers",
"supervisor_api": "Supervisor API",
"supervisor_version": "Supervisor version",
"supported": "Supported",
@@ -54,6 +54,15 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"error": "Unsupported",
}
nameservers = set()
for interface in network_info.get("interfaces", []):
if not interface.get("primary"):
continue
if ipv4 := interface.get("ipv4"):
nameservers.update(ipv4.get("nameservers", []))
if ipv6 := interface.get("ipv6"):
nameservers.update(ipv6.get("nameservers", []))
information = {
"host_os": host_info.get("operating_system"),
"update_channel": info.get("channel"),
@@ -62,6 +71,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"docker_version": info.get("docker"),
"disk_total": f"{host_info.get('disk_total')} GB",
"disk_used": f"{host_info.get('disk_used')} GB",
"nameservers": ", ".join(nameservers),
"healthy": healthy,
"supported": supported,
"host_connectivity": network_info.get("host_internet"),
@@ -628,12 +628,12 @@ class HomeAccessory(Accessory): # type: ignore[misc]
self,
domain: str,
service: str,
service_data: dict[str, Any] | None,
service_data: dict[str, Any],
value: Any | None = None,
) -> None:
"""Fire event and call service for changes from HomeKit."""
event_data = {
ATTR_ENTITY_ID: self.entity_id,
ATTR_ENTITY_ID: service_data.get(ATTR_ENTITY_ID, self.entity_id),
ATTR_DISPLAY_NAME: self.display_name,
ATTR_SERVICE: service,
ATTR_VALUE: value,
@@ -57,6 +57,8 @@ CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor"
CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor"
CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor"
CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor"
CONF_LINKED_VALVE_DURATION = "linked_valve_duration"
CONF_LINKED_VALVE_END_TIME = "linked_valve_end_time"
CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold"
CONF_MAX_FPS = "max_fps"
CONF_MAX_HEIGHT = "max_height"
@@ -229,10 +231,12 @@ CHAR_ON = "On"
CHAR_OUTLET_IN_USE = "OutletInUse"
CHAR_POSITION_STATE = "PositionState"
CHAR_PROGRAMMABLE_SWITCH_EVENT = "ProgrammableSwitchEvent"
CHAR_REMAINING_DURATION = "RemainingDuration"
CHAR_REMOTE_KEY = "RemoteKey"
CHAR_ROTATION_DIRECTION = "RotationDirection"
CHAR_ROTATION_SPEED = "RotationSpeed"
CHAR_SATURATION = "Saturation"
CHAR_SET_DURATION = "SetDuration"
CHAR_SERIAL_NUMBER = "SerialNumber"
CHAR_SERVICE_LABEL_INDEX = "ServiceLabelIndex"
CHAR_SERVICE_LABEL_NAMESPACE = "ServiceLabelNamespace"
@@ -15,6 +15,11 @@ from pyhap.const import (
)
from homeassistant.components import button, input_button
from homeassistant.components.input_number import (
ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE,
DOMAIN as INPUT_NUMBER_DOMAIN,
SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE,
)
from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION
from homeassistant.components.lawn_mower import (
DOMAIN as LAWN_MOWER_DOMAIN,
@@ -45,6 +50,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, State, callback, split_entity_id
from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util
from .accessories import TYPES, HomeAccessory, HomeDriver
from .const import (
@@ -54,7 +60,11 @@ from .const import (
CHAR_NAME,
CHAR_ON,
CHAR_OUTLET_IN_USE,
CHAR_REMAINING_DURATION,
CHAR_SET_DURATION,
CHAR_VALVE_TYPE,
CONF_LINKED_VALVE_DURATION,
CONF_LINKED_VALVE_END_TIME,
SERV_OUTLET,
SERV_SWITCH,
SERV_VALVE,
@@ -271,7 +281,21 @@ class ValveBase(HomeAccessory):
self.on_service = on_service
self.off_service = off_service
serv_valve = self.add_preload_service(SERV_VALVE)
self.chars = []
self.linked_duration_entity: str | None = self.config.get(
CONF_LINKED_VALVE_DURATION
)
self.linked_end_time_entity: str | None = self.config.get(
CONF_LINKED_VALVE_END_TIME
)
if self.linked_duration_entity:
self.chars.append(CHAR_SET_DURATION)
if self.linked_end_time_entity:
self.chars.append(CHAR_REMAINING_DURATION)
serv_valve = self.add_preload_service(SERV_VALVE, self.chars)
self.char_active = serv_valve.configure_char(
CHAR_ACTIVE, value=False, setter_callback=self.set_state
)
@@ -279,6 +303,25 @@ class ValveBase(HomeAccessory):
self.char_valve_type = serv_valve.configure_char(
CHAR_VALVE_TYPE, value=VALVE_TYPE[valve_type].valve_type
)
if CHAR_SET_DURATION in self.chars:
_LOGGER.debug(
"%s: Add characteristic %s", self.entity_id, CHAR_SET_DURATION
)
self.char_set_duration = serv_valve.configure_char(
CHAR_SET_DURATION,
value=self.get_duration(),
setter_callback=self.set_duration,
)
if CHAR_REMAINING_DURATION in self.chars:
_LOGGER.debug(
"%s: Add characteristic %s", self.entity_id, CHAR_REMAINING_DURATION
)
self.char_remaining_duration = serv_valve.configure_char(
CHAR_REMAINING_DURATION, getter_callback=self.get_remaining_duration
)
# Set the state so it is in sync on initial
# GET to avoid an event storm after homekit startup
self.async_update_state(state)
@@ -294,12 +337,75 @@ class ValveBase(HomeAccessory):
@callback
def async_update_state(self, new_state: State) -> None:
"""Update switch state after state changed."""
self._update_duration_chars()
current_state = 1 if new_state.state in self.open_states else 0
_LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state)
self.char_active.set_value(current_state)
_LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state)
self.char_in_use.set_value(current_state)
def _update_duration_chars(self) -> None:
"""Update valve duration related properties if characteristics are available."""
if CHAR_SET_DURATION in self.chars:
self.char_set_duration.set_value(self.get_duration())
if CHAR_REMAINING_DURATION in self.chars:
self.char_remaining_duration.set_value(self.get_remaining_duration())
def set_duration(self, value: int) -> None:
"""Set default duration for how long the valve should remain open."""
_LOGGER.debug("%s: Set default run time to %s", self.entity_id, value)
self.async_call_service(
INPUT_NUMBER_DOMAIN,
INPUT_NUMBER_SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: self.linked_duration_entity,
INPUT_NUMBER_ATTR_VALUE: value,
},
value,
)
def get_duration(self) -> int:
"""Get the default duration from Home Assistant."""
duration_state = self._get_entity_state(self.linked_duration_entity)
if duration_state is None:
_LOGGER.debug(
"%s: No linked duration entity state available", self.entity_id
)
return 0
try:
duration = float(duration_state)
return max(int(duration), 0)
except ValueError:
_LOGGER.debug("%s: Cannot parse linked duration entity", self.entity_id)
return 0
def get_remaining_duration(self) -> int:
"""Calculate the remaining duration based on end time in Home Assistant."""
end_time_state = self._get_entity_state(self.linked_end_time_entity)
if end_time_state is None:
_LOGGER.debug(
"%s: No linked end time entity state available", self.entity_id
)
return self.get_duration()
end_time = dt_util.parse_datetime(end_time_state)
if end_time is None:
_LOGGER.debug("%s: Cannot parse linked end time entity", self.entity_id)
return self.get_duration()
remaining_time = (end_time - dt_util.utcnow()).total_seconds()
return max(int(remaining_time), 0)
def _get_entity_state(self, entity_id: str | None) -> str | None:
"""Fetch the state of a linked entity."""
if entity_id is None:
return None
state = self.hass.states.get(entity_id)
if state is None:
return None
return state.state
@TYPES.register("ValveSwitch")
class ValveSwitch(ValveBase):
+15 -1
View File
@@ -17,6 +17,7 @@ import voluptuous as vol
from homeassistant.components import (
binary_sensor,
input_number,
media_player,
persistent_notification,
sensor,
@@ -69,6 +70,8 @@ from .const import (
CONF_LINKED_OBSTRUCTION_SENSOR,
CONF_LINKED_PM25_SENSOR,
CONF_LINKED_TEMPERATURE_SENSOR,
CONF_LINKED_VALVE_DURATION,
CONF_LINKED_VALVE_END_TIME,
CONF_LOW_BATTERY_THRESHOLD,
CONF_MAX_FPS,
CONF_MAX_HEIGHT,
@@ -266,7 +269,9 @@ SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend(
TYPE_VALVE,
)
),
)
),
vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN),
vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN),
}
)
@@ -277,6 +282,12 @@ SENSOR_SCHEMA = BASIC_INFO_SCHEMA.extend(
}
)
VALVE_SCHEMA = BASIC_INFO_SCHEMA.extend(
{
vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN),
vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN),
}
)
HOMEKIT_CHAR_TRANSLATIONS = {
0: " ", # nul
@@ -360,6 +371,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
elif domain == "sensor":
config = SENSOR_SCHEMA(config)
elif domain == "valve":
config = VALVE_SCHEMA(config)
else:
config = BASIC_INFO_SCHEMA(config)
@@ -283,19 +283,19 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity):
@property
def is_closed(self) -> bool | None:
"""Return if the cover is closed."""
return self._device.doorState == DoorState.CLOSED
return self.functional_channel.doorState == DoorState.CLOSED
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self._device.send_door_command_async(DoorCommand.OPEN)
await self.functional_channel.async_send_door_command(DoorCommand.OPEN)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self._device.send_door_command_async(DoorCommand.CLOSE)
await self.functional_channel.async_send_door_command(DoorCommand.CLOSE)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self._device.send_door_command_async(DoorCommand.STOP)
await self.functional_channel.async_send_door_command(DoorCommand.STOP)
class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):
@@ -13,5 +13,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "bronze",
"requirements": ["pylitterbot==2024.2.2"]
"requirements": ["pylitterbot==2024.2.3"]
}
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["aiomealie==0.10.0"]
"requirements": ["aiomealie==0.10.1"]
}
+3 -2
View File
@@ -174,7 +174,8 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity):
if list_item.display.strip() != stripped_item_summary:
update_shopping_item.note = stripped_item_summary
update_shopping_item.position = position
update_shopping_item.is_food = False
if update_shopping_item.is_food is not None:
update_shopping_item.is_food = False
update_shopping_item.food_id = None
update_shopping_item.quantity = 0.0
update_shopping_item.checked = item.status == TodoItemStatus.COMPLETED
@@ -249,7 +250,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity):
mutate_shopping_item.note = item.note
mutate_shopping_item.checked = item.checked
if item.is_food:
if item.is_food or item.food_id:
mutate_shopping_item.food_id = item.food_id
mutate_shopping_item.unit_id = item.unit_id
+21 -21
View File
@@ -203,27 +203,27 @@
"plate": {
"name": "Plate {plate_no}",
"state": {
"power_step_0": "0",
"power_step_warm": "Warming",
"power_step_1": "1",
"power_step_2": "1\u2022",
"power_step_3": "2",
"power_step_4": "2\u2022",
"power_step_5": "3",
"power_step_6": "3\u2022",
"power_step_7": "4",
"power_step_8": "4\u2022",
"power_step_9": "5",
"power_step_10": "5\u2022",
"power_step_11": "6",
"power_step_12": "6\u2022",
"power_step_13": "7",
"power_step_14": "7\u2022",
"power_step_15": "8",
"power_step_16": "8\u2022",
"power_step_17": "9",
"power_step_18": "9\u2022",
"power_step_boost": "Boost"
"plate_step_0": "0",
"plate_step_warm": "Warming",
"plate_step_1": "1",
"plate_step_2": "1\u2022",
"plate_step_3": "2",
"plate_step_4": "2\u2022",
"plate_step_5": "3",
"plate_step_6": "3\u2022",
"plate_step_7": "4",
"plate_step_8": "4\u2022",
"plate_step_9": "5",
"plate_step_10": "5\u2022",
"plate_step_11": "6",
"plate_step_12": "6\u2022",
"plate_step_13": "7",
"plate_step_14": "7\u2022",
"plate_step_15": "8",
"plate_step_16": "8\u2022",
"plate_step_17": "9",
"plate_step_18": "9\u2022",
"plate_step_boost": "Boost"
}
},
"drying_step": {
@@ -289,17 +289,23 @@ class MotionTiltDevice(MotionPositionDevice):
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Set_angle, 180)
await self.async_request_position_till_stop()
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt."""
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Set_angle, 0)
await self.async_request_position_till_stop()
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
angle = kwargs[ATTR_TILT_POSITION] * 180 / 100
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Set_angle, angle)
await self.async_request_position_till_stop()
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the cover."""
async with self._api_lock:
@@ -360,11 +366,15 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Open)
await self.async_request_position_till_stop()
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt."""
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Close)
await self.async_request_position_till_stop()
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
angle = kwargs[ATTR_TILT_POSITION]
@@ -376,6 +386,8 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Set_position, angle)
await self.async_request_position_till_stop()
async def async_set_absolute_position(self, **kwargs):
"""Move the cover to a specific absolute position (see TDBU)."""
angle = kwargs.get(ATTR_TILT_POSITION)
@@ -390,6 +402,8 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Set_position, angle)
await self.async_request_position_till_stop()
class MotionTDBUDevice(MotionBaseDevice):
"""Representation of a Motion Top Down Bottom Up blind Device."""
@@ -42,6 +42,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
self._requesting_position: CALLBACK_TYPE | None = None
self._previous_positions: list[int | dict | None] = []
self._previous_angles: list[int | None] = []
if blind.device_type in DEVICE_TYPES_WIFI:
self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI
@@ -112,17 +113,27 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
"""Request a state update from the blind at a scheduled point in time."""
# add the last position to the list and keep the list at max 2 items
self._previous_positions.append(self._blind.position)
self._previous_angles.append(self._blind.angle)
if len(self._previous_positions) > 2:
del self._previous_positions[: len(self._previous_positions) - 2]
if len(self._previous_angles) > 2:
del self._previous_angles[: len(self._previous_angles) - 2]
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Update_trigger)
self.coordinator.async_update_listeners()
if len(self._previous_positions) < 2 or not all(
self._blind.position == prev_position
for prev_position in self._previous_positions
if (
len(self._previous_positions) < 2
or not all(
self._blind.position == prev_position
for prev_position in self._previous_positions
)
or len(self._previous_angles) < 2
or not all(
self._blind.angle == prev_angle for prev_angle in self._previous_angles
)
):
# keep updating the position @self._update_interval_moving until the position does not change.
self._requesting_position = async_call_later(
@@ -132,6 +143,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
)
else:
self._previous_positions = []
self._previous_angles = []
self._requesting_position = None
async def async_request_position_till_stop(self, delay: int | None = None) -> None:
@@ -140,7 +152,8 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
delay = self._update_interval_moving
self._previous_positions = []
if self._blind.position is None:
self._previous_angles = []
if self._blind.position is None and self._blind.angle is None:
return
if self._requesting_position is not None:
self._requesting_position()
@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/motion_blinds",
"iot_class": "local_push",
"loggers": ["motionblinds"],
"requirements": ["motionblinds==0.6.29"]
"requirements": ["motionblinds==0.6.30"]
}
@@ -1104,6 +1104,7 @@
},
"device_class_sensor": {
"options": {
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
@@ -3,6 +3,7 @@
from __future__ import annotations
from enum import StrEnum
from typing import TYPE_CHECKING
from psnawp_api.core.psnawp_exceptions import (
PSNAWPClientError,
@@ -10,12 +11,14 @@ from psnawp_api.core.psnawp_exceptions import (
PSNAWPNotFoundError,
PSNAWPServerError,
)
from psnawp_api.models.group.group import Group
from homeassistant.components.notify import (
DOMAIN as NOTIFY_DOMAIN,
NotifyEntity,
NotifyEntityDescription,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
@@ -24,6 +27,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import (
PlaystationNetworkConfigEntry,
PlaystationNetworkFriendDataCoordinator,
PlaystationNetworkGroupsUpdateCoordinator,
)
from .entity import PlaystationNetworkServiceEntity
@@ -35,6 +39,7 @@ class PlaystationNetworkNotify(StrEnum):
"""PlayStation Network sensors."""
GROUP_MESSAGE = "group_message"
DIRECT_MESSAGE = "direct_message"
async def async_setup_entry(
@@ -45,6 +50,7 @@ async def async_setup_entry(
"""Set up the notify entity platform."""
coordinator = config_entry.runtime_data.groups
groups_added: set[str] = set()
entity_registry = er.async_get(hass)
@@ -72,8 +78,50 @@ async def async_setup_entry(
coordinator.async_add_listener(add_entities)
add_entities()
for subentry_id, friend_coordinator in config_entry.runtime_data.friends.items():
async_add_entities(
[
PlaystationNetworkDirectMessageNotifyEntity(
friend_coordinator,
config_entry.subentries[subentry_id],
)
],
config_subentry_id=subentry_id,
)
class PlaystationNetworkNotifyEntity(PlaystationNetworkServiceEntity, NotifyEntity):
class PlaystationNetworkNotifyBaseEntity(PlaystationNetworkServiceEntity, NotifyEntity):
"""Base class of PlayStation Network notify entity."""
group: Group | None = None
def send_message(self, message: str, title: str | None = None) -> None:
"""Send a message."""
if TYPE_CHECKING:
assert self.group
try:
self.group.send_message(message)
except PSNAWPNotFoundError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="group_invalid",
translation_placeholders=dict(self.translation_placeholders),
) from e
except PSNAWPForbiddenError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_message_forbidden",
translation_placeholders=dict(self.translation_placeholders),
) from e
except (PSNAWPServerError, PSNAWPClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_message_failed",
translation_placeholders=dict(self.translation_placeholders),
) from e
class PlaystationNetworkNotifyEntity(PlaystationNetworkNotifyBaseEntity):
"""Representation of a PlayStation Network notify entity."""
coordinator: PlaystationNetworkGroupsUpdateCoordinator
@@ -101,26 +149,31 @@ class PlaystationNetworkNotifyEntity(PlaystationNetworkServiceEntity, NotifyEnti
super().__init__(coordinator, self.entity_description)
class PlaystationNetworkDirectMessageNotifyEntity(PlaystationNetworkNotifyBaseEntity):
"""Representation of a PlayStation Network notify entity for sending direct messages."""
coordinator: PlaystationNetworkFriendDataCoordinator
def __init__(
self,
coordinator: PlaystationNetworkFriendDataCoordinator,
subentry: ConfigSubentry,
) -> None:
"""Initialize a notification entity."""
self.entity_description = NotifyEntityDescription(
key=PlaystationNetworkNotify.DIRECT_MESSAGE,
translation_key=PlaystationNetworkNotify.DIRECT_MESSAGE,
)
super().__init__(coordinator, self.entity_description, subentry)
def send_message(self, message: str, title: str | None = None) -> None:
"""Send a message."""
try:
self.group.send_message(message)
except PSNAWPNotFoundError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="group_invalid",
translation_placeholders=dict(self.translation_placeholders),
) from e
except PSNAWPForbiddenError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_message_forbidden",
translation_placeholders=dict(self.translation_placeholders),
) from e
except (PSNAWPServerError, PSNAWPClientError) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_message_failed",
translation_placeholders=dict(self.translation_placeholders),
) from e
if not self.group:
self.group = self.coordinator.psn.psn.group(
users_list=[self.coordinator.user]
)
super().send_message(message, title)
@@ -158,6 +158,9 @@
"notify": {
"group_message": {
"name": "Group: {group_name}"
},
"direct_message": {
"name": "Direct message"
}
}
}
+3 -3
View File
@@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import QbusConfigEntry
from .entity import QbusEntity, add_new_outputs
from .entity import QbusEntity, create_new_entities
PARALLEL_UPDATES = 0
@@ -42,13 +42,13 @@ async def async_setup_entry(
added_outputs: list[QbusMqttOutput] = []
def _check_outputs() -> None:
add_new_outputs(
entities = create_new_entities(
coordinator,
added_outputs,
lambda output: output.type == "thermo",
QbusClimate,
async_add_entities,
)
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
+1
View File
@@ -10,6 +10,7 @@ PLATFORMS: list[Platform] = [
Platform.COVER,
Platform.LIGHT,
Platform.SCENE,
Platform.SENSOR,
Platform.SWITCH,
]
+3 -3
View File
@@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import QbusConfigEntry
from .entity import QbusEntity, add_new_outputs
from .entity import QbusEntity, create_new_entities
PARALLEL_UPDATES = 0
@@ -36,13 +36,13 @@ async def async_setup_entry(
added_outputs: list[QbusMqttOutput] = []
def _check_outputs() -> None:
add_new_outputs(
entities = create_new_entities(
coordinator,
added_outputs,
lambda output: output.type == "shutter",
QbusCover,
async_add_entities,
)
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
+40 -16
View File
@@ -14,7 +14,6 @@ from qbusmqttapi.state import QbusMqttState
from homeassistant.components.mqtt import ReceiveMessage, client as mqtt
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
from .coordinator import QbusControllerCoordinator
@@ -24,14 +23,24 @@ _REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$")
StateT = TypeVar("StateT", bound=QbusMqttState)
def add_new_outputs(
def create_new_entities(
coordinator: QbusControllerCoordinator,
added_outputs: list[QbusMqttOutput],
filter_fn: Callable[[QbusMqttOutput], bool],
entity_type: type[QbusEntity],
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Call async_add_entities for new outputs."""
) -> list[QbusEntity]:
"""Create entities for new outputs."""
new_outputs = determine_new_outputs(coordinator, added_outputs, filter_fn)
return [entity_type(output) for output in new_outputs]
def determine_new_outputs(
coordinator: QbusControllerCoordinator,
added_outputs: list[QbusMqttOutput],
filter_fn: Callable[[QbusMqttOutput], bool],
) -> list[QbusMqttOutput]:
"""Determine new outputs."""
added_ref_ids = {k.ref_id for k in added_outputs}
@@ -43,7 +52,8 @@ def add_new_outputs(
if new_outputs:
added_outputs.extend(new_outputs)
async_add_entities([entity_type(output) for output in new_outputs])
return new_outputs
def format_ref_id(ref_id: str) -> str | None:
@@ -67,7 +77,13 @@ class QbusEntity(Entity, Generic[StateT], ABC):
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
def __init__(
self,
mqtt_output: QbusMqttOutput,
*,
id_suffix: str = "",
link_to_main_device: bool = False,
) -> None:
"""Initialize the Qbus entity."""
self._mqtt_output = mqtt_output
@@ -79,17 +95,25 @@ class QbusEntity(Entity, Generic[StateT], ABC):
)
ref_id = format_ref_id(mqtt_output.ref_id)
unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}"
self._attr_unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}"
if id_suffix:
unique_id += f"_{id_suffix}"
# Create linked device
self._attr_device_info = DeviceInfo(
name=mqtt_output.name.title(),
manufacturer=MANUFACTURER,
identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")},
suggested_area=mqtt_output.location.title(),
via_device=create_main_device_identifier(mqtt_output),
)
self._attr_unique_id = unique_id
if link_to_main_device:
self._attr_device_info = DeviceInfo(
identifiers={create_main_device_identifier(mqtt_output)}
)
else:
self._attr_device_info = DeviceInfo(
name=mqtt_output.name.title(),
manufacturer=MANUFACTURER,
identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")},
suggested_area=mqtt_output.location.title(),
via_device=create_main_device_identifier(mqtt_output),
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
+3 -3
View File
@@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import brightness_to_value, value_to_brightness
from .coordinator import QbusConfigEntry
from .entity import QbusEntity, add_new_outputs
from .entity import QbusEntity, create_new_entities
PARALLEL_UPDATES = 0
@@ -27,13 +27,13 @@ async def async_setup_entry(
added_outputs: list[QbusMqttOutput] = []
def _check_outputs() -> None:
add_new_outputs(
entities = create_new_entities(
coordinator,
added_outputs,
lambda output: output.type == "analog",
QbusLight,
async_add_entities,
)
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
@@ -7,6 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/qbus",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["qbusmqttapi"],
"mqtt": [
"cloudapp/QBUSMQTTGW/state",
"cloudapp/QBUSMQTTGW/config",
+4 -9
View File
@@ -7,11 +7,10 @@ from qbusmqttapi.state import QbusMqttState, StateAction, StateType
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import QbusConfigEntry
from .entity import QbusEntity, add_new_outputs, create_main_device_identifier
from .entity import QbusEntity, create_new_entities
PARALLEL_UPDATES = 0
@@ -27,13 +26,13 @@ async def async_setup_entry(
added_outputs: list[QbusMqttOutput] = []
def _check_outputs() -> None:
add_new_outputs(
entities = create_new_entities(
coordinator,
added_outputs,
lambda output: output.type == "scene",
QbusScene,
async_add_entities,
)
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
@@ -45,12 +44,8 @@ class QbusScene(QbusEntity, Scene):
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
"""Initialize scene entity."""
super().__init__(mqtt_output)
super().__init__(mqtt_output, link_to_main_device=True)
# Add to main controller device
self._attr_device_info = DeviceInfo(
identifiers={create_main_device_identifier(mqtt_output)}
)
self._attr_name = mqtt_output.name.title()
async def async_activate(self, **kwargs: Any) -> None:
+378
View File
@@ -0,0 +1,378 @@
"""Support for Qbus sensor."""
from dataclasses import dataclass
from qbusmqttapi.discovery import QbusMqttOutput
from qbusmqttapi.state import (
GaugeStateProperty,
QbusMqttGaugeState,
QbusMqttHumidityState,
QbusMqttThermoState,
QbusMqttVentilationState,
QbusMqttWeatherState,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfLength,
UnitOfPower,
UnitOfPressure,
UnitOfSoundPressure,
UnitOfSpeed,
UnitOfTemperature,
UnitOfVolume,
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import QbusConfigEntry
from .entity import QbusEntity, create_new_entities, determine_new_outputs
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class QbusWeatherDescription(SensorEntityDescription):
"""Description for Qbus weather entities."""
property: str
_WEATHER_DESCRIPTIONS = (
QbusWeatherDescription(
key="daylight",
property="dayLight",
translation_key="daylight",
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX,
),
QbusWeatherDescription(
key="light",
property="light",
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX,
),
QbusWeatherDescription(
key="light_east",
property="lightEast",
translation_key="light_east",
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX,
),
QbusWeatherDescription(
key="light_south",
property="lightSouth",
translation_key="light_south",
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX,
),
QbusWeatherDescription(
key="light_west",
property="lightWest",
translation_key="light_west",
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX,
),
QbusWeatherDescription(
key="temperature",
property="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
),
QbusWeatherDescription(
key="wind",
property="wind",
device_class=SensorDeviceClass.WIND_SPEED,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
),
)
_GAUGE_VARIANT_DESCRIPTIONS = {
"AIRPRESSURE": SensorEntityDescription(
key="airpressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.MBAR,
state_class=SensorStateClass.MEASUREMENT,
),
"AIRQUALITY": SensorEntityDescription(
key="airquality",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
"CURRENT": SensorEntityDescription(
key="current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
"ENERGY": SensorEntityDescription(
key="energy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL,
),
"GAS": SensorEntityDescription(
key="gas",
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT,
),
"GASFLOW": SensorEntityDescription(
key="gasflow",
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT,
),
"HUMIDITY": SensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
"LIGHT": SensorEntityDescription(
key="light",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
),
"LOUDNESS": SensorEntityDescription(
key="loudness",
device_class=SensorDeviceClass.SOUND_PRESSURE,
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
state_class=SensorStateClass.MEASUREMENT,
),
"POWER": SensorEntityDescription(
key="power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
state_class=SensorStateClass.MEASUREMENT,
),
"PRESSURE": SensorEntityDescription(
key="pressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.KPA,
state_class=SensorStateClass.MEASUREMENT,
),
"TEMPERATURE": SensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
"VOLTAGE": SensorEntityDescription(
key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
"VOLUME": SensorEntityDescription(
key="volume",
device_class=SensorDeviceClass.VOLUME_STORAGE,
native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.MEASUREMENT,
),
"WATER": SensorEntityDescription(
key="water",
device_class=SensorDeviceClass.WATER,
native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.TOTAL,
),
"WATERFLOW": SensorEntityDescription(
key="waterflow",
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT,
),
"WATERLEVEL": SensorEntityDescription(
key="waterlevel",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
),
"WATERPRESSURE": SensorEntityDescription(
key="waterpressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.MBAR,
state_class=SensorStateClass.MEASUREMENT,
),
"WIND": SensorEntityDescription(
key="wind",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT,
),
}
def _is_gauge_with_variant(output: QbusMqttOutput) -> bool:
return (
output.type == "gauge"
and isinstance(output.variant, str)
and _GAUGE_VARIANT_DESCRIPTIONS.get(output.variant.upper()) is not None
)
def _is_ventilation_with_co2(output: QbusMqttOutput) -> bool:
return output.type == "ventilation" and output.properties.get("co2") is not None
async def async_setup_entry(
hass: HomeAssistant,
entry: QbusConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor entities."""
coordinator = entry.runtime_data
added_outputs: list[QbusMqttOutput] = []
def _create_weather_entities() -> list[QbusEntity]:
new_outputs = determine_new_outputs(
coordinator, added_outputs, lambda output: output.type == "weatherstation"
)
return [
QbusWeatherSensor(output, description)
for output in new_outputs
for description in _WEATHER_DESCRIPTIONS
]
def _check_outputs() -> None:
entities: list[QbusEntity] = [
*create_new_entities(
coordinator,
added_outputs,
_is_gauge_with_variant,
QbusGaugeVariantSensor,
),
*create_new_entities(
coordinator,
added_outputs,
lambda output: output.type == "humidity",
QbusHumiditySensor,
),
*create_new_entities(
coordinator,
added_outputs,
lambda output: output.type == "thermo",
QbusThermoSensor,
),
*create_new_entities(
coordinator,
added_outputs,
_is_ventilation_with_co2,
QbusVentilationSensor,
),
*_create_weather_entities(),
]
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
class QbusGaugeVariantSensor(QbusEntity, SensorEntity):
"""Representation of a Qbus sensor entity for gauges with variant."""
_state_cls = QbusMqttGaugeState
_attr_name = None
_attr_suggested_display_precision = 2
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
"""Initialize sensor entity."""
super().__init__(mqtt_output)
variant = str(mqtt_output.variant)
self.entity_description = _GAUGE_VARIANT_DESCRIPTIONS[variant.upper()]
async def _handle_state_received(self, state: QbusMqttGaugeState) -> None:
self._attr_native_value = state.read_value(GaugeStateProperty.CURRENT_VALUE)
class QbusHumiditySensor(QbusEntity, SensorEntity):
"""Representation of a Qbus sensor entity for humidity modules."""
_state_cls = QbusMqttHumidityState
_attr_device_class = SensorDeviceClass.HUMIDITY
_attr_name = None
_attr_native_unit_of_measurement = PERCENTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
async def _handle_state_received(self, state: QbusMqttHumidityState) -> None:
self._attr_native_value = state.read_value()
class QbusThermoSensor(QbusEntity, SensorEntity):
"""Representation of a Qbus sensor entity for thermostats."""
_state_cls = QbusMqttThermoState
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_state_class = SensorStateClass.MEASUREMENT
async def _handle_state_received(self, state: QbusMqttThermoState) -> None:
self._attr_native_value = state.read_current_temperature()
class QbusVentilationSensor(QbusEntity, SensorEntity):
"""Representation of a Qbus sensor entity for ventilations."""
_state_cls = QbusMqttVentilationState
_attr_device_class = SensorDeviceClass.CO2
_attr_name = None
_attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_suggested_display_precision = 0
async def _handle_state_received(self, state: QbusMqttVentilationState) -> None:
self._attr_native_value = state.read_co2()
class QbusWeatherSensor(QbusEntity, SensorEntity):
"""Representation of a Qbus weather sensor."""
_state_cls = QbusMqttWeatherState
entity_description: QbusWeatherDescription
def __init__(
self, mqtt_output: QbusMqttOutput, description: QbusWeatherDescription
) -> None:
"""Initialize sensor entity."""
super().__init__(mqtt_output, id_suffix=description.key)
self.entity_description = description
if description.key == "temperature":
self._attr_name = None
async def _handle_state_received(self, state: QbusMqttWeatherState) -> None:
if value := state.read_property(self.entity_description.property, None):
self.native_value = value
@@ -16,6 +16,22 @@
"no_controller": "No controllers were found"
}
},
"entity": {
"sensor": {
"daylight": {
"name": "Daylight"
},
"light_east": {
"name": "Illuminance east"
},
"light_south": {
"name": "Illuminance south"
},
"light_west": {
"name": "Illuminance west"
}
}
},
"exceptions": {
"invalid_preset": {
"message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}."
+3 -3
View File
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import QbusConfigEntry
from .entity import QbusEntity, add_new_outputs
from .entity import QbusEntity, create_new_entities
PARALLEL_UPDATES = 0
@@ -26,13 +26,13 @@ async def async_setup_entry(
added_outputs: list[QbusMqttOutput] = []
def _check_outputs() -> None:
add_new_outputs(
entities = create_new_entities(
coordinator,
added_outputs,
lambda output: output.type == "onoff",
QbusSwitch,
async_add_entities,
)
async_add_entities(entities)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
+2 -1
View File
@@ -82,6 +82,7 @@
},
"sensor_device_class": {
"options": {
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
@@ -129,7 +130,7 @@
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
@@ -89,8 +89,6 @@ class RepairsFlowManager(data_entry_flow.FlowManager):
"""
if result.get("type") != data_entry_flow.FlowResultType.ABORT:
ir.async_delete_issue(self.hass, flow.handler, flow.init_data["issue_id"])
if "result" not in result:
result["result"] = None
return result
+4 -1
View File
@@ -139,6 +139,7 @@
"selector": {
"device_class": {
"options": {
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
@@ -155,6 +156,7 @@
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
@@ -184,13 +186,14 @@
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
"water": "[%key:component::sensor::entity_component::water::name%]",
"weight": "[%key:component::sensor::entity_component::weight::name%]",
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
},
+6 -1
View File
@@ -71,10 +71,13 @@
"selector": {
"device_class": {
"options": {
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::name%]",
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
@@ -85,6 +88,7 @@
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
@@ -115,13 +119,14 @@
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
"water": "[%key:component::sensor::entity_component::water::name%]",
"weight": "[%key:component::sensor::entity_component::weight::name%]",
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
},
@@ -4,6 +4,7 @@ from __future__ import annotations
import contextlib
from dataclasses import dataclass, field
import logging
from typing import Any
from pysqueezebox import Player
@@ -21,6 +22,8 @@ from homeassistant.helpers.network import is_internal_request
from .const import DOMAIN, UNPLAYABLE_TYPES
_LOGGER = logging.getLogger(__name__)
LIBRARY = [
"favorites",
"artists",
@@ -138,18 +141,42 @@ class BrowseData:
self.squeezebox_id_by_type.update(SQUEEZEBOX_ID_BY_TYPE)
self.media_type_to_squeezebox.update(MEDIA_TYPE_TO_SQUEEZEBOX)
def add_new_command(self, cmd: str | MediaType, type: str) -> None:
"""Add items to maps for new apps or radios."""
self.known_apps_radios.add(cmd)
self.media_type_to_squeezebox[cmd] = cmd
self.squeezebox_id_by_type[cmd] = type
self.content_type_media_class[cmd] = {
"item": MediaClass.DIRECTORY,
"children": MediaClass.TRACK,
}
self.content_type_to_child_type[cmd] = MediaType.TRACK
def _add_new_command_to_browse_data(
browse_data: BrowseData, cmd: str | MediaType, type: str
) -> None:
"""Add items to maps for new apps or radios."""
browse_data.media_type_to_squeezebox[cmd] = cmd
browse_data.squeezebox_id_by_type[cmd] = type
browse_data.content_type_media_class[cmd] = {
"item": MediaClass.DIRECTORY,
"children": MediaClass.TRACK,
}
browse_data.content_type_to_child_type[cmd] = MediaType.TRACK
async def async_init(self, player: Player, browse_limit: int) -> None:
"""Initialize known apps and radios from the player."""
cmd = ["apps", 0, browse_limit]
result = await player.async_query(*cmd)
for app in result["appss_loop"]:
app_cmd = "app-" + app["cmd"]
if app_cmd not in self.known_apps_radios:
self.add_new_command(app_cmd, "item_id")
_LOGGER.debug(
"Adding new command %s to browse data for player %s",
app_cmd,
player.player_id,
)
cmd = ["radios", 0, browse_limit]
result = await player.async_query(*cmd)
for app in result["radioss_loop"]:
app_cmd = "app-" + app["cmd"]
if app_cmd not in self.known_apps_radios:
self.add_new_command(app_cmd, "item_id")
_LOGGER.debug(
"Adding new command %s to browse data for player %s",
app_cmd,
player.player_id,
)
def _build_response_apps_radios_category(
@@ -292,8 +319,7 @@ async def build_item_response(
app_cmd = "app-" + item["cmd"]
if app_cmd not in browse_data.known_apps_radios:
browse_data.known_apps_radios.add(app_cmd)
_add_new_command_to_browse_data(browse_data, app_cmd, "item_id")
browse_data.add_new_command(app_cmd, "item_id")
child_media = _build_response_apps_radios_category(
browse_data=browse_data, cmd=app_cmd, item=item
@@ -311,6 +311,11 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
)
return None
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
await super().async_added_to_hass()
await self._browse_data.async_init(self._player, self.browse_limit)
async def async_will_remove_from_hass(self) -> None:
"""Remove from list of known players when removed from hass."""
self.coordinator.config_entry.runtime_data.known_player_ids.remove(
@@ -278,10 +278,10 @@
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"state": "Template for the number's current value.",
"step": "Template for the number's increment/decrement step.",
"step": "Defines the number's increment/decrement step.",
"set_value": "Defines actions to run when the number is set to a value. Receives variable `value`.",
"max": "Template for the number's maximum value.",
"min": "Template for the number's minimum value.",
"max": "Defines the number's maximum value.",
"min": "Defines the number's minimum value.",
"unit_of_measurement": "Defines the unit of measurement of the number, if any."
},
"sections": {
@@ -901,6 +901,7 @@
},
"sensor_device_class": {
"options": {
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
@@ -948,7 +949,7 @@
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
@@ -247,11 +247,15 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any
raise UpdateFailed(e.message) from e
self.updated_once = True
if not data or not isinstance(data.get("time_series"), list):
raise UpdateFailed("Received invalid data")
# Add all time periods together
output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0)
for period in data.get("time_series", []):
for key in ENERGY_HISTORY_FIELDS:
output[key] += period.get(key, 0)
if key in period:
output[key] += period[key]
return output
+21 -13
View File
@@ -16,6 +16,7 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
LightEntityDescription,
color_supported,
filter_supported_color_modes,
)
from homeassistant.const import EntityCategory
@@ -530,19 +531,6 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
description.brightness_min, dptype=DPType.INTEGER
)
if int_type := self.find_dpcode(
description.color_temp, dptype=DPType.INTEGER, prefer_function=True
):
self._color_temp = int_type
color_modes.add(ColorMode.COLOR_TEMP)
# If entity does not have color_temp, check if it has work_mode "white"
elif color_mode_enum := self.find_dpcode(
description.color_mode, dptype=DPType.ENUM, prefer_function=True
):
if WorkMode.WHITE.value in color_mode_enum.range:
color_modes.add(ColorMode.WHITE)
self._white_color_mode = ColorMode.WHITE
if (
dpcode := self.find_dpcode(description.color_data, prefer_function=True)
) and self.get_dptype(dpcode) == DPType.JSON:
@@ -568,6 +556,26 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
):
self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2
# Check if the light has color temperature
if int_type := self.find_dpcode(
description.color_temp, dptype=DPType.INTEGER, prefer_function=True
):
self._color_temp = int_type
color_modes.add(ColorMode.COLOR_TEMP)
# If light has color but does not have color_temp, check if it has
# work_mode "white"
elif (
color_supported(color_modes)
and (
color_mode_enum := self.find_dpcode(
description.color_mode, dptype=DPType.ENUM, prefer_function=True
)
)
and WorkMode.WHITE.value in color_mode_enum.range
):
color_modes.add(ColorMode.WHITE)
self._white_color_mode = ColorMode.WHITE
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
if len(self._attr_supported_color_modes) == 1:
# If the light supports only a single color mode, set it now
@@ -162,7 +162,11 @@ class UptimeKumaSensorEntity(
name=coordinator.data[monitor].monitor_name,
identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{monitor!s}")},
manufacturer="Uptime Kuma",
configuration_url=coordinator.config_entry.data[CONF_URL],
configuration_url=(
None
if "127.0.0.1" in (url := coordinator.config_entry.data[CONF_URL])
else url
),
sw_version=coordinator.api.version.version,
)
+1 -1
View File
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["voip_utils"],
"quality_scale": "internal",
"requirements": ["voip-utils==0.3.3"]
"requirements": ["voip-utils==0.3.4"]
}
@@ -9,6 +9,7 @@ from typing import Any
import voluptuous as vol
from volvocarsapi.api import VolvoCarsApi
from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle
from volvocarsapi.scopes import DEFAULT_SCOPES
from homeassistant.config_entries import (
SOURCE_REAUTH,
@@ -54,6 +55,13 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
self._vehicles: list[VolvoCarsVehicle] = []
self._config_data: dict = {}
@property
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
return super().extra_authorize_data | {
"scope": " ".join(DEFAULT_SCOPES),
}
@property
def logger(self) -> logging.Logger:
"""Return logger."""
@@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
"iot_class": "local_push",
"requirements": ["xiaomi-ble==1.1.0"]
"requirements": ["xiaomi-ble==1.2.0"]
}
+6 -2
View File
@@ -58,7 +58,7 @@ async def async_setup_entry(
zha_data = get_zha_data(hass)
if zha_data.update_coordinator is None:
zha_data.update_coordinator = ZHAFirmwareUpdateCoordinator(
hass, get_zha_gateway(hass).application_controller
hass, config_entry, get_zha_gateway(hass).application_controller
)
entities_to_create = zha_data.platforms[Platform.UPDATE]
@@ -79,12 +79,16 @@ class ZHAFirmwareUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disa
"""Firmware update coordinator that broadcasts updates network-wide."""
def __init__(
self, hass: HomeAssistant, controller_application: ControllerApplication
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
controller_application: ControllerApplication,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name="ZHA firmware update coordinator",
update_method=self.async_update_data,
)
+25 -6
View File
@@ -105,7 +105,6 @@ from .const import (
CONF_USB_PATH,
CONF_USE_ADDON,
DOMAIN,
DRIVER_READY_TIMEOUT,
EVENT_DEVICE_ADDED_TO_REGISTRY,
EVENT_VALUE_UPDATED,
LIB_LOGGER,
@@ -136,6 +135,7 @@ from .models import ZwaveJSConfigEntry, ZwaveJSData
from .services import async_setup_services
CONNECT_TIMEOUT = 10
DRIVER_READY_TIMEOUT = 60
CONFIG_SCHEMA = vol.Schema(
{
@@ -368,6 +368,16 @@ class DriverEvents:
)
)
# listen for driver ready event to reload the config entry
self.config_entry.async_on_unload(
driver.on(
"driver ready",
lambda _: self.hass.config_entries.async_schedule_reload(
self.config_entry.entry_id
),
)
)
# listen for new nodes being added to the mesh
self.config_entry.async_on_unload(
controller.on(
@@ -1074,23 +1084,32 @@ async def client_listen(
try:
await client.listen(driver_ready)
except BaseZwaveJSServerError as err:
if entry.state is not ConfigEntryState.LOADED:
if entry.state is ConfigEntryState.SETUP_IN_PROGRESS:
raise
LOGGER.error("Client listen failed: %s", err)
except Exception as err:
# We need to guard against unknown exceptions to not crash this task.
LOGGER.exception("Unexpected exception: %s", err)
if entry.state is not ConfigEntryState.LOADED:
if entry.state is ConfigEntryState.SETUP_IN_PROGRESS:
raise
if hass.is_stopping or entry.state is ConfigEntryState.UNLOAD_IN_PROGRESS:
return
if entry.state is ConfigEntryState.SETUP_IN_PROGRESS:
raise HomeAssistantError("Listen task ended unexpectedly")
# The entry needs to be reloaded since a new driver state
# will be acquired on reconnect.
# All model instances will be replaced when the new state is acquired.
if not hass.is_stopping:
if entry.state is not ConfigEntryState.LOADED:
raise HomeAssistantError("Listen task ended unexpectedly")
if entry.state.recoverable:
LOGGER.debug("Disconnected from server. Reloading integration")
hass.config_entries.async_schedule_reload(entry.entry_id)
else:
LOGGER.error(
"Disconnected from server. Cannot recover entry %s",
entry.title,
)
async def async_unload_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> bool:
+11 -28
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
from contextlib import suppress
import dataclasses
@@ -87,7 +86,6 @@ from .const import (
CONF_DATA_COLLECTION_OPTED_IN,
CONF_INSTALLER_MODE,
DOMAIN,
DRIVER_READY_TIMEOUT,
EVENT_DEVICE_ADDED_TO_REGISTRY,
LOGGER,
USER_AGENT,
@@ -98,6 +96,7 @@ from .helpers import (
async_get_node_from_device_id,
async_get_provisioning_entry_from_device_id,
async_get_version_info,
async_wait_for_driver_ready_event,
get_device_id,
)
@@ -2854,26 +2853,18 @@ async def websocket_hard_reset_controller(
connection.send_result(msg[ID], device.id)
async_cleanup()
@callback
def set_driver_ready(event: dict) -> None:
"Set the driver ready event."
wait_driver_ready.set()
wait_driver_ready = asyncio.Event()
msg[DATA_UNSUBSCRIBE] = unsubs = [
async_dispatcher_connect(
hass, EVENT_DEVICE_ADDED_TO_REGISTRY, _handle_device_added
),
driver.once("driver ready", set_driver_ready),
]
wait_for_driver_ready = async_wait_for_driver_ready_event(entry, driver)
await driver.async_hard_reset()
with suppress(TimeoutError):
async with asyncio.timeout(DRIVER_READY_TIMEOUT):
await wait_driver_ready.wait()
await wait_for_driver_ready()
# When resetting the controller, the controller home id is also changed.
# The controller state in the client is stale after resetting the controller,
# so get the new home id with a new client using the helper function.
@@ -2886,14 +2877,14 @@ async def websocket_hard_reset_controller(
# The stale unique id needs to be handled by a repair flow,
# after the config entry has been reloaded.
LOGGER.error(
"Failed to get server version, cannot update config entry"
"Failed to get server version, cannot update config entry "
"unique id with new home id, after controller reset"
)
else:
hass.config_entries.async_update_entry(
entry, unique_id=str(version_info.home_id)
)
await hass.config_entries.async_reload(entry.entry_id)
hass.config_entries.async_schedule_reload(entry.entry_id)
@websocket_api.websocket_command(
@@ -3100,27 +3091,19 @@ async def websocket_restore_nvm(
)
)
@callback
def set_driver_ready(event: dict) -> None:
"Set the driver ready event."
wait_driver_ready.set()
wait_driver_ready = asyncio.Event()
# Set up subscription for progress events
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
controller.on("nvm convert progress", forward_progress),
controller.on("nvm restore progress", forward_progress),
driver.once("driver ready", set_driver_ready),
]
wait_for_driver_ready = async_wait_for_driver_ready_event(entry, driver)
await controller.async_restore_nvm_base64(msg["data"], {"preserveRoutes": False})
with suppress(TimeoutError):
async with asyncio.timeout(DRIVER_READY_TIMEOUT):
await wait_driver_ready.wait()
await wait_for_driver_ready()
# When restoring the NVM to the controller, the controller home id is also changed.
# The controller state in the client is stale after restoring the NVM,
# so get the new home id with a new client using the helper function.
@@ -3133,14 +3116,13 @@ async def websocket_restore_nvm(
# The stale unique id needs to be handled by a repair flow,
# after the config entry has been reloaded.
LOGGER.error(
"Failed to get server version, cannot update config entry"
"Failed to get server version, cannot update config entry "
"unique id with new home id, after controller NVM restore"
)
else:
hass.config_entries.async_update_entry(
entry, unique_id=str(version_info.home_id)
)
await hass.config_entries.async_reload(entry.entry_id)
connection.send_message(
@@ -3152,3 +3134,4 @@ async def websocket_restore_nvm(
)
)
connection.send_result(msg[ID])
async_cleanup()
@@ -62,9 +62,12 @@ from .const import (
CONF_USB_PATH,
CONF_USE_ADDON,
DOMAIN,
DRIVER_READY_TIMEOUT,
)
from .helpers import CannotConnect, async_get_version_info
from .helpers import (
CannotConnect,
async_get_version_info,
async_wait_for_driver_ready_event,
)
from .models import ZwaveJSConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -1396,19 +1399,15 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
event["bytesWritten"] / event["total"] * 0.5 + 0.5
)
@callback
def set_driver_ready(event: dict) -> None:
"Set the driver ready event."
wait_driver_ready.set()
driver = self._get_driver()
controller = driver.controller
wait_driver_ready = asyncio.Event()
unsubs = [
controller.on("nvm convert progress", forward_progress),
controller.on("nvm restore progress", forward_progress),
driver.once("driver ready", set_driver_ready),
]
wait_for_driver_ready = async_wait_for_driver_ready_event(config_entry, driver)
try:
await controller.async_restore_nvm(
self.backup_data, {"preserveRoutes": False}
@@ -1417,8 +1416,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
raise AbortFlow(f"Failed to restore network: {err}") from err
else:
with suppress(TimeoutError):
async with asyncio.timeout(DRIVER_READY_TIMEOUT):
await wait_driver_ready.wait()
await wait_for_driver_ready()
try:
version_info = await async_get_version_info(
self.hass, config_entry.data[CONF_URL]
@@ -1435,10 +1433,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
self.hass.config_entries.async_update_entry(
config_entry, unique_id=str(version_info.home_id)
)
await self.hass.config_entries.async_reload(config_entry.entry_id)
# Reload the config entry two times to clean up
# the stale device entry.
# The config entry will be also be reloaded when the driver is ready,
# by the listener in the package module,
# and two reloads are needed to clean up the stale controller device entry.
# Since both the old and the new controller have the same node id,
# but different hardware identifiers, the integration
# will create a new device for the new controller, on the first reload,
@@ -201,7 +201,3 @@ COVER_TILT_PROPERTY_KEYS: set[str | int | None] = {
WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE,
WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION,
}
# Other constants
DRIVER_READY_TIMEOUT = 60
+54 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from collections.abc import Callable, Coroutine
from dataclasses import astuple, dataclass
import logging
from typing import Any, cast
@@ -56,6 +56,7 @@ from .const import (
)
from .models import ZwaveJSConfigEntry
DRIVER_READY_EVENT_TIMEOUT = 60
SERVER_VERSION_TIMEOUT = 10
@@ -588,5 +589,57 @@ async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> Versio
return version_info
@callback
def async_wait_for_driver_ready_event(
config_entry: ZwaveJSConfigEntry,
driver: Driver,
) -> Callable[[], Coroutine[Any, Any, None]]:
"""Wait for the driver ready event and the config entry reload.
When the driver ready event is received
the config entry will be reloaded by the integration.
This function helps wait for that to happen
before proceeding with further actions.
If the config entry is reloaded for another reason,
this function will not wait for it to be reloaded again.
Raises TimeoutError if the driver ready event and reload
is not received within the specified timeout.
"""
driver_ready_event_received = asyncio.Event()
config_entry_reloaded = asyncio.Event()
unsubscribers: list[Callable[[], None]] = []
@callback
def driver_ready_received(event: dict) -> None:
"""Receive the driver ready event."""
driver_ready_event_received.set()
unsubscribers.append(driver.once("driver ready", driver_ready_received))
@callback
def on_config_entry_state_change() -> None:
"""Check config entry was loaded after driver ready event."""
if config_entry.state is ConfigEntryState.LOADED:
config_entry_reloaded.set()
unsubscribers.append(
config_entry.async_on_state_change(on_config_entry_state_change)
)
async def wait_for_events() -> None:
try:
async with asyncio.timeout(DRIVER_READY_EVENT_TIMEOUT):
await asyncio.gather(
driver_ready_event_received.wait(), config_entry_reloaded.wait()
)
finally:
for unsubscribe in unsubscribers:
unsubscribe()
return wait_for_events
class CannotConnect(HomeAssistantError):
"""Indicate connection error."""
+2 -2
View File
@@ -298,8 +298,10 @@ class ConfigFlowContext(FlowContext, total=False):
class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
"""Typed result dict for config flow."""
# Extra keys, only present if type is CREATE_ENTRY
minor_version: int
options: Mapping[str, Any]
result: ConfigEntry
subentries: Iterable[ConfigSubentryData]
version: int
@@ -3345,7 +3347,6 @@ class ConfigSubentryFlowManager(
),
)
result["result"] = True
return result
@@ -3508,7 +3509,6 @@ class OptionsFlowManager(
):
self.hass.config_entries.async_schedule_reload(entry.entry_id)
result["result"] = True
return result
async def _async_setup_preview(
+2 -2
View File
@@ -24,8 +24,8 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 8
PATCH_VERSION: Final = "0b1"
MINOR_VERSION: Final = 9
PATCH_VERSION: Final = "0.dev0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
+7 -17
View File
@@ -142,7 +142,6 @@ class FlowResult(TypedDict, Generic[_FlowContextT, _HandlerT], total=False):
progress_task: asyncio.Task[Any] | None
reason: str
required: bool
result: Any
step_id: str
title: str
translation_domain: str
@@ -677,9 +676,10 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
and key in suggested_values
):
new_section_key = copy.copy(key)
schema[new_section_key] = val
val.schema = self.add_suggested_values_to_schema(
val.schema, suggested_values[key]
new_val = copy.copy(val)
schema[new_section_key] = new_val
new_val.schema = self.add_suggested_values_to_schema(
new_val.schema, suggested_values[key]
)
continue
@@ -706,10 +706,7 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
last_step: bool | None = None,
preview: str | None = None,
) -> _FlowResultT:
"""Return the definition of a form to gather user input.
The step_id parameter is deprecated and will be removed in a future release.
"""
"""Return the definition of a form to gather user input."""
flow_result = self._flow_result(
type=FlowResultType.FORM,
flow_id=self.flow_id,
@@ -771,10 +768,7 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
url: str,
description_placeholders: Mapping[str, str] | None = None,
) -> _FlowResultT:
"""Return the definition of an external step for the user to take.
The step_id parameter is deprecated and will be removed in a future release.
"""
"""Return the definition of an external step for the user to take."""
flow_result = self._flow_result(
type=FlowResultType.EXTERNAL_STEP,
flow_id=self.flow_id,
@@ -805,10 +799,7 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
description_placeholders: Mapping[str, str] | None = None,
progress_task: asyncio.Task[Any] | None = None,
) -> _FlowResultT:
"""Show a progress message to the user, without user input allowed.
The step_id parameter is deprecated and will be removed in a future release.
"""
"""Show a progress message to the user, without user input allowed."""
if progress_task is None and not self.__no_progress_task_reported:
self.__no_progress_task_reported = True
cls = self.__class__
@@ -868,7 +859,6 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
"""Show a navigation menu to the user.
Options dict maps step_id => i18n label
The step_id parameter is deprecated and will be removed in a future release.
"""
flow_result = self._flow_result(
type=FlowResultType.MENU,
+1 -1
View File
@@ -35,7 +35,7 @@ class _BaseFlowManagerView(HomeAssistantView, Generic[_FlowManagerT]):
"""Convert result to JSON."""
if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY:
data = result.copy()
data.pop("result")
assert "result" not in result
data.pop("data")
data.pop("context")
return data
+43 -16
View File
@@ -32,6 +32,7 @@ from homeassistant.util.json import format_unserializable_data
from . import storage, translation
from .debounce import Debouncer
from .deprecation import deprecated_function
from .frame import ReportBehavior, report_usage
from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment
from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType
@@ -67,6 +68,7 @@ CONNECTION_ZIGBEE = "zigbee"
ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30
# Can be removed when suggested_area is removed from DeviceEntry
RUNTIME_ONLY_ATTRS = {"suggested_area"}
CONFIGURATION_URL_SCHEMES = {"http", "https", "homeassistant"}
@@ -343,7 +345,8 @@ class DeviceEntry:
name: str | None = attr.ib(default=None)
primary_config_entry: str | None = attr.ib(default=None)
serial_number: str | None = attr.ib(default=None)
suggested_area: str | None = attr.ib(default=None)
# Suggested area is deprecated and will be removed from DeviceEntry in 2026.9.
_suggested_area: str | None = attr.ib(default=None)
sw_version: str | None = attr.ib(default=None)
via_device_id: str | None = attr.ib(default=None)
# This value is not stored, just used to keep track of events to fire.
@@ -442,6 +445,14 @@ class DeviceEntry:
)
)
@property
@deprecated_function(
"code which ignores suggested_area", breaks_in_ha_version="2026.9"
)
def suggested_area(self) -> str | None:
"""Return the suggested area for this device entry."""
return self._suggested_area
@attr.s(frozen=True, slots=True)
class DeletedDeviceEntry:
@@ -895,7 +906,19 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
if device is None:
deleted_device = self.deleted_devices.get_entry(identifiers, connections)
if deleted_device is None:
device = DeviceEntry(is_new=True)
area_id: str | None = None
if (
suggested_area is not None
and suggested_area is not UNDEFINED
and suggested_area != ""
):
# Circular dep
from . import area_registry as ar # noqa: PLC0415
area = ar.async_get(self.hass).async_get_or_create(suggested_area)
area_id = area.id
device = DeviceEntry(is_new=True, area_id=area_id)
else:
self.deleted_devices.pop(deleted_device.id)
device = deleted_device.to_device_entry(
@@ -950,7 +973,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
model_id=model_id,
name=name,
serial_number=serial_number,
suggested_area=suggested_area,
_suggested_area=suggested_area,
sw_version=sw_version,
via_device_id=via_device_id,
)
@@ -989,6 +1012,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
remove_config_entry_id: str | UndefinedType = UNDEFINED,
remove_config_subentry_id: str | None | UndefinedType = UNDEFINED,
serial_number: str | None | UndefinedType = UNDEFINED,
# _suggested_area is used internally by the device registry and must
# not be set by integrations.
_suggested_area: str | None | UndefinedType = UNDEFINED,
# suggested_area is deprecated and will be removed in 2026.9
suggested_area: str | None | UndefinedType = UNDEFINED,
sw_version: str | None | UndefinedType = UNDEFINED,
via_device_id: str | None | UndefinedType = UNDEFINED,
@@ -1054,19 +1081,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
"Cannot define both merge_identifiers and new_identifiers"
)
if (
suggested_area is not None
and suggested_area is not UNDEFINED
and suggested_area != ""
and area_id is UNDEFINED
and old.area_id is None
):
# Circular dep
from . import area_registry as ar # noqa: PLC0415
area = ar.async_get(self.hass).async_get_or_create(suggested_area)
area_id = area.id
if add_config_entry_id is not UNDEFINED:
if add_config_subentry_id is UNDEFINED:
# Interpret not specifying a subentry as None (the main entry)
@@ -1144,6 +1158,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
new_values["config_entries_subentries"] = config_entries_subentries
old_values["config_entries_subentries"] = old.config_entries_subentries
if suggested_area is not UNDEFINED:
report_usage(
"passes a suggested_area to device_registry.async_update device",
core_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2026.9.0",
)
if _suggested_area is not UNDEFINED:
suggested_area = _suggested_area
added_connections: set[tuple[str, str]] | None = None
added_identifiers: set[tuple[str, str]] | None = None
@@ -1197,6 +1221,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
("name", name),
("name_by_user", name_by_user),
("serial_number", serial_number),
# Can be removed when suggested_area is removed from DeviceEntry
("suggested_area", suggested_area),
("sw_version", sw_version),
("via_device_id", via_device_id),
@@ -1211,6 +1236,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
if not new_values:
return old
# This condition can be removed when suggested_area is removed from DeviceEntry
if not RUNTIME_ONLY_ATTRS.issuperset(new_values):
# Change modified_at if we are changing something that we store
new_values["modified_at"] = utcnow()
@@ -1233,6 +1259,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
# firing events for data we have nothing to compare
# against since its never saved on disk
if RUNTIME_ONLY_ATTRS.issuperset(new_values):
# This can be removed when suggested_area is removed from DeviceEntry
return new
self.async_schedule_save()
+6 -1
View File
@@ -608,10 +608,15 @@ async def async_get_all_descriptions(
new_descriptions_cache = descriptions_cache.copy()
for missing_trigger in missing_triggers:
domain = triggers[missing_trigger]
trigger_description_key = (
platform_and_sub_type[1]
if len(platform_and_sub_type := missing_trigger.split(".")) > 1
else missing_trigger
)
if (
yaml_description := new_triggers_descriptions.get(domain, {}).get( # type: ignore[union-attr]
missing_trigger
trigger_description_key
)
) is None:
_LOGGER.debug(
+2 -3
View File
@@ -209,7 +209,6 @@ aiofiles>=24.1.0
# https://github.com/aio-libs/multidict/issues/1131
multidict>=6.4.2
# rpds-py > 0.25.0 requires cargo 1.84.0
# Stable Alpine current only ships cargo 1.83.0
# rpds-py frequently updates cargo causing build failures
# No wheels upstream available for armhf & armv7
rpds-py==0.24.0
rpds-py==0.26.0
+5 -13
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.8.0b1"
version = "2025.9.0.dev0"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
@@ -487,19 +487,10 @@ filterwarnings = [
"ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteofrance_api.model.forecast",
# -- fixed, waiting for release / update
# https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0
"ignore:.*invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base",
# https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0
"ignore:pkg_resources is deprecated as an API:UserWarning:datadog.util.compat",
# https://github.com/httplib2/httplib2/pull/226 - >=0.21.0
"ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2",
# https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0
"ignore::DeprecationWarning:holidays",
# https://github.com/ReactiveX/RxPY/pull/716 - >4.0.4
"ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:reactivex.internal.constants",
# https://github.com/postlund/pyatv/issues/2645 - >0.16.0
# https://github.com/postlund/pyatv/pull/2664
"ignore:Protobuf gencode .* exactly one major version older than the runtime version 6.* at pyatv:UserWarning:google.protobuf.runtime_version",
# https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0
"ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol",
"ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol",
@@ -526,6 +517,9 @@ filterwarnings = [
"ignore:loop argument is deprecated:DeprecationWarning:emulated_roku",
# https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16
"ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async",
# https://pypi.org/project/motionblindsble/ - v0.1.3 - 2024-11-12
# https://github.com/LennP/motionblindsble/blob/0.1.3/motionblindsble/device.py#L390
"ignore:Passing additional arguments for BLEDevice is deprecated and has no effect:DeprecationWarning:motionblindsble.device",
# https://pypi.org/project/pyeconet/ - v0.1.28 - 2025-02-15
# https://github.com/w1ll1am23/pyeconet/blob/v0.1.28/src/pyeconet/api.py#L38
"ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api",
@@ -542,8 +536,6 @@ filterwarnings = [
"ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api",
# https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10
"ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const",
# New in aiohttp - v3.9.0
"ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)",
# - SyntaxWarnings
# https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10
"ignore:.*invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common",
@@ -589,7 +581,7 @@ filterwarnings = [
# -- Websockets 14.1
# https://websockets.readthedocs.io/en/stable/howto/upgrade.html
"ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy",
# https://github.com/graphql-python/gql/pull/543 - >=4.0.0a0
# https://github.com/graphql-python/gql/pull/543 - >=4.0.0b0
"ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base",
# -- unmaintained projects, last release about 2+ years
+9 -9
View File
@@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==37.2.0
aioesphomeapi==37.2.2
# homeassistant.components.flo
aioflo==2021.11.0
@@ -310,7 +310,7 @@ aiolookin==1.0.0
aiolyric==2.0.1
# homeassistant.components.mealie
aiomealie==0.10.0
aiomealie==0.10.1
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -791,7 +791,7 @@ deluge-client==1.10.2
demetriek==1.3.0
# homeassistant.components.denonavr
denonavr==1.1.1
denonavr==1.1.2
# homeassistant.components.devialet
devialet==1.5.7
@@ -1100,7 +1100,7 @@ greenwavereality==0.5.1
gridnet==5.0.1
# homeassistant.components.growatt_server
growattServer==1.6.0
growattServer==1.7.1
# homeassistant.components.google_sheets
gspread==5.5.0
@@ -1458,7 +1458,7 @@ monzopy==1.5.1
mopeka-iot-ble==0.8.0
# homeassistant.components.motion_blinds
motionblinds==0.6.29
motionblinds==0.6.30
# homeassistant.components.motionblinds_ble
motionblindsble==0.1.3
@@ -1963,7 +1963,7 @@ pyefergy==22.5.0
pyegps==0.2.5
# homeassistant.components.emoncms
pyemoncms==0.1.1
pyemoncms==0.1.2
# homeassistant.components.enphase_envoy
pyenphase==2.2.3
@@ -2122,7 +2122,7 @@ pylibrespot-java==0.1.1
pylitejet==0.6.3
# homeassistant.components.litterrobot
pylitterbot==2024.2.2
pylitterbot==2024.2.3
# homeassistant.components.lutron_caseta
pylutron-caseta==0.24.0
@@ -3057,7 +3057,7 @@ venstarcolortouch==0.21
vilfo-api-client==0.5.0
# homeassistant.components.voip
voip-utils==0.3.3
voip-utils==0.3.4
# homeassistant.components.volkszaehler
volkszaehler==0.4.0
@@ -3139,7 +3139,7 @@ wyoming==1.7.1
xbox-webapi==2.1.0
# homeassistant.components.xiaomi_ble
xiaomi-ble==1.1.0
xiaomi-ble==1.2.0
# homeassistant.components.knx
xknx==3.8.0
+9 -9
View File
@@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==37.2.0
aioesphomeapi==37.2.2
# homeassistant.components.flo
aioflo==2021.11.0
@@ -292,7 +292,7 @@ aiolookin==1.0.0
aiolyric==2.0.1
# homeassistant.components.mealie
aiomealie==0.10.0
aiomealie==0.10.1
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -691,7 +691,7 @@ deluge-client==1.10.2
demetriek==1.3.0
# homeassistant.components.denonavr
denonavr==1.1.1
denonavr==1.1.2
# homeassistant.components.devialet
devialet==1.5.7
@@ -961,7 +961,7 @@ greeneye_monitor==3.0.3
gridnet==5.0.1
# homeassistant.components.growatt_server
growattServer==1.6.0
growattServer==1.7.1
# homeassistant.components.google_sheets
gspread==5.5.0
@@ -1250,7 +1250,7 @@ monzopy==1.5.1
mopeka-iot-ble==0.8.0
# homeassistant.components.motion_blinds
motionblinds==0.6.29
motionblinds==0.6.30
# homeassistant.components.motionblinds_ble
motionblindsble==0.1.3
@@ -1638,7 +1638,7 @@ pyefergy==22.5.0
pyegps==0.2.5
# homeassistant.components.emoncms
pyemoncms==0.1.1
pyemoncms==0.1.2
# homeassistant.components.enphase_envoy
pyenphase==2.2.3
@@ -1767,7 +1767,7 @@ pylibrespot-java==0.1.1
pylitejet==0.6.3
# homeassistant.components.litterrobot
pylitterbot==2024.2.2
pylitterbot==2024.2.3
# homeassistant.components.lutron_caseta
pylutron-caseta==0.24.0
@@ -2525,7 +2525,7 @@ venstarcolortouch==0.21
vilfo-api-client==0.5.0
# homeassistant.components.voip
voip-utils==0.3.3
voip-utils==0.3.4
# homeassistant.components.volvo
volvocarsapi==0.4.1
@@ -2592,7 +2592,7 @@ wyoming==1.7.1
xbox-webapi==2.1.0
# homeassistant.components.xiaomi_ble
xiaomi-ble==1.1.0
xiaomi-ble==1.2.0
# homeassistant.components.knx
xknx==3.8.0
+1 -1
View File
@@ -4,7 +4,7 @@
# Stop on errors
set -e
cd "$(dirname "$0")/.."
cd "$(realpath "$(dirname "$0")/..")"
echo "Installing development dependencies..."
uv pip install wheel --constraint homeassistant/package_constraints.txt --upgrade
+2 -3
View File
@@ -235,10 +235,9 @@ aiofiles>=24.1.0
# https://github.com/aio-libs/multidict/issues/1131
multidict>=6.4.2
# rpds-py > 0.25.0 requires cargo 1.84.0
# Stable Alpine current only ships cargo 1.83.0
# rpds-py frequently updates cargo causing build failures
# No wheels upstream available for armhf & armv7
rpds-py==0.24.0
rpds-py==0.26.0
"""
GENERATED_MESSAGE = (
@@ -21,7 +21,6 @@
'aa:bb:cc:dd:ee:ff',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Acaia',
@@ -31,7 +30,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': 'Kitchen',
'sw_version': None,
'via_device_id': None,
})
@@ -21,7 +21,6 @@
'84fce612f5b8',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'AirGradient',
@@ -31,7 +30,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '84fce612f5b8',
'suggested_area': None,
'sw_version': '3.1.1',
'via_device_id': None,
})
@@ -58,7 +56,6 @@
'84fce612f5b8',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'AirGradient',
@@ -68,7 +65,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '84fce612f5b8',
'suggested_area': None,
'sw_version': '3.1.1',
'via_device_id': None,
})
@@ -0,0 +1,623 @@
# serializer version: 1
# name: test_diagnostics
dict({
'data': dict({
'chain_names': list([
dict({
'name': 'Chain 0',
'number': 1,
}),
dict({
'name': 'Chain 1',
'number': 2,
}),
]),
'derived': dict({
'mac': '**REDACTED**',
'mac_interface': 'br0',
}),
'firewall': dict({
'eb6tables': False,
'ebtables': False,
'ip6tables': False,
'iptables': False,
}),
'genuine': '/images/genuine.png',
'gps': dict({
'fix': 0,
'lat': '**REDACTED**',
'lon': '**REDACTED**',
}),
'host': dict({
'cpuload': 10.10101,
'device_id': '03aa0d0b40fed0a47088293584ef5432',
'devmodel': 'NanoStation 5AC loco',
'freeram': 16564224,
'fwversion': 'v8.7.17',
'height': 3,
'hostname': '**REDACTED**',
'loadavg': 0.412598,
'netrole': 'bridge',
'power_time': 268683,
'temperature': 0,
'time': '2025-06-23 23:06:42',
'timestamp': 2668313184,
'totalram': 63447040,
'uptime': 264888,
}),
'interfaces': list([
dict({
'enabled': True,
'hwaddr': '**REDACTED**',
'ifname': 'eth0',
'mtu': 1500,
'status': dict({
'cable_len': 18,
'duplex': True,
'ip6addr': None,
'ipaddr': '**REDACTED**',
'plugged': True,
'rx_bytes': 3984971949,
'rx_dropped': 0,
'rx_errors': 4,
'rx_packets': 73564835,
'snr': list([
30,
30,
30,
30,
]),
'speed': 1000,
'tx_bytes': 209900085624,
'tx_dropped': 10,
'tx_errors': 0,
'tx_packets': 185866883,
}),
}),
dict({
'enabled': True,
'hwaddr': '**REDACTED**',
'ifname': 'ath0',
'mtu': 1500,
'status': dict({
'cable_len': None,
'duplex': False,
'ip6addr': None,
'ipaddr': '**REDACTED**',
'plugged': False,
'rx_bytes': 206938324766,
'rx_dropped': 0,
'rx_errors': 0,
'rx_packets': 149767200,
'snr': None,
'speed': 0,
'tx_bytes': 5265602738,
'tx_dropped': 2005,
'tx_errors': 0,
'tx_packets': 52980390,
}),
}),
dict({
'enabled': True,
'hwaddr': '**REDACTED**',
'ifname': 'br0',
'mtu': 1500,
'status': dict({
'cable_len': None,
'duplex': False,
'ip6addr': '**REDACTED**',
'ipaddr': '**REDACTED**',
'plugged': True,
'rx_bytes': 204802727,
'rx_dropped': 0,
'rx_errors': 0,
'rx_packets': 1791592,
'snr': None,
'speed': 0,
'tx_bytes': 236295176,
'tx_dropped': 0,
'tx_errors': 0,
'tx_packets': 298119,
}),
}),
]),
'ntpclient': dict({
}),
'portfw': False,
'provmode': dict({
}),
'services': dict({
'airview': 2,
'dhcp6d_stateful': False,
'dhcpc': False,
'dhcpd': False,
'pppoe': False,
}),
'unms': dict({
'status': 0,
'timestamp': None,
}),
'wireless': dict({
'antenna_gain': 13,
'apmac': '**REDACTED**',
'aprepeater': False,
'band': 2,
'cac_state': 0,
'cac_timeout': 0,
'center1_freq': 5530,
'chanbw': 80,
'compat_11n': 0,
'count': 1,
'dfs': 1,
'distance': 0,
'essid': '**REDACTED**',
'frequency': 5500,
'hide_essid': 0,
'ieeemode': '11ACVHT80',
'mode': 'ap-ptp',
'noisef': -89,
'nol_state': 0,
'nol_timeout': 0,
'polling': dict({
'atpc_status': 2,
'cb_capacity': 593970,
'dl_capacity': 647400,
'ff_cap_rep': False,
'fixed_frame': False,
'gps_sync': False,
'rx_use': 42,
'tx_use': 6,
'ul_capacity': 540540,
'use': 48,
}),
'rstatus': 5,
'rx_chainmask': 3,
'rx_idx': 8,
'rx_nss': 2,
'security': 'WPA2',
'service': dict({
'link': 266003,
'time': 267181,
}),
'sta': list([
dict({
'airmax': dict({
'actual_priority': 0,
'atpc_status': 2,
'beam': 0,
'cb_capacity': 593970,
'desired_priority': 0,
'dl_capacity': 647400,
'rx': dict({
'cinr': 31,
'evm': list([
list([
31,
28,
33,
32,
32,
32,
31,
31,
31,
29,
30,
32,
30,
27,
34,
31,
31,
30,
32,
29,
31,
29,
31,
33,
31,
31,
32,
30,
31,
34,
33,
31,
30,
31,
30,
31,
31,
32,
31,
30,
33,
31,
30,
31,
27,
31,
30,
30,
30,
30,
30,
29,
32,
34,
31,
30,
28,
30,
29,
35,
31,
33,
32,
29,
]),
list([
34,
34,
35,
34,
35,
35,
34,
34,
34,
34,
34,
34,
34,
34,
35,
35,
34,
34,
35,
34,
33,
33,
35,
34,
34,
35,
34,
35,
34,
34,
35,
34,
34,
33,
34,
34,
34,
34,
34,
35,
35,
35,
34,
35,
33,
34,
34,
34,
34,
35,
35,
34,
34,
34,
34,
34,
34,
34,
34,
34,
34,
34,
35,
35,
]),
]),
'usage': 42,
}),
'tx': dict({
'cinr': 31,
'evm': list([
list([
32,
34,
28,
33,
35,
30,
31,
33,
30,
30,
32,
30,
29,
33,
31,
29,
33,
31,
31,
30,
33,
34,
33,
31,
33,
32,
32,
31,
29,
31,
30,
32,
31,
30,
29,
32,
31,
32,
31,
31,
32,
29,
31,
29,
30,
32,
32,
31,
32,
32,
33,
31,
28,
29,
31,
31,
33,
32,
33,
32,
32,
32,
31,
33,
]),
list([
37,
37,
37,
38,
38,
37,
36,
38,
38,
37,
37,
37,
37,
37,
39,
37,
37,
37,
37,
37,
37,
36,
37,
37,
37,
37,
37,
37,
37,
38,
37,
37,
38,
37,
37,
37,
38,
37,
38,
37,
37,
37,
37,
37,
36,
37,
37,
37,
37,
37,
37,
38,
37,
37,
38,
37,
36,
37,
37,
37,
37,
37,
37,
37,
]),
]),
'usage': 6,
}),
'ul_capacity': 540540,
}),
'airos_connected': True,
'cb_capacity_expect': 416000,
'chainrssi': list([
35,
32,
0,
]),
'distance': 1,
'dl_avg_linkscore': 100,
'dl_capacity_expect': 208000,
'dl_linkscore': 100,
'dl_rate_expect': 3,
'dl_signal_expect': -80,
'last_disc': 1,
'lastip': '**REDACTED**',
'mac': '**REDACTED**',
'noisefloor': -89,
'remote': dict({
'age': 1,
'airview': 2,
'antenna_gain': 13,
'cable_loss': 0,
'chainrssi': list([
33,
37,
0,
]),
'compat_11n': 0,
'cpuload': 43.564301,
'device_id': 'd4f4cdf82961e619328a8f72f8d7653b',
'distance': 1,
'ethlist': list([
dict({
'cable_len': 14,
'duplex': True,
'enabled': True,
'ifname': 'eth0',
'plugged': True,
'snr': list([
30,
30,
29,
30,
]),
'speed': 1000,
}),
]),
'freeram': 14290944,
'gps': dict({
'fix': 0,
'lat': '**REDACTED**',
'lon': '**REDACTED**',
}),
'height': 2,
'hostname': '**REDACTED**',
'ip6addr': '**REDACTED**',
'ipaddr': '**REDACTED**',
'mode': 'sta-ptp',
'netrole': 'bridge',
'noisefloor': -90,
'oob': False,
'platform': 'NanoStation 5AC loco',
'power_time': 268512,
'rssi': 38,
'rx_bytes': 3624206478,
'rx_chainmask': 3,
'rx_throughput': 251,
'service': dict({
'link': 265996,
'time': 267195,
}),
'signal': -58,
'sys_id': '0xe7fa',
'temperature': 0,
'time': '2025-06-23 23:13:54',
'totalram': 63447040,
'tx_bytes': 212308148210,
'tx_power': -4,
'tx_ratedata': list([
14,
4,
372,
2223,
4708,
4037,
8142,
485763,
29420892,
24748154,
]),
'tx_throughput': 16023,
'unms': dict({
'status': 0,
'timestamp': None,
}),
'uptime': 265320,
'version': 'WA.ar934x.v8.7.17.48152.250620.2132',
}),
'rssi': 37,
'rx_idx': 8,
'rx_nss': 2,
'signal': -59,
'stats': dict({
'rx_bytes': 206938324814,
'rx_packets': 149767200,
'rx_pps': 846,
'tx_bytes': 5265602739,
'tx_packets': 52980390,
'tx_pps': 0,
}),
'tx_idx': 9,
'tx_latency': 0,
'tx_lretries': 0,
'tx_nss': 2,
'tx_packets': 0,
'tx_ratedata': list([
175,
4,
47,
200,
673,
158,
163,
138,
68895,
19577430,
]),
'tx_sretries': 0,
'ul_avg_linkscore': 88,
'ul_capacity_expect': 624000,
'ul_linkscore': 86,
'ul_rate_expect': 8,
'ul_signal_expect': -55,
'uptime': 170281,
}),
]),
'sta_disconnected': list([
]),
'throughput': dict({
'rx': 9907,
'tx': 222,
}),
'tx_chainmask': 3,
'tx_idx': 9,
'tx_nss': 2,
'txpower': -3,
}),
}),
'entry_data': dict({
'host': '**REDACTED**',
'password': '**REDACTED**',
'username': 'ubnt',
}),
})
# ---
@@ -0,0 +1,32 @@
"""Diagnostic tests for airOS."""
from unittest.mock import MagicMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.airos.coordinator import AirOSData
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_airos_client: MagicMock,
mock_config_entry: MockConfigEntry,
ap_fixture: AirOSData,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
await setup_integration(hass, mock_config_entry)
assert (
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot
)
+18
View File
@@ -0,0 +1,18 @@
"""Common methods used across tests for air-Q."""
from aioairq import DeviceInfo
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
TEST_USER_DATA = {
CONF_IP_ADDRESS: "192.168.0.0",
CONF_PASSWORD: "password",
}
TEST_DEVICE_INFO = DeviceInfo(
id="id",
name="name",
model="model",
sw_version="sw",
hw_version="hw",
)
TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"}
+4 -13
View File
@@ -3,7 +3,7 @@
import logging
from unittest.mock import patch
from aioairq import DeviceInfo, InvalidAuth
from aioairq import InvalidAuth
from aiohttp.client_exceptions import ClientConnectionError
import pytest
@@ -13,25 +13,16 @@ from homeassistant.components.airq.const import (
CONF_RETURN_AVERAGE,
DOMAIN,
)
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .common import TEST_DEVICE_INFO, TEST_USER_DATA
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
TEST_USER_DATA = {
CONF_IP_ADDRESS: "192.168.0.0",
CONF_PASSWORD: "password",
}
TEST_DEVICE_INFO = DeviceInfo(
id="id",
name="name",
model="model",
sw_version="sw",
hw_version="hw",
)
DEFAULT_OPTIONS = {
CONF_CLIP_NEGATIVE: True,
CONF_RETURN_AVERAGE: True,
+2 -10
View File
@@ -3,7 +3,6 @@
import logging
from unittest.mock import patch
from aioairq import DeviceInfo as AirQDeviceInfo
import pytest
from homeassistant.components.airq import AirQCoordinator
@@ -12,9 +11,10 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from .common import TEST_DEVICE_DATA, TEST_DEVICE_INFO
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
MOCKED_ENTRY = MockConfigEntry(
domain=DOMAIN,
data={
@@ -24,14 +24,6 @@ MOCKED_ENTRY = MockConfigEntry(
unique_id="123-456",
)
TEST_DEVICE_INFO = AirQDeviceInfo(
id="id",
name="name",
model="model",
sw_version="sw",
hw_version="hw",
)
TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"}
STATUS_WARMUP = {
"co": "co sensor still in warm up phase; waiting time = 18 s",
"tvoc": "tvoc sensor still in warm up phase; waiting time = 18 s",
@@ -17,7 +17,6 @@
'echo_test_serial_number',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Amazon',
@@ -27,7 +26,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'echo_test_serial_number',
'suggested_area': None,
'sw_version': 'echo_test_software_version',
'via_device_id': None,
})
@@ -1051,7 +1051,6 @@ async def test_devices_payload(
"hw_version": "test-hw-version",
"integration": "hue",
"is_custom_integration": False,
"has_suggested_area": True,
"has_configuration_url": True,
"via_device": None,
},
@@ -1063,7 +1062,6 @@ async def test_devices_payload(
"hw_version": None,
"integration": "hue",
"is_custom_integration": False,
"has_suggested_area": False,
"has_configuration_url": False,
"via_device": 0,
},
@@ -17,7 +17,6 @@
'junctionId',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'A. O. Smith',
@@ -27,7 +26,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'serial',
'suggested_area': 'Basement',
'sw_version': '2.14',
'via_device_id': None,
})
@@ -17,7 +17,6 @@
'XXXXXXXXXXXX',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'APC',
@@ -27,7 +26,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': '3.14.14 (31 May 2016) unknown',
'via_device_id': None,
})
@@ -50,7 +48,6 @@
'XXXX',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'APC',
@@ -60,7 +57,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
})
@@ -83,7 +79,6 @@
'mocked-config-entry-id',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'APC',
@@ -93,7 +88,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
})
@@ -116,7 +110,6 @@
'mocked-config-entry-id',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'APC',
@@ -126,7 +119,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
})
@@ -17,7 +17,6 @@
'tmt100',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'August Home Inc.',
@@ -27,7 +26,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': 'tmt100 Name',
'sw_version': '3.1.0-HYDRC75+201909251139',
'via_device_id': None,
})
@@ -21,7 +21,6 @@
'online_with_doorsense',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'August Home Inc.',
@@ -31,7 +30,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': 'online_with_doorsense Name',
'sw_version': 'undefined-4.3.0-1.8.14',
'via_device_id': None,
})
@@ -21,7 +21,6 @@
'00:40:8c:12:34:56',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Axis Communications AB',
@@ -31,7 +30,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '00:40:8c:12:34:56',
'suggested_area': None,
'sw_version': '9.10.1',
'via_device_id': None,
})
@@ -58,7 +56,6 @@
'00:40:8c:12:34:56',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Axis Communications AB',
@@ -68,7 +65,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '00:40:8c:12:34:56',
'suggested_area': None,
'sw_version': '9.80.1',
'via_device_id': None,
})
+13 -5
View File
@@ -11,7 +11,11 @@ from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ASSUMED_STATE, CONF_ACCESS_TOKEN, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
)
from homeassistant.setup import async_setup_component
from .common import (
@@ -202,7 +206,9 @@ async def test_old_identifiers_are_removed(
async def test_smart_by_bond_device_suggested_area(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test we can setup a smart by bond device and get the suggested area."""
config_entry = MockConfigEntry(
@@ -241,11 +247,13 @@ async def test_smart_by_bond_device_suggested_area(
device = device_registry.async_get_device(identifiers={(DOMAIN, "KXXX12345")})
assert device is not None
assert device.suggested_area == "Den"
assert device.area_id == area_registry.async_get_area_by_name("Den").id
async def test_bridge_device_suggested_area(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test we can setup a bridge bond device and get the suggested area."""
config_entry = MockConfigEntry(
@@ -289,7 +297,7 @@ async def test_bridge_device_suggested_area(
device = device_registry.async_get_device(identifiers={(DOMAIN, "ZXXX12345")})
assert device is not None
assert device.suggested_area == "Office"
assert device.area_id == area_registry.async_get_area_by_name("Office").id
async def test_device_remove_devices(
+352 -18
View File
@@ -3,11 +3,11 @@
from ipaddress import ip_address
from unittest.mock import AsyncMock, MagicMock
from bsblan import BSBLANConnectionError
from bsblan import BSBLANAuthError, BSBLANConnectionError, BSBLANError
import pytest
from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -129,7 +129,7 @@ async def test_full_user_flow_implementation(
result = await _init_user_flow(hass)
_assert_form_result(result, "user")
result2 = await _configure_flow(
result = await _configure_flow(
hass,
result["flow_id"],
{
@@ -142,7 +142,7 @@ async def test_full_user_flow_implementation(
)
_assert_create_entry_result(
result2,
result,
format_mac("00:80:41:19:69:90"),
{
CONF_HOST: "127.0.0.1",
@@ -185,6 +185,94 @@ async def test_connection_error(
_assert_form_result(result, "user", {"base": "cannot_connect"})
async def test_authentication_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test we show user form on BSBLan authentication error with field preservation."""
mock_bsblan.device.side_effect = BSBLANAuthError
user_input = {
CONF_HOST: "192.168.1.100",
CONF_PORT: 8080,
CONF_PASSKEY: "secret",
CONF_USERNAME: "testuser",
CONF_PASSWORD: "wrongpassword",
}
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=user_input,
)
assert result.get("type") is FlowResultType.FORM
assert result.get("errors") == {"base": "invalid_auth"}
assert result.get("step_id") == "user"
# Verify that user input is preserved in the form
data_schema = result.get("data_schema")
assert data_schema is not None
# Check that the form fields contain the previously entered values
host_field = next(
field for field in data_schema.schema if field.schema == CONF_HOST
)
port_field = next(
field for field in data_schema.schema if field.schema == CONF_PORT
)
passkey_field = next(
field for field in data_schema.schema if field.schema == CONF_PASSKEY
)
username_field = next(
field for field in data_schema.schema if field.schema == CONF_USERNAME
)
password_field = next(
field for field in data_schema.schema if field.schema == CONF_PASSWORD
)
# The defaults are callable functions, so we need to call them
assert host_field.default() == "192.168.1.100"
assert port_field.default() == 8080
assert passkey_field.default() == "secret"
assert username_field.default() == "testuser"
assert password_field.default() == "wrongpassword"
async def test_authentication_error_vs_connection_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test that authentication and connection errors are handled differently."""
# Test connection error first
mock_bsblan.device.side_effect = BSBLANConnectionError
result = await _init_user_flow(
hass,
{
CONF_HOST: "127.0.0.1",
CONF_PORT: 80,
},
)
_assert_form_result(result, "user", {"base": "cannot_connect"})
# Reset and test authentication error
mock_bsblan.device.side_effect = BSBLANAuthError
result = await _init_user_flow(
hass,
{
CONF_HOST: "127.0.0.1",
CONF_PORT: 80,
CONF_USERNAME: "admin",
CONF_PASSWORD: "wrongpass",
},
)
_assert_form_result(result, "user", {"base": "invalid_auth"})
async def test_user_device_exists_abort(
hass: HomeAssistant,
mock_bsblan: MagicMock,
@@ -217,7 +305,7 @@ async def test_zeroconf_discovery(
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info)
_assert_form_result(result, "discovery_confirm")
result2 = await _configure_flow(
result = await _configure_flow(
hass,
result["flow_id"],
{
@@ -228,7 +316,7 @@ async def test_zeroconf_discovery(
)
_assert_create_entry_result(
result2,
result,
format_mac("00:80:41:19:69:90"),
{
CONF_HOST: "10.0.2.60",
@@ -285,7 +373,7 @@ async def test_zeroconf_discovery_no_mac_requires_auth(
# Reset side_effect for the second call to succeed
mock_bsblan.device.side_effect = None
result2 = await _configure_flow(
result = await _configure_flow(
hass,
result["flow_id"],
{
@@ -295,7 +383,7 @@ async def test_zeroconf_discovery_no_mac_requires_auth(
)
_assert_create_entry_result(
result2,
result,
"00:80:41:19:69:90", # MAC from fixture file
{
CONF_HOST: "10.0.2.60",
@@ -324,10 +412,10 @@ async def test_zeroconf_discovery_no_mac_no_auth_required(
_assert_form_result(result, "discovery_confirm")
# User confirms the discovery
result2 = await _configure_flow(hass, result["flow_id"], {})
result = await _configure_flow(hass, result["flow_id"], {})
_assert_create_entry_result(
result2,
result,
"00:80:41:19:69:90", # MAC from fixture file
{
CONF_HOST: "10.0.2.60",
@@ -355,7 +443,7 @@ async def test_zeroconf_discovery_connection_error(
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info)
_assert_form_result(result, "discovery_confirm")
result2 = await _configure_flow(
result = await _configure_flow(
hass,
result["flow_id"],
{
@@ -365,7 +453,7 @@ async def test_zeroconf_discovery_connection_error(
},
)
_assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"})
_assert_form_result(result, "discovery_confirm", {"base": "cannot_connect"})
async def test_zeroconf_discovery_updates_host_port_on_existing_entry(
@@ -445,7 +533,7 @@ async def test_zeroconf_discovery_connection_error_recovery(
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info)
_assert_form_result(result, "discovery_confirm")
result2 = await _configure_flow(
result = await _configure_flow(
hass,
result["flow_id"],
{
@@ -455,12 +543,12 @@ async def test_zeroconf_discovery_connection_error_recovery(
},
)
_assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"})
_assert_form_result(result, "discovery_confirm", {"base": "cannot_connect"})
# Second attempt succeeds (connection is fixed)
mock_bsblan.device.side_effect = None
result3 = await _configure_flow(
result = await _configure_flow(
hass,
result["flow_id"],
{
@@ -471,7 +559,7 @@ async def test_zeroconf_discovery_connection_error_recovery(
)
_assert_create_entry_result(
result3,
result,
format_mac("00:80:41:19:69:90"),
{
CONF_HOST: "10.0.2.60",
@@ -513,7 +601,7 @@ async def test_connection_error_recovery(
# Second attempt succeeds (connection is fixed)
mock_bsblan.device.side_effect = None
result2 = await _configure_flow(
result = await _configure_flow(
hass,
result["flow_id"],
{
@@ -526,7 +614,7 @@ async def test_connection_error_recovery(
)
_assert_create_entry_result(
result2,
result,
format_mac("00:80:41:19:69:90"),
{
CONF_HOST: "127.0.0.1",
@@ -567,3 +655,249 @@ async def test_zeroconf_discovery_no_mac_duplicate_host_port(
# Should not call device API since we abort early
assert len(mock_bsblan.device.mock_calls) == 0
async def test_reauth_flow_success(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test successful reauth flow."""
mock_config_entry.add_to_hass(hass)
# Start reauth flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
)
_assert_form_result(result, "reauth_confirm")
# Check that the form has the correct description placeholder
assert result.get("description_placeholders") == {"name": "BSBLAN Setup"}
# Check that existing values are preserved as defaults
data_schema = result.get("data_schema")
assert data_schema is not None
# Complete reauth with new credentials
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSKEY: "new_passkey",
CONF_USERNAME: "new_admin",
CONF_PASSWORD: "new_password",
},
)
_assert_abort_result(result, "reauth_successful")
# Verify config entry was updated with new credentials
assert mock_config_entry.data[CONF_PASSKEY] == "new_passkey"
assert mock_config_entry.data[CONF_USERNAME] == "new_admin"
assert mock_config_entry.data[CONF_PASSWORD] == "new_password"
# Verify host and port remain unchanged
assert mock_config_entry.data[CONF_HOST] == "127.0.0.1"
assert mock_config_entry.data[CONF_PORT] == 80
async def test_reauth_flow_auth_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth flow with authentication error."""
mock_config_entry.add_to_hass(hass)
# Mock authentication error
mock_bsblan.device.side_effect = BSBLANAuthError
# Start reauth flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
)
_assert_form_result(result, "reauth_confirm")
# Submit with wrong credentials
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSKEY: "wrong_passkey",
CONF_USERNAME: "wrong_admin",
CONF_PASSWORD: "wrong_password",
},
)
_assert_form_result(result, "reauth_confirm", {"base": "invalid_auth"})
# Verify that user input is preserved in the form after error
data_schema = result.get("data_schema")
assert data_schema is not None
# Check that the form fields contain the previously entered values
passkey_field = next(
field for field in data_schema.schema if field.schema == CONF_PASSKEY
)
username_field = next(
field for field in data_schema.schema if field.schema == CONF_USERNAME
)
assert passkey_field.default() == "wrong_passkey"
assert username_field.default() == "wrong_admin"
async def test_reauth_flow_connection_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth flow with connection error."""
mock_config_entry.add_to_hass(hass)
# Mock connection error
mock_bsblan.device.side_effect = BSBLANConnectionError
# Start reauth flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
)
_assert_form_result(result, "reauth_confirm")
# Submit credentials but get connection error
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
)
_assert_form_result(result, "reauth_confirm", {"base": "cannot_connect"})
async def test_reauth_flow_preserves_existing_values(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that reauth flow preserves existing values when user doesn't change them."""
mock_config_entry.add_to_hass(hass)
# Start reauth flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
)
_assert_form_result(result, "reauth_confirm")
# Submit without changing any credentials (only password is provided)
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSWORD: "new_password_only",
},
)
_assert_abort_result(result, "reauth_successful")
# Verify that existing passkey and username are preserved
assert mock_config_entry.data[CONF_PASSKEY] == "1234" # Original value
assert mock_config_entry.data[CONF_USERNAME] == "admin" # Original value
assert mock_config_entry.data[CONF_PASSWORD] == "new_password_only" # New value
async def test_reauth_flow_partial_credentials_update(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth flow with partial credential updates."""
mock_config_entry.add_to_hass(hass)
# Start reauth flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
)
# Submit with only username and password changes
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_USERNAME: "new_admin",
CONF_PASSWORD: "new_password",
},
)
_assert_abort_result(result, "reauth_successful")
# Verify partial update: passkey preserved, username and password updated
assert mock_config_entry.data[CONF_PASSKEY] == "1234" # Original preserved
assert mock_config_entry.data[CONF_USERNAME] == "new_admin" # Updated
assert mock_config_entry.data[CONF_PASSWORD] == "new_password" # Updated
# Host and port should remain unchanged
assert mock_config_entry.data[CONF_HOST] == "127.0.0.1"
assert mock_config_entry.data[CONF_PORT] == 80
async def test_zeroconf_discovery_auth_error_during_confirm(
hass: HomeAssistant,
mock_bsblan: MagicMock,
zeroconf_discovery_info: ZeroconfServiceInfo,
) -> None:
"""Test authentication error during discovery_confirm step."""
# Remove MAC from discovery to force discovery_confirm step
zeroconf_discovery_info.properties.pop("mac", None)
# Setup device to require authentication during initial discovery
mock_bsblan.device.side_effect = BSBLANError
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=zeroconf_discovery_info,
)
_assert_form_result(result, "discovery_confirm")
# Now setup auth error for the confirmation step
mock_bsblan.device.side_effect = BSBLANAuthError
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSKEY: "wrong_key",
CONF_USERNAME: "admin",
CONF_PASSWORD: "wrong_password",
},
)
# Should show the discovery_confirm form again with auth error
_assert_form_result(result, "discovery_confirm", {"base": "invalid_auth"})
+32 -2
View File
@@ -2,13 +2,14 @@
from unittest.mock import MagicMock
from bsblan import BSBLANConnectionError
from bsblan import BSBLANAuthError, BSBLANConnectionError
from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.bsblan.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_load_unload_config_entry(
@@ -45,3 +46,32 @@ async def test_config_entry_not_ready(
assert len(mock_bsblan.state.mock_calls) == 1
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_config_entry_auth_failed_triggers_reauth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_bsblan: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that BSBLANAuthError during coordinator update triggers reauth flow."""
# First, set up the integration successfully
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
# Mock BSBLANAuthError during next update
mock_bsblan.initialize.side_effect = BSBLANAuthError("Authentication failed")
# Advance time by the coordinator's update interval to trigger update
freezer.tick(delta=20) # Advance beyond the 12 second scan interval + random offset
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Check that a reauth flow has been started
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["context"]["source"] == "reauth"
assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id
@@ -17,7 +17,6 @@
'0020c2d8',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Cambridge Audio',
@@ -27,7 +26,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '0020c2d8',
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
})
-1
View File
@@ -236,7 +236,6 @@ async def test_legacy_subscription_repair_flow_timeout(
"handler": "cloud",
"reason": "operation_took_too_long",
"description_placeholders": None,
"result": None,
}
assert issue_registry.async_get_issue(
@@ -17,7 +17,6 @@
'01234E56789A',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Dresden Elektronik',
@@ -27,7 +26,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
})
@@ -21,7 +21,6 @@
'1234567890',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'devolo',
@@ -31,7 +30,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '1234567890',
'suggested_area': None,
'sw_version': '5.6.1',
'via_device_id': None,
})
@@ -58,7 +56,6 @@
'1234567890',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'devolo',
@@ -68,7 +65,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '1234567890',
'suggested_area': None,
'sw_version': '5.6.1',
'via_device_id': None,
})
@@ -91,7 +87,6 @@
'1234567890',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'devolo',
@@ -101,7 +96,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '1234567890',
'suggested_area': None,
'sw_version': '5.6.1',
'via_device_id': None,
})
@@ -17,7 +17,6 @@
'E1234567890000000001',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Ecovacs',
@@ -27,7 +26,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'E1234567890000000001',
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
})
@@ -70,7 +70,6 @@
'GW24L1A02987',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Elgato',
@@ -80,7 +79,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'GW24L1A02987',
'suggested_area': None,
'sw_version': '1.0.4 (229)',
'via_device_id': None,
})
@@ -156,7 +154,6 @@
'GW24L1A02987',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Elgato',
@@ -166,7 +163,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'GW24L1A02987',
'suggested_area': None,
'sw_version': '1.0.4 (229)',
'via_device_id': None,
})

Some files were not shown because too many files have changed in this diff Show More