Compare commits

...

48 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
236 changed files with 8772 additions and 2502 deletions

View File

@@ -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),
}

View File

@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info: todo
discovery: todo
docs-data-update: done

View File

@@ -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,
}

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,
}
)

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(

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": {

View File

@@ -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",

View File

@@ -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"]
}

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%]"
}
}
}

View File

@@ -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

View File

@@ -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"
],

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):

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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"),

View File

@@ -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,

View File

@@ -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"

View File

@@ -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):

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)

View File

@@ -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):

View File

@@ -13,5 +13,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "bronze",
"requirements": ["pylitterbot==2024.2.2"]
"requirements": ["pylitterbot==2024.2.3"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["aiomealie==0.10.0"]
"requirements": ["aiomealie==0.10.1"]
}

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

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": {

View File

@@ -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."""

View File

@@ -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()

View File

@@ -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"]
}

View File

@@ -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%]",

View File

@@ -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)

View File

@@ -158,6 +158,9 @@
"notify": {
"group_message": {
"name": "Group: {group_name}"
},
"direct_message": {
"name": "Direct message"
}
}
}

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))

View File

@@ -10,6 +10,7 @@ PLATFORMS: list[Platform] = [
Platform.COVER,
Platform.LIGHT,
Platform.SCENE,
Platform.SENSOR,
Platform.SWITCH,
]

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))

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."""

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))

View File

@@ -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",

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:

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

View File

@@ -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}."

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))

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%]",

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%]"
}
},

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%]"
}
},

View File

@@ -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

View File

@@ -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(

View File

@@ -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%]",

View File

@@ -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

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

View File

@@ -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,
)

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"]
}

View File

@@ -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."""

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,
)

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:

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()

View File

@@ -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,

View File

@@ -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

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."""

View File

@@ -676,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

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()

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(

View File

@@ -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

14
requirements_all.txt generated
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
@@ -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

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
@@ -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

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

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -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',
}),
})
# ---

View File

@@ -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
)

View File

@@ -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,
})

View File

@@ -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,
},

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -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,
})

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(

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"})

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

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -102,7 +102,6 @@
'CN11A1A00001',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Elgato',
@@ -112,7 +111,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'CN11A1A00001',
'suggested_area': None,
'sw_version': '1.0.3 (192)',
'via_device_id': None,
})
@@ -222,7 +220,6 @@
'CN11A1A00001',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Elgato',
@@ -232,7 +229,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'CN11A1A00001',
'suggested_area': None,
'sw_version': '1.0.3 (192)',
'via_device_id': None,
})
@@ -342,7 +338,6 @@
'CN11A1A00001',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Elgato',
@@ -352,7 +347,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'CN11A1A00001',
'suggested_area': None,
'sw_version': '1.0.3 (192)',
'via_device_id': None,
})

View File

@@ -77,7 +77,6 @@
'GW24L1A02987',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Elgato',
@@ -87,7 +86,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,
})
@@ -173,7 +171,6 @@
'GW24L1A02987',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Elgato',
@@ -183,7 +180,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,
})
@@ -269,7 +265,6 @@
'GW24L1A02987',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Elgato',
@@ -279,7 +274,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,
})
@@ -362,7 +356,6 @@
'GW24L1A02987',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Elgato',
@@ -372,7 +365,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,
})
@@ -458,7 +450,6 @@
'GW24L1A02987',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Elgato',
@@ -468,7 +459,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,
})

View File

@@ -69,7 +69,6 @@
'GW24L1A02987',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Elgato',
@@ -79,7 +78,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,
})
@@ -154,7 +152,6 @@
'GW24L1A02987',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Elgato',
@@ -164,7 +161,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,
})

View File

