mirror of
https://github.com/home-assistant/core.git
synced 2026-01-01 04:38:04 +00:00
Compare commits
48 Commits
copilot/fi
...
trigger_de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6caa86ab3 | ||
|
|
4318e29ce8 | ||
|
|
fea5c63bba | ||
|
|
b2349ac2bd | ||
|
|
08f7b708a4 | ||
|
|
1236801b7d | ||
|
|
72d9dbf39d | ||
|
|
755864f9f3 | ||
|
|
fa476d4e34 | ||
|
|
018197e41a | ||
|
|
7dd2b9e422 | ||
|
|
3e615fd373 | ||
|
|
c0bf167e10 | ||
|
|
45f6778ff4 | ||
|
|
bddd4d621a | ||
|
|
b0e75e9ee4 | ||
|
|
d45c03a795 | ||
|
|
8562c8d32f | ||
|
|
ae42d71123 | ||
|
|
9616c8cd7b | ||
|
|
9394546668 | ||
|
|
d43f21c2e2 | ||
|
|
8d68fee9f8 | ||
|
|
b4a4e218ec | ||
|
|
fb2d62d692 | ||
|
|
f538807d6e | ||
|
|
a08c3c9f44 | ||
|
|
506431c75f | ||
|
|
37579440e6 | ||
|
|
5ce2729dc2 | ||
|
|
b5e4ae4a53 | ||
|
|
3d4386ea6d | ||
|
|
9f1cec893e | ||
|
|
bc87140a6f | ||
|
|
d77a3fca83 | ||
|
|
924a86dfb6 | ||
|
|
0d7608f7c5 | ||
|
|
22e054f4cd | ||
|
|
8b53b26333 | ||
|
|
4d59e8cd80 | ||
|
|
61396d92a5 | ||
|
|
c72c600de4 | ||
|
|
b86b0c10bd | ||
|
|
eb222f6c5d | ||
|
|
4b5fe424ed | ||
|
|
61ca42e923 | ||
|
|
21c1427abf | ||
|
|
aa6b37bc7c |
33
homeassistant/components/airos/diagnostics.py
Normal file
33
homeassistant/components/airos/diagnostics.py
Normal 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),
|
||||
}
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
|
||||
@@ -430,7 +430,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
"model": device.model,
|
||||
"sw_version": device.sw_version,
|
||||
"hw_version": device.hw_version,
|
||||
"has_suggested_area": device.suggested_area is not None,
|
||||
"has_configuration_url": device.configuration_url is not None,
|
||||
"via_device": None,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/denonavr",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["denonavr"],
|
||||
"requirements": ["denonavr==1.1.1"],
|
||||
"requirements": ["denonavr==1.1.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Denon",
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/emoncms",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pyemoncms==0.1.1"]
|
||||
"requirements": ["pyemoncms==0.1.2"]
|
||||
}
|
||||
|
||||
@@ -12,12 +12,26 @@
|
||||
},
|
||||
"data_description": {
|
||||
"url": "Server URL starting with the protocol (http or https)",
|
||||
"api_key": "Your 32 bits API key"
|
||||
"api_key": "Your 32 bits API key",
|
||||
"sync_mode": "Pick your feeds manually (default) or synchronize them at once"
|
||||
}
|
||||
},
|
||||
"choose_feeds": {
|
||||
"data": {
|
||||
"include_only_feed_id": "Choose feeds to include"
|
||||
},
|
||||
"data_description": {
|
||||
"include_only_feed_id": "Pick the feeds you want to synchronize"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"url": "[%key:component::emoncms::config::step::user::data_description::url%]",
|
||||
"api_key": "[%key:component::emoncms::config::step::user::data_description::api_key%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -30,8 +44,8 @@
|
||||
"selector": {
|
||||
"sync_mode": {
|
||||
"options": {
|
||||
"auto": "Synchronize all available Feeds",
|
||||
"manual": "Select which Feeds to synchronize"
|
||||
"auto": "Synchronize all available feeds",
|
||||
"manual": "Select which feeds to synchronize"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -89,6 +103,9 @@
|
||||
"init": {
|
||||
"data": {
|
||||
"include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data::include_only_feed_id%]"
|
||||
},
|
||||
"data_description": {
|
||||
"include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data_description::include_only_feed_id%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,9 @@ async def async_get_config_entry_diagnostics(
|
||||
entities.append({"entity": entity_dict, "state": state_dict})
|
||||
device_dict = asdict(device)
|
||||
device_dict.pop("_cache", None)
|
||||
# This can be removed when suggested_area is removed from DeviceEntry
|
||||
device_dict.pop("_suggested_area")
|
||||
device_dict.pop("is_new", None)
|
||||
device_entities.append({"device": device_dict, "entities": entities})
|
||||
|
||||
# remove envoy serial
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==37.2.0",
|
||||
"aioesphomeapi==37.2.2",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.1.0"
|
||||
],
|
||||
|
||||
@@ -66,6 +66,26 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
|
||||
key="last_alarm_type_name",
|
||||
translation_key="last_alarm_type_name",
|
||||
),
|
||||
"Record_Mode": SensorEntityDescription(
|
||||
key="Record_Mode",
|
||||
translation_key="record_mode",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"battery_camera_work_mode": SensorEntityDescription(
|
||||
key="battery_camera_work_mode",
|
||||
translation_key="battery_camera_work_mode",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"powerStatus": SensorEntityDescription(
|
||||
key="powerStatus",
|
||||
translation_key="power_status",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"OnlineStatus": SensorEntityDescription(
|
||||
key="OnlineStatus",
|
||||
translation_key="online_status",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -76,16 +96,26 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up EZVIZ sensors based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[EzvizSensor] = []
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
for camera, sensors in coordinator.data.items():
|
||||
entities.extend(
|
||||
EzvizSensor(coordinator, camera, sensor)
|
||||
for camera in coordinator.data
|
||||
for sensor, value in coordinator.data[camera].items()
|
||||
if sensor in SENSOR_TYPES
|
||||
if value is not None
|
||||
]
|
||||
)
|
||||
for sensor, value in sensors.items()
|
||||
if sensor in SENSOR_TYPES and value is not None
|
||||
)
|
||||
|
||||
optionals = sensors.get("optionals", {})
|
||||
entities.extend(
|
||||
EzvizSensor(coordinator, camera, optional_key)
|
||||
for optional_key in ("powerStatus", "OnlineStatus")
|
||||
if optional_key in optionals
|
||||
)
|
||||
|
||||
if "mode" in optionals.get("Record_Mode", {}):
|
||||
entities.append(EzvizSensor(coordinator, camera, "mode"))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class EzvizSensor(EzvizEntity, SensorEntity):
|
||||
|
||||
@@ -147,6 +147,18 @@
|
||||
},
|
||||
"last_alarm_type_name": {
|
||||
"name": "Last alarm type name"
|
||||
},
|
||||
"record_mode": {
|
||||
"name": "Record mode"
|
||||
},
|
||||
"battery_camera_work_mode": {
|
||||
"name": "Battery work mode"
|
||||
},
|
||||
"power_status": {
|
||||
"name": "Power status"
|
||||
},
|
||||
"online_status": {
|
||||
"name": "Online status"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"healthy": "Healthy",
|
||||
"host_os": "Host operating system",
|
||||
"installed_addons": "Installed add-ons",
|
||||
"nameservers": "Nameservers",
|
||||
"supervisor_api": "Supervisor API",
|
||||
"supervisor_version": "Supervisor version",
|
||||
"supported": "Supported",
|
||||
|
||||
@@ -54,6 +54,15 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"error": "Unsupported",
|
||||
}
|
||||
|
||||
nameservers = set()
|
||||
for interface in network_info.get("interfaces", []):
|
||||
if not interface.get("primary"):
|
||||
continue
|
||||
if ipv4 := interface.get("ipv4"):
|
||||
nameservers.update(ipv4.get("nameservers", []))
|
||||
if ipv6 := interface.get("ipv6"):
|
||||
nameservers.update(ipv6.get("nameservers", []))
|
||||
|
||||
information = {
|
||||
"host_os": host_info.get("operating_system"),
|
||||
"update_channel": info.get("channel"),
|
||||
@@ -62,6 +71,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"docker_version": info.get("docker"),
|
||||
"disk_total": f"{host_info.get('disk_total')} GB",
|
||||
"disk_used": f"{host_info.get('disk_used')} GB",
|
||||
"nameservers": ", ".join(nameservers),
|
||||
"healthy": healthy,
|
||||
"supported": supported,
|
||||
"host_connectivity": network_info.get("host_internet"),
|
||||
|
||||
@@ -628,12 +628,12 @@ class HomeAccessory(Accessory): # type: ignore[misc]
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
service_data: dict[str, Any] | None,
|
||||
service_data: dict[str, Any],
|
||||
value: Any | None = None,
|
||||
) -> None:
|
||||
"""Fire event and call service for changes from HomeKit."""
|
||||
event_data = {
|
||||
ATTR_ENTITY_ID: self.entity_id,
|
||||
ATTR_ENTITY_ID: service_data.get(ATTR_ENTITY_ID, self.entity_id),
|
||||
ATTR_DISPLAY_NAME: self.display_name,
|
||||
ATTR_SERVICE: service,
|
||||
ATTR_VALUE: value,
|
||||
|
||||
@@ -57,6 +57,8 @@ CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor"
|
||||
CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor"
|
||||
CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor"
|
||||
CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor"
|
||||
CONF_LINKED_VALVE_DURATION = "linked_valve_duration"
|
||||
CONF_LINKED_VALVE_END_TIME = "linked_valve_end_time"
|
||||
CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold"
|
||||
CONF_MAX_FPS = "max_fps"
|
||||
CONF_MAX_HEIGHT = "max_height"
|
||||
@@ -229,10 +231,12 @@ CHAR_ON = "On"
|
||||
CHAR_OUTLET_IN_USE = "OutletInUse"
|
||||
CHAR_POSITION_STATE = "PositionState"
|
||||
CHAR_PROGRAMMABLE_SWITCH_EVENT = "ProgrammableSwitchEvent"
|
||||
CHAR_REMAINING_DURATION = "RemainingDuration"
|
||||
CHAR_REMOTE_KEY = "RemoteKey"
|
||||
CHAR_ROTATION_DIRECTION = "RotationDirection"
|
||||
CHAR_ROTATION_SPEED = "RotationSpeed"
|
||||
CHAR_SATURATION = "Saturation"
|
||||
CHAR_SET_DURATION = "SetDuration"
|
||||
CHAR_SERIAL_NUMBER = "SerialNumber"
|
||||
CHAR_SERVICE_LABEL_INDEX = "ServiceLabelIndex"
|
||||
CHAR_SERVICE_LABEL_NAMESPACE = "ServiceLabelNamespace"
|
||||
|
||||
@@ -15,6 +15,11 @@ from pyhap.const import (
|
||||
)
|
||||
|
||||
from homeassistant.components import button, input_button
|
||||
from homeassistant.components.input_number import (
|
||||
ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE,
|
||||
DOMAIN as INPUT_NUMBER_DOMAIN,
|
||||
SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE,
|
||||
)
|
||||
from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION
|
||||
from homeassistant.components.lawn_mower import (
|
||||
DOMAIN as LAWN_MOWER_DOMAIN,
|
||||
@@ -45,6 +50,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback, split_entity_id
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .accessories import TYPES, HomeAccessory, HomeDriver
|
||||
from .const import (
|
||||
@@ -54,7 +60,11 @@ from .const import (
|
||||
CHAR_NAME,
|
||||
CHAR_ON,
|
||||
CHAR_OUTLET_IN_USE,
|
||||
CHAR_REMAINING_DURATION,
|
||||
CHAR_SET_DURATION,
|
||||
CHAR_VALVE_TYPE,
|
||||
CONF_LINKED_VALVE_DURATION,
|
||||
CONF_LINKED_VALVE_END_TIME,
|
||||
SERV_OUTLET,
|
||||
SERV_SWITCH,
|
||||
SERV_VALVE,
|
||||
@@ -271,7 +281,21 @@ class ValveBase(HomeAccessory):
|
||||
self.on_service = on_service
|
||||
self.off_service = off_service
|
||||
|
||||
serv_valve = self.add_preload_service(SERV_VALVE)
|
||||
self.chars = []
|
||||
|
||||
self.linked_duration_entity: str | None = self.config.get(
|
||||
CONF_LINKED_VALVE_DURATION
|
||||
)
|
||||
self.linked_end_time_entity: str | None = self.config.get(
|
||||
CONF_LINKED_VALVE_END_TIME
|
||||
)
|
||||
|
||||
if self.linked_duration_entity:
|
||||
self.chars.append(CHAR_SET_DURATION)
|
||||
if self.linked_end_time_entity:
|
||||
self.chars.append(CHAR_REMAINING_DURATION)
|
||||
|
||||
serv_valve = self.add_preload_service(SERV_VALVE, self.chars)
|
||||
self.char_active = serv_valve.configure_char(
|
||||
CHAR_ACTIVE, value=False, setter_callback=self.set_state
|
||||
)
|
||||
@@ -279,6 +303,25 @@ class ValveBase(HomeAccessory):
|
||||
self.char_valve_type = serv_valve.configure_char(
|
||||
CHAR_VALVE_TYPE, value=VALVE_TYPE[valve_type].valve_type
|
||||
)
|
||||
|
||||
if CHAR_SET_DURATION in self.chars:
|
||||
_LOGGER.debug(
|
||||
"%s: Add characteristic %s", self.entity_id, CHAR_SET_DURATION
|
||||
)
|
||||
self.char_set_duration = serv_valve.configure_char(
|
||||
CHAR_SET_DURATION,
|
||||
value=self.get_duration(),
|
||||
setter_callback=self.set_duration,
|
||||
)
|
||||
|
||||
if CHAR_REMAINING_DURATION in self.chars:
|
||||
_LOGGER.debug(
|
||||
"%s: Add characteristic %s", self.entity_id, CHAR_REMAINING_DURATION
|
||||
)
|
||||
self.char_remaining_duration = serv_valve.configure_char(
|
||||
CHAR_REMAINING_DURATION, getter_callback=self.get_remaining_duration
|
||||
)
|
||||
|
||||
# Set the state so it is in sync on initial
|
||||
# GET to avoid an event storm after homekit startup
|
||||
self.async_update_state(state)
|
||||
@@ -294,12 +337,75 @@ class ValveBase(HomeAccessory):
|
||||
@callback
|
||||
def async_update_state(self, new_state: State) -> None:
|
||||
"""Update switch state after state changed."""
|
||||
self._update_duration_chars()
|
||||
current_state = 1 if new_state.state in self.open_states else 0
|
||||
_LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state)
|
||||
self.char_active.set_value(current_state)
|
||||
_LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state)
|
||||
self.char_in_use.set_value(current_state)
|
||||
|
||||
def _update_duration_chars(self) -> None:
|
||||
"""Update valve duration related properties if characteristics are available."""
|
||||
if CHAR_SET_DURATION in self.chars:
|
||||
self.char_set_duration.set_value(self.get_duration())
|
||||
if CHAR_REMAINING_DURATION in self.chars:
|
||||
self.char_remaining_duration.set_value(self.get_remaining_duration())
|
||||
|
||||
def set_duration(self, value: int) -> None:
|
||||
"""Set default duration for how long the valve should remain open."""
|
||||
_LOGGER.debug("%s: Set default run time to %s", self.entity_id, value)
|
||||
self.async_call_service(
|
||||
INPUT_NUMBER_DOMAIN,
|
||||
INPUT_NUMBER_SERVICE_SET_VALUE,
|
||||
{
|
||||
ATTR_ENTITY_ID: self.linked_duration_entity,
|
||||
INPUT_NUMBER_ATTR_VALUE: value,
|
||||
},
|
||||
value,
|
||||
)
|
||||
|
||||
def get_duration(self) -> int:
|
||||
"""Get the default duration from Home Assistant."""
|
||||
duration_state = self._get_entity_state(self.linked_duration_entity)
|
||||
if duration_state is None:
|
||||
_LOGGER.debug(
|
||||
"%s: No linked duration entity state available", self.entity_id
|
||||
)
|
||||
return 0
|
||||
|
||||
try:
|
||||
duration = float(duration_state)
|
||||
return max(int(duration), 0)
|
||||
except ValueError:
|
||||
_LOGGER.debug("%s: Cannot parse linked duration entity", self.entity_id)
|
||||
return 0
|
||||
|
||||
def get_remaining_duration(self) -> int:
|
||||
"""Calculate the remaining duration based on end time in Home Assistant."""
|
||||
end_time_state = self._get_entity_state(self.linked_end_time_entity)
|
||||
if end_time_state is None:
|
||||
_LOGGER.debug(
|
||||
"%s: No linked end time entity state available", self.entity_id
|
||||
)
|
||||
return self.get_duration()
|
||||
|
||||
end_time = dt_util.parse_datetime(end_time_state)
|
||||
if end_time is None:
|
||||
_LOGGER.debug("%s: Cannot parse linked end time entity", self.entity_id)
|
||||
return self.get_duration()
|
||||
|
||||
remaining_time = (end_time - dt_util.utcnow()).total_seconds()
|
||||
return max(int(remaining_time), 0)
|
||||
|
||||
def _get_entity_state(self, entity_id: str | None) -> str | None:
|
||||
"""Fetch the state of a linked entity."""
|
||||
if entity_id is None:
|
||||
return None
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state is None:
|
||||
return None
|
||||
return state.state
|
||||
|
||||
|
||||
@TYPES.register("ValveSwitch")
|
||||
class ValveSwitch(ValveBase):
|
||||
|
||||
@@ -17,6 +17,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import (
|
||||
binary_sensor,
|
||||
input_number,
|
||||
media_player,
|
||||
persistent_notification,
|
||||
sensor,
|
||||
@@ -69,6 +70,8 @@ from .const import (
|
||||
CONF_LINKED_OBSTRUCTION_SENSOR,
|
||||
CONF_LINKED_PM25_SENSOR,
|
||||
CONF_LINKED_TEMPERATURE_SENSOR,
|
||||
CONF_LINKED_VALVE_DURATION,
|
||||
CONF_LINKED_VALVE_END_TIME,
|
||||
CONF_LOW_BATTERY_THRESHOLD,
|
||||
CONF_MAX_FPS,
|
||||
CONF_MAX_HEIGHT,
|
||||
@@ -266,7 +269,9 @@ SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||
TYPE_VALVE,
|
||||
)
|
||||
),
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN),
|
||||
vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -277,6 +282,12 @@ SENSOR_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||
}
|
||||
)
|
||||
|
||||
VALVE_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN),
|
||||
vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN),
|
||||
}
|
||||
)
|
||||
|
||||
HOMEKIT_CHAR_TRANSLATIONS = {
|
||||
0: " ", # nul
|
||||
@@ -360,6 +371,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
|
||||
elif domain == "sensor":
|
||||
config = SENSOR_SCHEMA(config)
|
||||
|
||||
elif domain == "valve":
|
||||
config = VALVE_SCHEMA(config)
|
||||
|
||||
else:
|
||||
config = BASIC_INFO_SCHEMA(config)
|
||||
|
||||
|
||||
@@ -283,19 +283,19 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity):
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
return self._device.doorState == DoorState.CLOSED
|
||||
return self.functional_channel.doorState == DoorState.CLOSED
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
await self._device.send_door_command_async(DoorCommand.OPEN)
|
||||
await self.functional_channel.async_send_door_command(DoorCommand.OPEN)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
await self._device.send_door_command_async(DoorCommand.CLOSE)
|
||||
await self.functional_channel.async_send_door_command(DoorCommand.CLOSE)
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
await self._device.send_door_command_async(DoorCommand.STOP)
|
||||
await self.functional_channel.async_send_door_command(DoorCommand.STOP)
|
||||
|
||||
|
||||
class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylitterbot"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pylitterbot==2024.2.2"]
|
||||
"requirements": ["pylitterbot==2024.2.3"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aiomealie==0.10.0"]
|
||||
"requirements": ["aiomealie==0.10.1"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -203,27 +203,27 @@
|
||||
"plate": {
|
||||
"name": "Plate {plate_no}",
|
||||
"state": {
|
||||
"power_step_0": "0",
|
||||
"power_step_warm": "Warming",
|
||||
"power_step_1": "1",
|
||||
"power_step_2": "1\u2022",
|
||||
"power_step_3": "2",
|
||||
"power_step_4": "2\u2022",
|
||||
"power_step_5": "3",
|
||||
"power_step_6": "3\u2022",
|
||||
"power_step_7": "4",
|
||||
"power_step_8": "4\u2022",
|
||||
"power_step_9": "5",
|
||||
"power_step_10": "5\u2022",
|
||||
"power_step_11": "6",
|
||||
"power_step_12": "6\u2022",
|
||||
"power_step_13": "7",
|
||||
"power_step_14": "7\u2022",
|
||||
"power_step_15": "8",
|
||||
"power_step_16": "8\u2022",
|
||||
"power_step_17": "9",
|
||||
"power_step_18": "9\u2022",
|
||||
"power_step_boost": "Boost"
|
||||
"plate_step_0": "0",
|
||||
"plate_step_warm": "Warming",
|
||||
"plate_step_1": "1",
|
||||
"plate_step_2": "1\u2022",
|
||||
"plate_step_3": "2",
|
||||
"plate_step_4": "2\u2022",
|
||||
"plate_step_5": "3",
|
||||
"plate_step_6": "3\u2022",
|
||||
"plate_step_7": "4",
|
||||
"plate_step_8": "4\u2022",
|
||||
"plate_step_9": "5",
|
||||
"plate_step_10": "5\u2022",
|
||||
"plate_step_11": "6",
|
||||
"plate_step_12": "6\u2022",
|
||||
"plate_step_13": "7",
|
||||
"plate_step_14": "7\u2022",
|
||||
"plate_step_15": "8",
|
||||
"plate_step_16": "8\u2022",
|
||||
"plate_step_17": "9",
|
||||
"plate_step_18": "9\u2022",
|
||||
"plate_step_boost": "Boost"
|
||||
}
|
||||
},
|
||||
"drying_step": {
|
||||
|
||||
@@ -289,17 +289,23 @@ class MotionTiltDevice(MotionPositionDevice):
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, 180)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Close the cover tilt."""
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, 0)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
angle = kwargs[ATTR_TILT_POSITION] * 180 / 100
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, angle)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
async with self._api_lock:
|
||||
@@ -360,11 +366,15 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Open)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Close the cover tilt."""
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Close)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
angle = kwargs[ATTR_TILT_POSITION]
|
||||
@@ -376,6 +386,8 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_position, angle)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_set_absolute_position(self, **kwargs):
|
||||
"""Move the cover to a specific absolute position (see TDBU)."""
|
||||
angle = kwargs.get(ATTR_TILT_POSITION)
|
||||
@@ -390,6 +402,8 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_position, angle)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
|
||||
class MotionTDBUDevice(MotionBaseDevice):
|
||||
"""Representation of a Motion Top Down Bottom Up blind Device."""
|
||||
|
||||
@@ -42,6 +42,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
|
||||
|
||||
self._requesting_position: CALLBACK_TYPE | None = None
|
||||
self._previous_positions: list[int | dict | None] = []
|
||||
self._previous_angles: list[int | None] = []
|
||||
|
||||
if blind.device_type in DEVICE_TYPES_WIFI:
|
||||
self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI
|
||||
@@ -112,17 +113,27 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
|
||||
"""Request a state update from the blind at a scheduled point in time."""
|
||||
# add the last position to the list and keep the list at max 2 items
|
||||
self._previous_positions.append(self._blind.position)
|
||||
self._previous_angles.append(self._blind.angle)
|
||||
if len(self._previous_positions) > 2:
|
||||
del self._previous_positions[: len(self._previous_positions) - 2]
|
||||
if len(self._previous_angles) > 2:
|
||||
del self._previous_angles[: len(self._previous_angles) - 2]
|
||||
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Update_trigger)
|
||||
|
||||
self.coordinator.async_update_listeners()
|
||||
|
||||
if len(self._previous_positions) < 2 or not all(
|
||||
self._blind.position == prev_position
|
||||
for prev_position in self._previous_positions
|
||||
if (
|
||||
len(self._previous_positions) < 2
|
||||
or not all(
|
||||
self._blind.position == prev_position
|
||||
for prev_position in self._previous_positions
|
||||
)
|
||||
or len(self._previous_angles) < 2
|
||||
or not all(
|
||||
self._blind.angle == prev_angle for prev_angle in self._previous_angles
|
||||
)
|
||||
):
|
||||
# keep updating the position @self._update_interval_moving until the position does not change.
|
||||
self._requesting_position = async_call_later(
|
||||
@@ -132,6 +143,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
|
||||
)
|
||||
else:
|
||||
self._previous_positions = []
|
||||
self._previous_angles = []
|
||||
self._requesting_position = None
|
||||
|
||||
async def async_request_position_till_stop(self, delay: int | None = None) -> None:
|
||||
@@ -140,7 +152,8 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
|
||||
delay = self._update_interval_moving
|
||||
|
||||
self._previous_positions = []
|
||||
if self._blind.position is None:
|
||||
self._previous_angles = []
|
||||
if self._blind.position is None and self._blind.angle is None:
|
||||
return
|
||||
if self._requesting_position is not None:
|
||||
self._requesting_position()
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/motion_blinds",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["motionblinds"],
|
||||
"requirements": ["motionblinds==0.6.29"]
|
||||
"requirements": ["motionblinds==0.6.30"]
|
||||
}
|
||||
|
||||
@@ -1104,6 +1104,7 @@
|
||||
},
|
||||
"device_class_sensor": {
|
||||
"options": {
|
||||
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
|
||||
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
|
||||
"area": "[%key:component::sensor::entity_component::area::name%]",
|
||||
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from psnawp_api.core.psnawp_exceptions import (
|
||||
PSNAWPClientError,
|
||||
@@ -10,12 +11,14 @@ from psnawp_api.core.psnawp_exceptions import (
|
||||
PSNAWPNotFoundError,
|
||||
PSNAWPServerError,
|
||||
)
|
||||
from psnawp_api.models.group.group import Group
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
DOMAIN as NOTIFY_DOMAIN,
|
||||
NotifyEntity,
|
||||
NotifyEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -24,6 +27,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
PlaystationNetworkConfigEntry,
|
||||
PlaystationNetworkFriendDataCoordinator,
|
||||
PlaystationNetworkGroupsUpdateCoordinator,
|
||||
)
|
||||
from .entity import PlaystationNetworkServiceEntity
|
||||
@@ -35,6 +39,7 @@ class PlaystationNetworkNotify(StrEnum):
|
||||
"""PlayStation Network sensors."""
|
||||
|
||||
GROUP_MESSAGE = "group_message"
|
||||
DIRECT_MESSAGE = "direct_message"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -45,6 +50,7 @@ async def async_setup_entry(
|
||||
"""Set up the notify entity platform."""
|
||||
|
||||
coordinator = config_entry.runtime_data.groups
|
||||
|
||||
groups_added: set[str] = set()
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
@@ -72,8 +78,50 @@ async def async_setup_entry(
|
||||
coordinator.async_add_listener(add_entities)
|
||||
add_entities()
|
||||
|
||||
for subentry_id, friend_coordinator in config_entry.runtime_data.friends.items():
|
||||
async_add_entities(
|
||||
[
|
||||
PlaystationNetworkDirectMessageNotifyEntity(
|
||||
friend_coordinator,
|
||||
config_entry.subentries[subentry_id],
|
||||
)
|
||||
],
|
||||
config_subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
class PlaystationNetworkNotifyEntity(PlaystationNetworkServiceEntity, NotifyEntity):
|
||||
|
||||
class PlaystationNetworkNotifyBaseEntity(PlaystationNetworkServiceEntity, NotifyEntity):
|
||||
"""Base class of PlayStation Network notify entity."""
|
||||
|
||||
group: Group | None = None
|
||||
|
||||
def send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send a message."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.group
|
||||
try:
|
||||
self.group.send_message(message)
|
||||
except PSNAWPNotFoundError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="group_invalid",
|
||||
translation_placeholders=dict(self.translation_placeholders),
|
||||
) from e
|
||||
except PSNAWPForbiddenError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_message_forbidden",
|
||||
translation_placeholders=dict(self.translation_placeholders),
|
||||
) from e
|
||||
except (PSNAWPServerError, PSNAWPClientError) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_message_failed",
|
||||
translation_placeholders=dict(self.translation_placeholders),
|
||||
) from e
|
||||
|
||||
|
||||
class PlaystationNetworkNotifyEntity(PlaystationNetworkNotifyBaseEntity):
|
||||
"""Representation of a PlayStation Network notify entity."""
|
||||
|
||||
coordinator: PlaystationNetworkGroupsUpdateCoordinator
|
||||
@@ -101,26 +149,31 @@ class PlaystationNetworkNotifyEntity(PlaystationNetworkServiceEntity, NotifyEnti
|
||||
|
||||
super().__init__(coordinator, self.entity_description)
|
||||
|
||||
|
||||
class PlaystationNetworkDirectMessageNotifyEntity(PlaystationNetworkNotifyBaseEntity):
|
||||
"""Representation of a PlayStation Network notify entity for sending direct messages."""
|
||||
|
||||
coordinator: PlaystationNetworkFriendDataCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PlaystationNetworkFriendDataCoordinator,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
"""Initialize a notification entity."""
|
||||
|
||||
self.entity_description = NotifyEntityDescription(
|
||||
key=PlaystationNetworkNotify.DIRECT_MESSAGE,
|
||||
translation_key=PlaystationNetworkNotify.DIRECT_MESSAGE,
|
||||
)
|
||||
|
||||
super().__init__(coordinator, self.entity_description, subentry)
|
||||
|
||||
def send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send a message."""
|
||||
|
||||
try:
|
||||
self.group.send_message(message)
|
||||
except PSNAWPNotFoundError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="group_invalid",
|
||||
translation_placeholders=dict(self.translation_placeholders),
|
||||
) from e
|
||||
except PSNAWPForbiddenError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_message_forbidden",
|
||||
translation_placeholders=dict(self.translation_placeholders),
|
||||
) from e
|
||||
except (PSNAWPServerError, PSNAWPClientError) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_message_failed",
|
||||
translation_placeholders=dict(self.translation_placeholders),
|
||||
) from e
|
||||
if not self.group:
|
||||
self.group = self.coordinator.psn.psn.group(
|
||||
users_list=[self.coordinator.user]
|
||||
)
|
||||
super().send_message(message, title)
|
||||
|
||||
@@ -158,6 +158,9 @@
|
||||
"notify": {
|
||||
"group_message": {
|
||||
"name": "Group: {group_name}"
|
||||
},
|
||||
"direct_message": {
|
||||
"name": "Direct message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -10,6 +10,7 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.SCENE,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.color import brightness_to_value, value_to_brightness
|
||||
|
||||
from .coordinator import QbusConfigEntry
|
||||
from .entity import QbusEntity, add_new_outputs
|
||||
from .entity import QbusEntity, create_new_entities
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -27,13 +27,13 @@ async def async_setup_entry(
|
||||
added_outputs: list[QbusMqttOutput] = []
|
||||
|
||||
def _check_outputs() -> None:
|
||||
add_new_outputs(
|
||||
entities = create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
lambda output: output.type == "analog",
|
||||
QbusLight,
|
||||
async_add_entities,
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/qbus",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["qbusmqttapi"],
|
||||
"mqtt": [
|
||||
"cloudapp/QBUSMQTTGW/state",
|
||||
"cloudapp/QBUSMQTTGW/config",
|
||||
|
||||
@@ -7,11 +7,10 @@ from qbusmqttapi.state import QbusMqttState, StateAction, StateType
|
||||
|
||||
from homeassistant.components.scene import Scene
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import QbusConfigEntry
|
||||
from .entity import QbusEntity, add_new_outputs, create_main_device_identifier
|
||||
from .entity import QbusEntity, create_new_entities
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -27,13 +26,13 @@ async def async_setup_entry(
|
||||
added_outputs: list[QbusMqttOutput] = []
|
||||
|
||||
def _check_outputs() -> None:
|
||||
add_new_outputs(
|
||||
entities = create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
lambda output: output.type == "scene",
|
||||
QbusScene,
|
||||
async_add_entities,
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
@@ -45,12 +44,8 @@ class QbusScene(QbusEntity, Scene):
|
||||
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
|
||||
"""Initialize scene entity."""
|
||||
|
||||
super().__init__(mqtt_output)
|
||||
super().__init__(mqtt_output, link_to_main_device=True)
|
||||
|
||||
# Add to main controller device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={create_main_device_identifier(mqtt_output)}
|
||||
)
|
||||
self._attr_name = mqtt_output.name.title()
|
||||
|
||||
async def async_activate(self, **kwargs: Any) -> None:
|
||||
|
||||
378
homeassistant/components/qbus/sensor.py
Normal file
378
homeassistant/components/qbus/sensor.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""Support for Qbus sensor."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from qbusmqttapi.discovery import QbusMqttOutput
|
||||
from qbusmqttapi.state import (
|
||||
GaugeStateProperty,
|
||||
QbusMqttGaugeState,
|
||||
QbusMqttHumidityState,
|
||||
QbusMqttThermoState,
|
||||
QbusMqttVentilationState,
|
||||
QbusMqttWeatherState,
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfLength,
|
||||
UnitOfPower,
|
||||
UnitOfPressure,
|
||||
UnitOfSoundPressure,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import QbusConfigEntry
|
||||
from .entity import QbusEntity, create_new_entities, determine_new_outputs
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class QbusWeatherDescription(SensorEntityDescription):
|
||||
"""Description for Qbus weather entities."""
|
||||
|
||||
property: str
|
||||
|
||||
|
||||
_WEATHER_DESCRIPTIONS = (
|
||||
QbusWeatherDescription(
|
||||
key="daylight",
|
||||
property="dayLight",
|
||||
translation_key="daylight",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
),
|
||||
QbusWeatherDescription(
|
||||
key="light",
|
||||
property="light",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
),
|
||||
QbusWeatherDescription(
|
||||
key="light_east",
|
||||
property="lightEast",
|
||||
translation_key="light_east",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
),
|
||||
QbusWeatherDescription(
|
||||
key="light_south",
|
||||
property="lightSouth",
|
||||
translation_key="light_south",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
),
|
||||
QbusWeatherDescription(
|
||||
key="light_west",
|
||||
property="lightWest",
|
||||
translation_key="light_west",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
),
|
||||
QbusWeatherDescription(
|
||||
key="temperature",
|
||||
property="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
),
|
||||
QbusWeatherDescription(
|
||||
key="wind",
|
||||
property="wind",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
),
|
||||
)
|
||||
|
||||
_GAUGE_VARIANT_DESCRIPTIONS = {
|
||||
"AIRPRESSURE": SensorEntityDescription(
|
||||
key="airpressure",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.MBAR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"AIRQUALITY": SensorEntityDescription(
|
||||
key="airquality",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"CURRENT": SensorEntityDescription(
|
||||
key="current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"ENERGY": SensorEntityDescription(
|
||||
key="energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
"GAS": SensorEntityDescription(
|
||||
key="gas",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"GASFLOW": SensorEntityDescription(
|
||||
key="gasflow",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"HUMIDITY": SensorEntityDescription(
|
||||
key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"LIGHT": SensorEntityDescription(
|
||||
key="light",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"LOUDNESS": SensorEntityDescription(
|
||||
key="loudness",
|
||||
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
||||
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"POWER": SensorEntityDescription(
|
||||
key="power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"PRESSURE": SensorEntityDescription(
|
||||
key="pressure",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.KPA,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"TEMPERATURE": SensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"VOLTAGE": SensorEntityDescription(
|
||||
key="voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"VOLUME": SensorEntityDescription(
|
||||
key="volume",
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"WATER": SensorEntityDescription(
|
||||
key="water",
|
||||
device_class=SensorDeviceClass.WATER,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
"WATERFLOW": SensorEntityDescription(
|
||||
key="waterflow",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"WATERLEVEL": SensorEntityDescription(
|
||||
key="waterlevel",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.METERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"WATERPRESSURE": SensorEntityDescription(
|
||||
key="waterpressure",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.MBAR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"WIND": SensorEntityDescription(
|
||||
key="wind",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _is_gauge_with_variant(output: QbusMqttOutput) -> bool:
|
||||
return (
|
||||
output.type == "gauge"
|
||||
and isinstance(output.variant, str)
|
||||
and _GAUGE_VARIANT_DESCRIPTIONS.get(output.variant.upper()) is not None
|
||||
)
|
||||
|
||||
|
||||
def _is_ventilation_with_co2(output: QbusMqttOutput) -> bool:
|
||||
return output.type == "ventilation" and output.properties.get("co2") is not None
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: QbusConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensor entities."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
added_outputs: list[QbusMqttOutput] = []
|
||||
|
||||
def _create_weather_entities() -> list[QbusEntity]:
|
||||
new_outputs = determine_new_outputs(
|
||||
coordinator, added_outputs, lambda output: output.type == "weatherstation"
|
||||
)
|
||||
|
||||
return [
|
||||
QbusWeatherSensor(output, description)
|
||||
for output in new_outputs
|
||||
for description in _WEATHER_DESCRIPTIONS
|
||||
]
|
||||
|
||||
def _check_outputs() -> None:
|
||||
entities: list[QbusEntity] = [
|
||||
*create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
_is_gauge_with_variant,
|
||||
QbusGaugeVariantSensor,
|
||||
),
|
||||
*create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
lambda output: output.type == "humidity",
|
||||
QbusHumiditySensor,
|
||||
),
|
||||
*create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
lambda output: output.type == "thermo",
|
||||
QbusThermoSensor,
|
||||
),
|
||||
*create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
_is_ventilation_with_co2,
|
||||
QbusVentilationSensor,
|
||||
),
|
||||
*_create_weather_entities(),
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
|
||||
|
||||
class QbusGaugeVariantSensor(QbusEntity, SensorEntity):
|
||||
"""Representation of a Qbus sensor entity for gauges with variant."""
|
||||
|
||||
_state_cls = QbusMqttGaugeState
|
||||
|
||||
_attr_name = None
|
||||
_attr_suggested_display_precision = 2
|
||||
|
||||
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
|
||||
"""Initialize sensor entity."""
|
||||
|
||||
super().__init__(mqtt_output)
|
||||
|
||||
variant = str(mqtt_output.variant)
|
||||
self.entity_description = _GAUGE_VARIANT_DESCRIPTIONS[variant.upper()]
|
||||
|
||||
async def _handle_state_received(self, state: QbusMqttGaugeState) -> None:
|
||||
self._attr_native_value = state.read_value(GaugeStateProperty.CURRENT_VALUE)
|
||||
|
||||
|
||||
class QbusHumiditySensor(QbusEntity, SensorEntity):
|
||||
"""Representation of a Qbus sensor entity for humidity modules."""
|
||||
|
||||
_state_cls = QbusMqttHumidityState
|
||||
|
||||
_attr_device_class = SensorDeviceClass.HUMIDITY
|
||||
_attr_name = None
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
async def _handle_state_received(self, state: QbusMqttHumidityState) -> None:
|
||||
self._attr_native_value = state.read_value()
|
||||
|
||||
|
||||
class QbusThermoSensor(QbusEntity, SensorEntity):
|
||||
"""Representation of a Qbus sensor entity for thermostats."""
|
||||
|
||||
_state_cls = QbusMqttThermoState
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
async def _handle_state_received(self, state: QbusMqttThermoState) -> None:
|
||||
self._attr_native_value = state.read_current_temperature()
|
||||
|
||||
|
||||
class QbusVentilationSensor(QbusEntity, SensorEntity):
|
||||
"""Representation of a Qbus sensor entity for ventilations."""
|
||||
|
||||
_state_cls = QbusMqttVentilationState
|
||||
|
||||
_attr_device_class = SensorDeviceClass.CO2
|
||||
_attr_name = None
|
||||
_attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_suggested_display_precision = 0
|
||||
|
||||
async def _handle_state_received(self, state: QbusMqttVentilationState) -> None:
|
||||
self._attr_native_value = state.read_co2()
|
||||
|
||||
|
||||
class QbusWeatherSensor(QbusEntity, SensorEntity):
|
||||
"""Representation of a Qbus weather sensor."""
|
||||
|
||||
_state_cls = QbusMqttWeatherState
|
||||
|
||||
entity_description: QbusWeatherDescription
|
||||
|
||||
def __init__(
|
||||
self, mqtt_output: QbusMqttOutput, description: QbusWeatherDescription
|
||||
) -> None:
|
||||
"""Initialize sensor entity."""
|
||||
|
||||
super().__init__(mqtt_output, id_suffix=description.key)
|
||||
|
||||
self.entity_description = description
|
||||
|
||||
if description.key == "temperature":
|
||||
self._attr_name = None
|
||||
|
||||
async def _handle_state_received(self, state: QbusMqttWeatherState) -> None:
|
||||
if value := state.read_property(self.entity_description.property, None):
|
||||
self.native_value = value
|
||||
@@ -16,6 +16,22 @@
|
||||
"no_controller": "No controllers were found"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"daylight": {
|
||||
"name": "Daylight"
|
||||
},
|
||||
"light_east": {
|
||||
"name": "Illuminance east"
|
||||
},
|
||||
"light_south": {
|
||||
"name": "Illuminance south"
|
||||
},
|
||||
"light_west": {
|
||||
"name": "Illuminance west"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_preset": {
|
||||
"message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}."
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -71,10 +71,13 @@
|
||||
"selector": {
|
||||
"device_class": {
|
||||
"options": {
|
||||
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
|
||||
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
|
||||
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
|
||||
"area": "[%key:component::sensor::entity_component::area::name%]",
|
||||
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
|
||||
"battery": "[%key:component::sensor::entity_component::battery::name%]",
|
||||
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
|
||||
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
|
||||
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
|
||||
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
|
||||
@@ -85,6 +88,7 @@
|
||||
"distance": "[%key:component::sensor::entity_component::distance::name%]",
|
||||
"duration": "[%key:component::sensor::entity_component::duration::name%]",
|
||||
"energy": "[%key:component::sensor::entity_component::energy::name%]",
|
||||
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
|
||||
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
|
||||
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
|
||||
"gas": "[%key:component::sensor::entity_component::gas::name%]",
|
||||
@@ -115,13 +119,14 @@
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
|
||||
"volume": "[%key:component::sensor::entity_component::volume::name%]",
|
||||
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
|
||||
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
|
||||
"water": "[%key:component::sensor::entity_component::water::name%]",
|
||||
"weight": "[%key:component::sensor::entity_component::weight::name%]",
|
||||
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
|
||||
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pysqueezebox import Player
|
||||
@@ -21,6 +22,8 @@ from homeassistant.helpers.network import is_internal_request
|
||||
|
||||
from .const import DOMAIN, UNPLAYABLE_TYPES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
LIBRARY = [
|
||||
"favorites",
|
||||
"artists",
|
||||
@@ -138,18 +141,42 @@ class BrowseData:
|
||||
self.squeezebox_id_by_type.update(SQUEEZEBOX_ID_BY_TYPE)
|
||||
self.media_type_to_squeezebox.update(MEDIA_TYPE_TO_SQUEEZEBOX)
|
||||
|
||||
def add_new_command(self, cmd: str | MediaType, type: str) -> None:
|
||||
"""Add items to maps for new apps or radios."""
|
||||
self.known_apps_radios.add(cmd)
|
||||
self.media_type_to_squeezebox[cmd] = cmd
|
||||
self.squeezebox_id_by_type[cmd] = type
|
||||
self.content_type_media_class[cmd] = {
|
||||
"item": MediaClass.DIRECTORY,
|
||||
"children": MediaClass.TRACK,
|
||||
}
|
||||
self.content_type_to_child_type[cmd] = MediaType.TRACK
|
||||
|
||||
def _add_new_command_to_browse_data(
|
||||
browse_data: BrowseData, cmd: str | MediaType, type: str
|
||||
) -> None:
|
||||
"""Add items to maps for new apps or radios."""
|
||||
browse_data.media_type_to_squeezebox[cmd] = cmd
|
||||
browse_data.squeezebox_id_by_type[cmd] = type
|
||||
browse_data.content_type_media_class[cmd] = {
|
||||
"item": MediaClass.DIRECTORY,
|
||||
"children": MediaClass.TRACK,
|
||||
}
|
||||
browse_data.content_type_to_child_type[cmd] = MediaType.TRACK
|
||||
async def async_init(self, player: Player, browse_limit: int) -> None:
|
||||
"""Initialize known apps and radios from the player."""
|
||||
|
||||
cmd = ["apps", 0, browse_limit]
|
||||
result = await player.async_query(*cmd)
|
||||
for app in result["appss_loop"]:
|
||||
app_cmd = "app-" + app["cmd"]
|
||||
if app_cmd not in self.known_apps_radios:
|
||||
self.add_new_command(app_cmd, "item_id")
|
||||
_LOGGER.debug(
|
||||
"Adding new command %s to browse data for player %s",
|
||||
app_cmd,
|
||||
player.player_id,
|
||||
)
|
||||
cmd = ["radios", 0, browse_limit]
|
||||
result = await player.async_query(*cmd)
|
||||
for app in result["radioss_loop"]:
|
||||
app_cmd = "app-" + app["cmd"]
|
||||
if app_cmd not in self.known_apps_radios:
|
||||
self.add_new_command(app_cmd, "item_id")
|
||||
_LOGGER.debug(
|
||||
"Adding new command %s to browse data for player %s",
|
||||
app_cmd,
|
||||
player.player_id,
|
||||
)
|
||||
|
||||
|
||||
def _build_response_apps_radios_category(
|
||||
@@ -292,8 +319,7 @@ async def build_item_response(
|
||||
app_cmd = "app-" + item["cmd"]
|
||||
|
||||
if app_cmd not in browse_data.known_apps_radios:
|
||||
browse_data.known_apps_radios.add(app_cmd)
|
||||
_add_new_command_to_browse_data(browse_data, app_cmd, "item_id")
|
||||
browse_data.add_new_command(app_cmd, "item_id")
|
||||
|
||||
child_media = _build_response_apps_radios_category(
|
||||
browse_data=browse_data, cmd=app_cmd, item=item
|
||||
|
||||
@@ -311,6 +311,11 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
|
||||
)
|
||||
return None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
await self._browse_data.async_init(self._player, self.browse_limit)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Remove from list of known players when removed from hass."""
|
||||
self.coordinator.config_entry.runtime_data.known_player_ids.remove(
|
||||
|
||||
@@ -278,10 +278,10 @@
|
||||
"data_description": {
|
||||
"device_id": "[%key:component::template::common::device_id_description%]",
|
||||
"state": "Template for the number's current value.",
|
||||
"step": "Template for the number's increment/decrement step.",
|
||||
"step": "Defines the number's increment/decrement step.",
|
||||
"set_value": "Defines actions to run when the number is set to a value. Receives variable `value`.",
|
||||
"max": "Template for the number's maximum value.",
|
||||
"min": "Template for the number's minimum value.",
|
||||
"max": "Defines the number's maximum value.",
|
||||
"min": "Defines the number's minimum value.",
|
||||
"unit_of_measurement": "Defines the unit of measurement of the number, if any."
|
||||
},
|
||||
"sections": {
|
||||
@@ -901,6 +901,7 @@
|
||||
},
|
||||
"sensor_device_class": {
|
||||
"options": {
|
||||
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
|
||||
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
|
||||
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
|
||||
"area": "[%key:component::sensor::entity_component::area::name%]",
|
||||
@@ -948,7 +949,7 @@
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
|
||||
"volume": "[%key:component::sensor::entity_component::volume::name%]",
|
||||
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
|
||||
|
||||
@@ -247,11 +247,15 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any
|
||||
raise UpdateFailed(e.message) from e
|
||||
self.updated_once = True
|
||||
|
||||
if not data or not isinstance(data.get("time_series"), list):
|
||||
raise UpdateFailed("Received invalid data")
|
||||
|
||||
# Add all time periods together
|
||||
output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0)
|
||||
for period in data.get("time_series", []):
|
||||
for key in ENERGY_HISTORY_FIELDS:
|
||||
output[key] += period.get(key, 0)
|
||||
if key in period:
|
||||
output[key] += period[key]
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.light import (
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
LightEntityDescription,
|
||||
color_supported,
|
||||
filter_supported_color_modes,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
@@ -530,19 +531,6 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
description.brightness_min, dptype=DPType.INTEGER
|
||||
)
|
||||
|
||||
if int_type := self.find_dpcode(
|
||||
description.color_temp, dptype=DPType.INTEGER, prefer_function=True
|
||||
):
|
||||
self._color_temp = int_type
|
||||
color_modes.add(ColorMode.COLOR_TEMP)
|
||||
# If entity does not have color_temp, check if it has work_mode "white"
|
||||
elif color_mode_enum := self.find_dpcode(
|
||||
description.color_mode, dptype=DPType.ENUM, prefer_function=True
|
||||
):
|
||||
if WorkMode.WHITE.value in color_mode_enum.range:
|
||||
color_modes.add(ColorMode.WHITE)
|
||||
self._white_color_mode = ColorMode.WHITE
|
||||
|
||||
if (
|
||||
dpcode := self.find_dpcode(description.color_data, prefer_function=True)
|
||||
) and self.get_dptype(dpcode) == DPType.JSON:
|
||||
@@ -568,6 +556,26 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
):
|
||||
self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2
|
||||
|
||||
# Check if the light has color temperature
|
||||
if int_type := self.find_dpcode(
|
||||
description.color_temp, dptype=DPType.INTEGER, prefer_function=True
|
||||
):
|
||||
self._color_temp = int_type
|
||||
color_modes.add(ColorMode.COLOR_TEMP)
|
||||
# If light has color but does not have color_temp, check if it has
|
||||
# work_mode "white"
|
||||
elif (
|
||||
color_supported(color_modes)
|
||||
and (
|
||||
color_mode_enum := self.find_dpcode(
|
||||
description.color_mode, dptype=DPType.ENUM, prefer_function=True
|
||||
)
|
||||
)
|
||||
and WorkMode.WHITE.value in color_mode_enum.range
|
||||
):
|
||||
color_modes.add(ColorMode.WHITE)
|
||||
self._white_color_mode = ColorMode.WHITE
|
||||
|
||||
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
|
||||
if len(self._attr_supported_color_modes) == 1:
|
||||
# If the light supports only a single color mode, set it now
|
||||
|
||||
@@ -162,7 +162,11 @@ class UptimeKumaSensorEntity(
|
||||
name=coordinator.data[monitor].monitor_name,
|
||||
identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{monitor!s}")},
|
||||
manufacturer="Uptime Kuma",
|
||||
configuration_url=coordinator.config_entry.data[CONF_URL],
|
||||
configuration_url=(
|
||||
None
|
||||
if "127.0.0.1" in (url := coordinator.config_entry.data[CONF_URL])
|
||||
else url
|
||||
),
|
||||
sw_version=coordinator.api.version.version,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["voip_utils"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["voip-utils==0.3.3"]
|
||||
"requirements": ["voip-utils==0.3.4"]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
from volvocarsapi.api import VolvoCarsApi
|
||||
from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle
|
||||
from volvocarsapi.scopes import DEFAULT_SCOPES
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
@@ -54,6 +55,13 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
self._vehicles: list[VolvoCarsVehicle] = []
|
||||
self._config_data: dict = {}
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
return super().extra_authorize_data | {
|
||||
"scope": " ".join(DEFAULT_SCOPES),
|
||||
}
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
from contextlib import suppress
|
||||
import dataclasses
|
||||
@@ -87,7 +86,6 @@ from .const import (
|
||||
CONF_DATA_COLLECTION_OPTED_IN,
|
||||
CONF_INSTALLER_MODE,
|
||||
DOMAIN,
|
||||
DRIVER_READY_TIMEOUT,
|
||||
EVENT_DEVICE_ADDED_TO_REGISTRY,
|
||||
LOGGER,
|
||||
USER_AGENT,
|
||||
@@ -98,6 +96,7 @@ from .helpers import (
|
||||
async_get_node_from_device_id,
|
||||
async_get_provisioning_entry_from_device_id,
|
||||
async_get_version_info,
|
||||
async_wait_for_driver_ready_event,
|
||||
get_device_id,
|
||||
)
|
||||
|
||||
@@ -2854,26 +2853,18 @@ async def websocket_hard_reset_controller(
|
||||
connection.send_result(msg[ID], device.id)
|
||||
async_cleanup()
|
||||
|
||||
@callback
|
||||
def set_driver_ready(event: dict) -> None:
|
||||
"Set the driver ready event."
|
||||
wait_driver_ready.set()
|
||||
|
||||
wait_driver_ready = asyncio.Event()
|
||||
|
||||
msg[DATA_UNSUBSCRIBE] = unsubs = [
|
||||
async_dispatcher_connect(
|
||||
hass, EVENT_DEVICE_ADDED_TO_REGISTRY, _handle_device_added
|
||||
),
|
||||
driver.once("driver ready", set_driver_ready),
|
||||
]
|
||||
|
||||
wait_for_driver_ready = async_wait_for_driver_ready_event(entry, driver)
|
||||
|
||||
await driver.async_hard_reset()
|
||||
|
||||
with suppress(TimeoutError):
|
||||
async with asyncio.timeout(DRIVER_READY_TIMEOUT):
|
||||
await wait_driver_ready.wait()
|
||||
|
||||
await wait_for_driver_ready()
|
||||
# When resetting the controller, the controller home id is also changed.
|
||||
# The controller state in the client is stale after resetting the controller,
|
||||
# so get the new home id with a new client using the helper function.
|
||||
@@ -2886,14 +2877,14 @@ async def websocket_hard_reset_controller(
|
||||
# The stale unique id needs to be handled by a repair flow,
|
||||
# after the config entry has been reloaded.
|
||||
LOGGER.error(
|
||||
"Failed to get server version, cannot update config entry"
|
||||
"Failed to get server version, cannot update config entry "
|
||||
"unique id with new home id, after controller reset"
|
||||
)
|
||||
else:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, unique_id=str(version_info.home_id)
|
||||
)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -3100,27 +3091,19 @@ async def websocket_restore_nvm(
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def set_driver_ready(event: dict) -> None:
|
||||
"Set the driver ready event."
|
||||
wait_driver_ready.set()
|
||||
|
||||
wait_driver_ready = asyncio.Event()
|
||||
|
||||
# Set up subscription for progress events
|
||||
connection.subscriptions[msg["id"]] = async_cleanup
|
||||
msg[DATA_UNSUBSCRIBE] = unsubs = [
|
||||
controller.on("nvm convert progress", forward_progress),
|
||||
controller.on("nvm restore progress", forward_progress),
|
||||
driver.once("driver ready", set_driver_ready),
|
||||
]
|
||||
|
||||
wait_for_driver_ready = async_wait_for_driver_ready_event(entry, driver)
|
||||
|
||||
await controller.async_restore_nvm_base64(msg["data"], {"preserveRoutes": False})
|
||||
|
||||
with suppress(TimeoutError):
|
||||
async with asyncio.timeout(DRIVER_READY_TIMEOUT):
|
||||
await wait_driver_ready.wait()
|
||||
|
||||
await wait_for_driver_ready()
|
||||
# When restoring the NVM to the controller, the controller home id is also changed.
|
||||
# The controller state in the client is stale after restoring the NVM,
|
||||
# so get the new home id with a new client using the helper function.
|
||||
@@ -3133,14 +3116,13 @@ async def websocket_restore_nvm(
|
||||
# The stale unique id needs to be handled by a repair flow,
|
||||
# after the config entry has been reloaded.
|
||||
LOGGER.error(
|
||||
"Failed to get server version, cannot update config entry"
|
||||
"Failed to get server version, cannot update config entry "
|
||||
"unique id with new home id, after controller NVM restore"
|
||||
)
|
||||
else:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, unique_id=str(version_info.home_id)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
connection.send_message(
|
||||
@@ -3152,3 +3134,4 @@ async def websocket_restore_nvm(
|
||||
)
|
||||
)
|
||||
connection.send_result(msg[ID])
|
||||
async_cleanup()
|
||||
|
||||
@@ -62,9 +62,12 @@ from .const import (
|
||||
CONF_USB_PATH,
|
||||
CONF_USE_ADDON,
|
||||
DOMAIN,
|
||||
DRIVER_READY_TIMEOUT,
|
||||
)
|
||||
from .helpers import CannotConnect, async_get_version_info
|
||||
from .helpers import (
|
||||
CannotConnect,
|
||||
async_get_version_info,
|
||||
async_wait_for_driver_ready_event,
|
||||
)
|
||||
from .models import ZwaveJSConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -1396,19 +1399,15 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
event["bytesWritten"] / event["total"] * 0.5 + 0.5
|
||||
)
|
||||
|
||||
@callback
|
||||
def set_driver_ready(event: dict) -> None:
|
||||
"Set the driver ready event."
|
||||
wait_driver_ready.set()
|
||||
|
||||
driver = self._get_driver()
|
||||
controller = driver.controller
|
||||
wait_driver_ready = asyncio.Event()
|
||||
unsubs = [
|
||||
controller.on("nvm convert progress", forward_progress),
|
||||
controller.on("nvm restore progress", forward_progress),
|
||||
driver.once("driver ready", set_driver_ready),
|
||||
]
|
||||
|
||||
wait_for_driver_ready = async_wait_for_driver_ready_event(config_entry, driver)
|
||||
|
||||
try:
|
||||
await controller.async_restore_nvm(
|
||||
self.backup_data, {"preserveRoutes": False}
|
||||
@@ -1417,8 +1416,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
raise AbortFlow(f"Failed to restore network: {err}") from err
|
||||
else:
|
||||
with suppress(TimeoutError):
|
||||
async with asyncio.timeout(DRIVER_READY_TIMEOUT):
|
||||
await wait_driver_ready.wait()
|
||||
await wait_for_driver_ready()
|
||||
try:
|
||||
version_info = await async_get_version_info(
|
||||
self.hass, config_entry.data[CONF_URL]
|
||||
@@ -1435,10 +1433,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
config_entry, unique_id=str(version_info.home_id)
|
||||
)
|
||||
await self.hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
# Reload the config entry two times to clean up
|
||||
# the stale device entry.
|
||||
# The config entry will be also be reloaded when the driver is ready,
|
||||
# by the listener in the package module,
|
||||
# and two reloads are needed to clean up the stale controller device entry.
|
||||
# Since both the old and the new controller have the same node id,
|
||||
# but different hardware identifiers, the integration
|
||||
# will create a new device for the new controller, on the first reload,
|
||||
|
||||
@@ -201,7 +201,3 @@ COVER_TILT_PROPERTY_KEYS: set[str | int | None] = {
|
||||
WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE,
|
||||
WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION,
|
||||
}
|
||||
|
||||
# Other constants
|
||||
|
||||
DRIVER_READY_TIMEOUT = 60
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
14
requirements_all.txt
generated
@@ -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
|
||||
|
||||
14
requirements_test_all.txt
generated
14
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
'aa:bb:cc:dd:ee:ff',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Acaia',
|
||||
@@ -31,7 +30,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'suggested_area': 'Kitchen',
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
'84fce612f5b8',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'AirGradient',
|
||||
@@ -31,7 +30,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': '84fce612f5b8',
|
||||
'suggested_area': None,
|
||||
'sw_version': '3.1.1',
|
||||
'via_device_id': None,
|
||||
})
|
||||
@@ -58,7 +56,6 @@
|
||||
'84fce612f5b8',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'AirGradient',
|
||||
@@ -68,7 +65,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': '84fce612f5b8',
|
||||
'suggested_area': None,
|
||||
'sw_version': '3.1.1',
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
623
tests/components/airos/snapshots/test_diagnostics.ambr
Normal file
623
tests/components/airos/snapshots/test_diagnostics.ambr
Normal 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',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
32
tests/components/airos/test_diagnostics.py
Normal file
32
tests/components/airos/test_diagnostics.py
Normal 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
|
||||
)
|
||||
@@ -17,7 +17,6 @@
|
||||
'echo_test_serial_number',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Amazon',
|
||||
@@ -27,7 +26,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': 'echo_test_serial_number',
|
||||
'suggested_area': None,
|
||||
'sw_version': 'echo_test_software_version',
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -1051,7 +1051,6 @@ async def test_devices_payload(
|
||||
"hw_version": "test-hw-version",
|
||||
"integration": "hue",
|
||||
"is_custom_integration": False,
|
||||
"has_suggested_area": True,
|
||||
"has_configuration_url": True,
|
||||
"via_device": None,
|
||||
},
|
||||
@@ -1063,7 +1062,6 @@ async def test_devices_payload(
|
||||
"hw_version": None,
|
||||
"integration": "hue",
|
||||
"is_custom_integration": False,
|
||||
"has_suggested_area": False,
|
||||
"has_configuration_url": False,
|
||||
"via_device": 0,
|
||||
},
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
'junctionId',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'A. O. Smith',
|
||||
@@ -27,7 +26,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': 'serial',
|
||||
'suggested_area': 'Basement',
|
||||
'sw_version': '2.14',
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
'XXXXXXXXXXXX',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'APC',
|
||||
@@ -27,7 +26,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'suggested_area': None,
|
||||
'sw_version': '3.14.14 (31 May 2016) unknown',
|
||||
'via_device_id': None,
|
||||
})
|
||||
@@ -50,7 +48,6 @@
|
||||
'XXXX',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'APC',
|
||||
@@ -60,7 +57,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'suggested_area': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
@@ -83,7 +79,6 @@
|
||||
'mocked-config-entry-id',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'APC',
|
||||
@@ -93,7 +88,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'suggested_area': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
@@ -116,7 +110,6 @@
|
||||
'mocked-config-entry-id',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'APC',
|
||||
@@ -126,7 +119,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'suggested_area': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
'tmt100',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'August Home Inc.',
|
||||
@@ -27,7 +26,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'suggested_area': 'tmt100 Name',
|
||||
'sw_version': '3.1.0-HYDRC75+201909251139',
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
'online_with_doorsense',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'August Home Inc.',
|
||||
@@ -31,7 +30,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'suggested_area': 'online_with_doorsense Name',
|
||||
'sw_version': 'undefined-4.3.0-1.8.14',
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
'00:40:8c:12:34:56',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Axis Communications AB',
|
||||
@@ -31,7 +30,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': '00:40:8c:12:34:56',
|
||||
'suggested_area': None,
|
||||
'sw_version': '9.10.1',
|
||||
'via_device_id': None,
|
||||
})
|
||||
@@ -58,7 +56,6 @@
|
||||
'00:40:8c:12:34:56',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Axis Communications AB',
|
||||
@@ -68,7 +65,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': '00:40:8c:12:34:56',
|
||||
'suggested_area': None,
|
||||
'sw_version': '9.80.1',
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bsblan import BSBLANConnectionError
|
||||
from bsblan import BSBLANAuthError, BSBLANConnectionError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
|
||||
from homeassistant.components.bsblan.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
async def test_load_unload_config_entry(
|
||||
@@ -45,3 +46,32 @@ async def test_config_entry_not_ready(
|
||||
|
||||
assert len(mock_bsblan.state.mock_calls) == 1
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_config_entry_auth_failed_triggers_reauth(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_bsblan: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that BSBLANAuthError during coordinator update triggers reauth flow."""
|
||||
# First, set up the integration successfully
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
# Mock BSBLANAuthError during next update
|
||||
mock_bsblan.initialize.side_effect = BSBLANAuthError("Authentication failed")
|
||||
|
||||
# Advance time by the coordinator's update interval to trigger update
|
||||
freezer.tick(delta=20) # Advance beyond the 12 second scan interval + random offset
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check that a reauth flow has been started
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert len(flows) == 1
|
||||
assert flows[0]["context"]["source"] == "reauth"
|
||||
assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
'0020c2d8',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Cambridge Audio',
|
||||
@@ -27,7 +26,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': '0020c2d8',
|
||||
'suggested_area': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
'01234E56789A',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Dresden Elektronik',
|
||||
@@ -27,7 +26,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'suggested_area': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
'1234567890',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'devolo',
|
||||
@@ -31,7 +30,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': '1234567890',
|
||||
'suggested_area': None,
|
||||
'sw_version': '5.6.1',
|
||||
'via_device_id': None,
|
||||
})
|
||||
@@ -58,7 +56,6 @@
|
||||
'1234567890',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'devolo',
|
||||
@@ -68,7 +65,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': '1234567890',
|
||||
'suggested_area': None,
|
||||
'sw_version': '5.6.1',
|
||||
'via_device_id': None,
|
||||
})
|
||||
@@ -91,7 +87,6 @@
|
||||
'1234567890',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'devolo',
|
||||
@@ -101,7 +96,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': '1234567890',
|
||||
'suggested_area': None,
|
||||
'sw_version': '5.6.1',
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
'E1234567890000000001',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Ecovacs',
|
||||
@@ -27,7 +26,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': 'E1234567890000000001',
|
||||
'suggested_area': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
'GW24L1A02987',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Elgato',
|
||||
@@ -80,7 +79,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': 'GW24L1A02987',
|
||||
'suggested_area': None,
|
||||
'sw_version': '1.0.4 (229)',
|
||||
'via_device_id': None,
|
||||
})
|
||||
@@ -156,7 +154,6 @@
|
||||
'GW24L1A02987',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Elgato',
|
||||
@@ -166,7 +163,6 @@
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': 'GW24L1A02987',
|
||||
'suggested_area': None,
|
||||
'sw_version': '1.0.4 (229)',
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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})
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user