@@ -50,7 +50,6 @@
'<<envoyserial>>',
]),
]),
'is_new': False,
'labels': list([
]),
'manufacturer': 'Enphase',
@@ -60,7 +59,6 @@
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '<<envoyserial>>',
'suggested_area': None,
'sw_version': '7.6.175',
}),
'entities': list([
@@ -298,7 +296,6 @@
'1',
]),
]),
'is_new': False,
'labels': list([
]),
'manufacturer': 'Enphase',
@@ -308,7 +305,6 @@
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'suggested_area': None,
'sw_version': None,
}),
'entities': list([
@@ -929,7 +925,6 @@
'<<envoyserial>>',
]),
]),
'is_new': False,
'labels': list([
]),
'manufacturer': 'Enphase',
@@ -939,7 +934,6 @@
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '<<envoyserial>>',
'suggested_area': None,
'sw_version': '7.6.175',
}),
'entities': list([
@@ -1177,7 +1171,6 @@
'1',
]),
]),
'is_new': False,
'labels': list([
]),
'manufacturer': 'Enphase',
@@ -1187,7 +1180,6 @@
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'suggested_area': None,
'sw_version': None,
}),
'entities': list([
@@ -1852,7 +1844,6 @@
'<<envoyserial>>',
]),
]),
'is_new': False,
'labels': list([
]),
'manufacturer': 'Enphase',
@@ -1862,7 +1853,6 @@
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '<<envoyserial>>',
'suggested_area': None,
'sw_version': '7.6.175',
}),
'entities': list([
@@ -2100,7 +2090,6 @@
'1',
]),
]),
'is_new': False,
'labels': list([
]),
'manufacturer': 'Enphase',
@@ -2110,7 +2099,6 @@
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'suggested_area': None,
'sw_version': None,
}),
'entities': list([
@@ -2796,7 +2784,6 @@
'1',
]),
]),
'is_new': False,
'labels': list([
]),
'manufacturer': 'Enphase',
@@ -2806,7 +2793,6 @@
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '1',
'suggested_area': None,
'sw_version': None,
}),
'entities': list([
@@ -3346,7 +3332,6 @@
'<<envoyserial>>',
]),
]),
'is_new': False,
'labels': list([
]),
'manufacturer': 'Enphase',
@@ -3356,7 +3341,6 @@
'name_by_user': None,
'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72',
'serial_number': '<<envoyserial>>',
'suggested_area': None,
'sw_version': '7.6.175',
}),
'entities': list([

View File

@@ -50,6 +50,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
issue_registry as ir,
@@ -1170,6 +1171,7 @@ async def test_esphome_user_services_changes(
async def test_esphome_device_with_suggested_area(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
@@ -1184,11 +1186,12 @@ async def test_esphome_device_with_suggested_area(
dev = device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}
)
assert dev.suggested_area == "kitchen"
assert dev.area_id == area_registry.async_get_area_by_name("kitchen").id
async def test_esphome_device_area_priority(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
@@ -1207,7 +1210,7 @@ async def test_esphome_device_area_priority(
connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}
)
# Should use device_info.area.name instead of suggested_area
assert dev.suggested_area == "Living Room"
assert dev.area_id == area_registry.async_get_area_by_name("Living Room").id
async def test_esphome_device_with_project(
@@ -1535,6 +1538,7 @@ async def test_assist_in_progress_issue_deleted(
async def test_sub_device_creation(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
@@ -1571,7 +1575,7 @@ async def test_sub_device_creation(
connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)}
)
assert main_device is not None
assert main_device.suggested_area == "Main Hub"
assert main_device.area_id == area_registry.async_get_area_by_name("Main Hub").id
# Check sub devices are created
sub_device_1 = device_registry.async_get_device(
@@ -1579,7 +1583,9 @@ async def test_sub_device_creation(
)
assert sub_device_1 is not None
assert sub_device_1.name == "Motion Sensor"
assert sub_device_1.suggested_area == "Living Room"
assert (
sub_device_1.area_id == area_registry.async_get_area_by_name("Living Room").id
)
assert sub_device_1.via_device_id == main_device.id
sub_device_2 = device_registry.async_get_device(
@@ -1587,7 +1593,9 @@ async def test_sub_device_creation(
)
assert sub_device_2 is not None
assert sub_device_2.name == "Light Switch"
assert sub_device_2.suggested_area == "Living Room"
assert (
sub_device_2.area_id == area_registry.async_get_area_by_name("Living Room").id
)
assert sub_device_2.via_device_id == main_device.id
sub_device_3 = device_registry.async_get_device(
@@ -1595,7 +1603,7 @@ async def test_sub_device_creation(
)
assert sub_device_3 is not None
assert sub_device_3.name == "Temperature Sensor"
assert sub_device_3.suggested_area == "Bedroom"
assert sub_device_3.area_id == area_registry.async_get_area_by_name("Bedroom").id
assert sub_device_3.via_device_id == main_device.id
@@ -1731,6 +1739,7 @@ async def test_sub_device_with_empty_name(
async def test_sub_device_references_main_device_area(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
@@ -1772,28 +1781,34 @@ async def test_sub_device_references_main_device_area(
connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)}
)
assert main_device is not None
assert main_device.suggested_area == "Main Hub Area"
assert (
main_device.area_id == area_registry.async_get_area_by_name("Main Hub Area").id
)
# Check sub device 1 uses main device's area
sub_device_1 = device_registry.async_get_device(
identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")}
)
assert sub_device_1 is not None
assert sub_device_1.suggested_area == "Main Hub Area"
assert (
sub_device_1.area_id == area_registry.async_get_area_by_name("Main Hub Area").id
)
# Check sub device 2 uses Living Room
sub_device_2 = device_registry.async_get_device(
identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")}
)
assert sub_device_2 is not None
assert sub_device_2.suggested_area == "Living Room"
assert (
sub_device_2.area_id == area_registry.async_get_area_by_name("Living Room").id
)
# Check sub device 3 uses Bedroom
sub_device_3 = device_registry.async_get_device(
identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")}
)
assert sub_device_3 is not None
assert sub_device_3.suggested_area == "Bedroom"
assert sub_device_3.area_id == area_registry.async_get_area_by_name("Bedroom").id
@patch("homeassistant.components.esphome.manager.secrets.token_bytes")

View File

@@ -22,7 +22,6 @@
'98765',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Flo by Moen',
@@ -32,7 +31,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '111111111111',
'suggested_area': None,
'sw_version': '6.1.1',
'via_device_id': None,
}),
@@ -57,7 +55,6 @@
'32839',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Flo by Moen',
@@ -67,7 +64,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '111111111112',
'suggested_area': None,
'sw_version': '1.1.15',
'via_device_id': None,
}),

View File

@@ -21,7 +21,6 @@
'00000000-0000-0000-0000-000000000001',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': None,
@@ -31,7 +30,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': '1.2.3',
'via_device_id': None,
})

View File

@@ -18,7 +18,6 @@
'ulid-conversation',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Google',
@@ -28,7 +27,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
}),
@@ -49,7 +47,6 @@
'ulid-stt',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Google',
@@ -59,7 +56,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
}),
@@ -80,7 +76,6 @@
'ulid-ai-task',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Google',
@@ -90,7 +85,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
}),
@@ -111,7 +105,6 @@
'ulid-tts',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Google',
@@ -121,7 +114,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
}),

View File

@@ -55,6 +55,10 @@ async def test_hassio_system_health(
hass.data["hassio_network_info"] = {
"host_internet": True,
"supervisor_internet": True,
"interfaces": [
{"primary": False, "ipv4": {"nameservers": ["9.9.9.9"]}},
{"primary": True, "ipv4": {"nameservers": ["1.1.1.1"]}},
],
}
with patch.dict(os.environ, MOCK_ENVIRON):
@@ -76,6 +80,7 @@ async def test_hassio_system_health(
"host_os": "Home Assistant OS 5.9",
"installed_addons": "Awesome Addon (1.0.0)",
"ntp_synchronized": True,
"nameservers": "1.1.1.1",
"supervisor_api": "ok",
"supervisor_version": "supervisor-2020.11.1",
"supported": True,

View File

@@ -21,7 +21,6 @@
'00055511EECC',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'homee',
@@ -31,7 +30,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': '1.2.3',
'via_device_id': None,
})
@@ -54,7 +52,6 @@
'00055511EECC-3',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': None,
@@ -64,7 +61,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': '4.54',
'via_device_id': <ANY>,
})

View File

@@ -2,6 +2,7 @@
from datetime import timedelta
from freezegun import freeze_time
import pytest
from homeassistant.components.homekit.const import (
@@ -22,6 +23,10 @@ from homeassistant.components.homekit.type_switches import (
Valve,
ValveSwitch,
)
from homeassistant.components.input_number import (
DOMAIN as INPUT_NUMBER_DOMAIN,
SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE,
)
from homeassistant.components.lawn_mower import (
DOMAIN as LAWN_MOWER_DOMAIN,
SERVICE_DOCK,
@@ -30,6 +35,7 @@ from homeassistant.components.lawn_mower import (
LawnMowerEntityFeature,
)
from homeassistant.components.select import ATTR_OPTIONS
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.vacuum import (
DOMAIN as VACUUM_DOMAIN,
SERVICE_RETURN_TO_BASE,
@@ -658,3 +664,223 @@ async def test_button_switch(
await hass.async_block_till_done()
assert acc.char_on.value is False
assert len(events) == 1
async def test_valve_switch_with_set_duration_characteristic(
hass: HomeAssistant, hk_driver, events: list[Event]
) -> None:
"""Test valve switch with set duration characteristic."""
entity_id = "switch.sprinkler"
hass.states.async_set(entity_id, STATE_OFF)
hass.states.async_set("input_number.valve_duration", "0")
await hass.async_block_till_done()
# Mock switch services to prevent errors
async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_ON)
async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_OFF)
acc = ValveSwitch(
hass,
hk_driver,
"Sprinkler",
entity_id,
5,
{"type": "sprinkler", "linked_valve_duration": "input_number.valve_duration"},
)
acc.run()
await hass.async_block_till_done()
# Assert initial state is synced
assert acc.get_duration() == 0
# Simulate setting duration from HomeKit
call_set_value = async_mock_service(
hass, INPUT_NUMBER_DOMAIN, INPUT_NUMBER_SERVICE_SET_VALUE
)
acc.char_set_duration.client_update_value(300)
await hass.async_block_till_done()
assert call_set_value
assert call_set_value[0].data == {
"entity_id": "input_number.valve_duration",
"value": 300,
}
# Assert state change in Home Assistant is synced to HomeKit
hass.states.async_set("input_number.valve_duration", "600")
await hass.async_block_till_done()
assert acc.get_duration() == 600
# Test fallback if no state is set
hass.states.async_remove("input_number.valve_duration")
await hass.async_block_till_done()
assert acc.get_duration() == 0
# Test remaining duration fallback if no end time is linked
assert acc.get_remaining_duration() == 0
async def test_valve_switch_with_remaining_duration_characteristic(
hass: HomeAssistant, hk_driver, events: list[Event]
) -> None:
"""Test valve switch with remaining duration characteristic."""
entity_id = "switch.sprinkler"
hass.states.async_set(entity_id, STATE_OFF)
hass.states.async_set("sensor.valve_end_time", dt_util.utcnow().isoformat())
await hass.async_block_till_done()
# Mock switch services to prevent errors
async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_ON)
async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_OFF)
acc = ValveSwitch(
hass,
hk_driver,
"Sprinkler",
entity_id,
5,
{"type": "sprinkler", "linked_valve_end_time": "sensor.valve_end_time"},
)
acc.run()
await hass.async_block_till_done()
# Assert initial state is synced
assert acc.get_remaining_duration() == 0
# Simulate remaining duration update from Home Assistant
with freeze_time(dt_util.utcnow()):
hass.states.async_set(
"sensor.valve_end_time",
(dt_util.utcnow() + timedelta(seconds=90)).isoformat(),
)
await hass.async_block_till_done()
# Assert remaining duration is calculated correctly based on end time
assert acc.get_remaining_duration() == 90
# Test fallback if no state is set
hass.states.async_remove("sensor.valve_end_time")
await hass.async_block_till_done()
assert acc.get_remaining_duration() == 0
# Test get duration fallback if no duration is linked
assert acc.get_duration() == 0
async def test_valve_switch_with_duration_characteristics(
hass: HomeAssistant, hk_driver, events: list[Event]
) -> None:
"""Test valve switch with set duration and remaining duration characteristics."""
entity_id = "switch.sprinkler"
# Test with duration and end time entities linked
hass.states.async_set(entity_id, STATE_OFF)
hass.states.async_set("input_number.valve_duration", "300")
hass.states.async_set("sensor.valve_end_time", dt_util.utcnow().isoformat())
await hass.async_block_till_done()
# Mock switch services to prevent errors
async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_ON)
async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_OFF)
# Mock input_number service for set_duration calls
call_set_value = async_mock_service(
hass, INPUT_NUMBER_DOMAIN, INPUT_NUMBER_SERVICE_SET_VALUE
)
acc = ValveSwitch(
hass,
hk_driver,
"Sprinkler",
entity_id,
5,
{
"type": "sprinkler",
"linked_valve_duration": "input_number.valve_duration",
"linked_valve_end_time": "sensor.valve_end_time",
},
)
acc.run()
await hass.async_block_till_done()
# Test update_duration_chars with both characteristics
with freeze_time(dt_util.utcnow()):
hass.states.async_set(
"sensor.valve_end_time",
(dt_util.utcnow() + timedelta(seconds=60)).isoformat(),
)
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
assert acc.char_set_duration.value == 300
assert acc.get_remaining_duration() == 60
# Test get_duration fallback with invalid state
hass.states.async_set("input_number.valve_duration", "invalid")
await hass.async_block_till_done()
assert acc.get_duration() == 0
# Test get_remaining_duration fallback with invalid state
hass.states.async_set("sensor.valve_end_time", "invalid")
await hass.async_block_till_done()
assert acc.get_remaining_duration() == 0
# Test get_remaining_duration with end time in the past
hass.states.async_set(
"sensor.valve_end_time",
(dt_util.utcnow() - timedelta(seconds=10)).isoformat(),
)
await hass.async_block_till_done()
assert acc.get_remaining_duration() == 0
# Test set_duration with negative value
acc.set_duration(-10)
await hass.async_block_till_done()
assert acc.get_duration() == 0
# Verify the service was called with correct parameters
assert len(call_set_value) == 1
assert call_set_value[0].data == {
"entity_id": "input_number.valve_duration",
"value": -10,
}
# Test set_duration with negative state
hass.states.async_set("sensor.valve_duration", -10)
await hass.async_block_till_done()
assert acc.get_duration() == 0
async def test_valve_with_duration_characteristics(
hass: HomeAssistant, hk_driver, events: list[Event]
) -> None:
"""Test valve with set duration and remaining duration characteristics."""
entity_id = "switch.sprinkler"
# Test with duration and end time entities linked
hass.states.async_set(entity_id, STATE_OFF)
hass.states.async_set("input_number.valve_duration", "900")
hass.states.async_set("sensor.valve_end_time", dt_util.utcnow().isoformat())
await hass.async_block_till_done()
# Using Valve instead of ValveSwitch
acc = Valve(
hass,
hk_driver,
"Valve",
entity_id,
5,
{
"linked_valve_duration": "input_number.valve_duration",
"linked_valve_end_time": "sensor.valve_end_time",
},
)
acc.run()
await hass.async_block_till_done()
with freeze_time(dt_util.utcnow()):
hass.states.async_set(
"sensor.valve_end_time",
(dt_util.utcnow() + timedelta(seconds=600)).isoformat(),
)
await hass.async_block_till_done()
assert acc.get_duration() == 900
assert acc.get_remaining_duration() == 600

View File

@@ -15,6 +15,8 @@ from homeassistant.components.homekit.const import (
CONF_LINKED_BATTERY_SENSOR,
CONF_LINKED_DOORBELL_SENSOR,
CONF_LINKED_MOTION_SENSOR,
CONF_LINKED_VALVE_DURATION,
CONF_LINKED_VALVE_END_TIME,
CONF_LOW_BATTERY_THRESHOLD,
CONF_MAX_FPS,
CONF_MAX_HEIGHT,
@@ -128,7 +130,25 @@ def test_validate_entity_config() -> None:
}
},
{"switch.test": {CONF_TYPE: "invalid_type"}},
{
"switch.test": {
CONF_TYPE: "sprinkler",
CONF_LINKED_VALVE_DURATION: "number.valve_duration", # Must be input_number entity
CONF_LINKED_VALVE_END_TIME: "datetime.valve_end_time", # Must be sensor (timestamp) entity
}
},
{"fan.test": {CONF_TYPE: "invalid_type"}},
{
"valve.test": {
CONF_LINKED_VALVE_END_TIME: "datetime.valve_end_time", # Must be sensor (timestamp) entity
CONF_LINKED_VALVE_DURATION: "number.valve_duration", # Must be input_number
}
},
{
"valve.test": {
CONF_TYPE: "sprinkler", # Extra keys not allowed
}
},
]
for conf in configs:
@@ -212,6 +232,19 @@ def test_validate_entity_config() -> None:
assert vec({"switch.demo": {CONF_TYPE: TYPE_VALVE}}) == {
"switch.demo": {CONF_TYPE: TYPE_VALVE, CONF_LOW_BATTERY_THRESHOLD: 20}
}
config = {
CONF_TYPE: TYPE_SPRINKLER,
CONF_LINKED_VALVE_DURATION: "input_number.valve_duration",
CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time",
}
assert vec({"switch.sprinkler": config}) == {
"switch.sprinkler": {
CONF_TYPE: TYPE_SPRINKLER,
CONF_LINKED_VALVE_DURATION: "input_number.valve_duration",
CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time",
CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD,
}
}
assert vec({"sensor.co": {CONF_THRESHOLD_CO: 500}}) == {
"sensor.co": {CONF_THRESHOLD_CO: 500, CONF_LOW_BATTERY_THRESHOLD: 20}
}
@@ -244,6 +277,17 @@ def test_validate_entity_config() -> None:
CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD,
}
}
config = {
CONF_LINKED_VALVE_DURATION: "input_number.valve_duration",
CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time",
}
assert vec({"valve.demo": config}) == {
"valve.demo": {
CONF_LINKED_VALVE_DURATION: "input_number.valve_duration",
CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time",
CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD,
}
}
def test_validate_media_player_features() -> None:

File diff suppressed because it is too large Load Diff

View File

@@ -328,6 +328,9 @@ async def test_snapshots(
device_dict.pop("created_at", None)
device_dict.pop("modified_at", None)
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")
devices.append({"device": device_dict, "entities": entities})

View File

@@ -365,14 +365,16 @@ async def test_hmip_garage_door_tormatic(
assert ha_state.state == "closed"
assert ha_state.attributes["current_position"] == 0
service_call_counter = len(hmip_device.mock_calls)
service_call_counter = len(hmip_device.functionalChannels[1].mock_calls)
await hass.services.async_call(
"cover", "open_cover", {"entity_id": entity_id}, blocking=True
)
assert len(hmip_device.mock_calls) == service_call_counter + 1
assert hmip_device.mock_calls[-1][0] == "send_door_command_async"
assert hmip_device.mock_calls[-1][1] == (DoorCommand.OPEN,)
assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1
assert (
hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command"
)
assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.OPEN,)
await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.OPEN)
ha_state = hass.states.get(entity_id)
assert ha_state.state == CoverState.OPEN
@@ -381,9 +383,11 @@ async def test_hmip_garage_door_tormatic(
await hass.services.async_call(
"cover", "close_cover", {"entity_id": entity_id}, blocking=True
)
assert len(hmip_device.mock_calls) == service_call_counter + 3
assert hmip_device.mock_calls[-1][0] == "send_door_command_async"
assert hmip_device.mock_calls[-1][1] == (DoorCommand.CLOSE,)
assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2
assert (
hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command"
)
assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.CLOSE,)
await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.CLOSED)
ha_state = hass.states.get(entity_id)
assert ha_state.state == CoverState.CLOSED
@@ -392,9 +396,11 @@ async def test_hmip_garage_door_tormatic(
await hass.services.async_call(
"cover", "stop_cover", {"entity_id": entity_id}, blocking=True
)
assert len(hmip_device.mock_calls) == service_call_counter + 5
assert hmip_device.mock_calls[-1][0] == "send_door_command_async"
assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,)
assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 3
assert (
hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command"
)
assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.STOP,)
async def test_hmip_garage_door_hoermann(
@@ -414,14 +420,16 @@ async def test_hmip_garage_door_hoermann(
assert ha_state.state == "closed"
assert ha_state.attributes["current_position"] == 0
service_call_counter = len(hmip_device.mock_calls)
service_call_counter = len(hmip_device.functionalChannels[1].mock_calls)
await hass.services.async_call(
"cover", "open_cover", {"entity_id": entity_id}, blocking=True
)
assert len(hmip_device.mock_calls) == service_call_counter + 1
assert hmip_device.mock_calls[-1][0] == "send_door_command_async"
assert hmip_device.mock_calls[-1][1] == (DoorCommand.OPEN,)
assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1
assert (
hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command"
)
assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.OPEN,)
await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.OPEN)
ha_state = hass.states.get(entity_id)
assert ha_state.state == CoverState.OPEN
@@ -430,9 +438,11 @@ async def test_hmip_garage_door_hoermann(
await hass.services.async_call(
"cover", "close_cover", {"entity_id": entity_id}, blocking=True
)
assert len(hmip_device.mock_calls) == service_call_counter + 3
assert hmip_device.mock_calls[-1][0] == "send_door_command_async"
assert hmip_device.mock_calls[-1][1] == (DoorCommand.CLOSE,)
assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2
assert (
hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command"
)
assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.CLOSE,)
await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.CLOSED)
ha_state = hass.states.get(entity_id)
assert ha_state.state == CoverState.CLOSED
@@ -441,9 +451,11 @@ async def test_hmip_garage_door_hoermann(
await hass.services.async_call(
"cover", "stop_cover", {"entity_id": entity_id}, blocking=True
)
assert len(hmip_device.mock_calls) == service_call_counter + 5
assert hmip_device.mock_calls[-1][0] == "send_door_command_async"
assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,)
assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 3
assert (
hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command"
)
assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.STOP,)
async def test_hmip_cover_shutter_group(

View File

@@ -70,7 +70,6 @@
'5c2fafabcdef',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'HomeWizard',
@@ -80,7 +79,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': '4.19',
'via_device_id': None,
})

View File

@@ -79,7 +79,6 @@
'5c2fafabcdef',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'HomeWizard',
@@ -89,7 +88,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': '3.03',
'via_device_id': None,
})
@@ -174,7 +172,6 @@
'5c2fafabcdef',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'HomeWizard',
@@ -184,7 +181,6 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': '4.07',
'via_device_id': None,
})

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