mirror of
https://github.com/home-assistant/core.git
synced 2026-06-17 01:12:51 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f9934004a | |||
| 427154074a |
@@ -642,7 +642,6 @@ homeassistant.components.xbox.*
|
||||
homeassistant.components.xiaomi_ble.*
|
||||
homeassistant.components.yale_smart_alarm.*
|
||||
homeassistant.components.yalexs_ble.*
|
||||
homeassistant.components.yoto.*
|
||||
homeassistant.components.youtube.*
|
||||
homeassistant.components.zeroconf.*
|
||||
homeassistant.components.zinvolt.*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"jid_options_description": "Additional grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity.",
|
||||
"jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity.",
|
||||
"jid_options_name": "JID options",
|
||||
"key_press": "Press",
|
||||
"key_release": "Release",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from compit_inext_api import Parameter
|
||||
from compit_inext_api import Param, Parameter
|
||||
from compit_inext_api.consts import (
|
||||
CompitFanMode,
|
||||
CompitHVACMode,
|
||||
@@ -150,7 +150,7 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
|
||||
value = self.get_parameter_value(CompitParameter.CURRENT_TEMPERATURE)
|
||||
if value is None:
|
||||
return None
|
||||
return float(value)
|
||||
return float(value.value)
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
@@ -158,7 +158,7 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
|
||||
value = self.get_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE)
|
||||
if value is None:
|
||||
return None
|
||||
return float(value)
|
||||
return float(value.value)
|
||||
|
||||
@cached_property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
@@ -195,24 +195,27 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
|
||||
"""Return the current preset mode."""
|
||||
preset_mode = self.get_parameter_value(CompitParameter.PRESET_MODE)
|
||||
|
||||
if preset_mode is not None:
|
||||
return COMPIT_PRESET_MAP.get(CompitPresetMode(preset_mode))
|
||||
if preset_mode:
|
||||
compit_preset_mode = CompitPresetMode(preset_mode.value)
|
||||
return COMPIT_PRESET_MAP.get(compit_preset_mode)
|
||||
return None
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
fan_mode = self.get_parameter_value(CompitParameter.FAN_MODE)
|
||||
if fan_mode is not None:
|
||||
return COMPIT_FANSPEED_MAP.get(CompitFanMode(fan_mode))
|
||||
if fan_mode:
|
||||
compit_fan_mode = CompitFanMode(fan_mode.value)
|
||||
return COMPIT_FANSPEED_MAP.get(compit_fan_mode)
|
||||
return None
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
hvac_mode = self.get_parameter_value(CompitParameter.HVAC_MODE)
|
||||
if hvac_mode is not None:
|
||||
return COMPIT_MODE_MAP.get(CompitHVACMode(hvac_mode))
|
||||
if hvac_mode:
|
||||
compit_hvac_mode = CompitHVACMode(hvac_mode.value)
|
||||
return COMPIT_MODE_MAP.get(compit_hvac_mode)
|
||||
return None
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
@@ -255,6 +258,8 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def get_parameter_value(self, parameter: CompitParameter) -> str | float | None:
|
||||
def get_parameter_value(self, parameter: CompitParameter) -> Param | None:
|
||||
"""Get the parameter value from the device state."""
|
||||
return self.coordinator.connector.get_current_value(self.device_id, parameter)
|
||||
return self.coordinator.connector.get_device_parameter(
|
||||
self.device_id, parameter
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["compit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["compit-inext-api==0.9.1"]
|
||||
"requirements": ["compit-inext-api==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -852,7 +852,7 @@ class DefaultAgent(ConversationEntity):
|
||||
)
|
||||
|
||||
# Build filtered slot list
|
||||
text_lower = remove_punctuation(text).strip().lower()
|
||||
text_lower = text.strip().lower()
|
||||
return TextSlotList(
|
||||
name="name",
|
||||
values=[
|
||||
@@ -889,8 +889,7 @@ class DefaultAgent(ConversationEntity):
|
||||
for name in intent.async_get_entity_aliases(
|
||||
self.hass, entity_entry, state=state
|
||||
):
|
||||
# Strip punctuation so aliases match the cleaned input text.
|
||||
yield (remove_punctuation(name).strip(), name, context)
|
||||
yield (name, name, context)
|
||||
|
||||
def _recognize_strict(
|
||||
self,
|
||||
@@ -1163,7 +1162,7 @@ class DefaultAgent(ConversationEntity):
|
||||
areas = ar.async_get(self.hass)
|
||||
area_names = []
|
||||
for area in areas.async_list_areas():
|
||||
area_names.append((remove_punctuation(area.name).strip(), area.name))
|
||||
area_names.append((area.name, area.name))
|
||||
if not area.aliases:
|
||||
continue
|
||||
|
||||
@@ -1172,13 +1171,13 @@ class DefaultAgent(ConversationEntity):
|
||||
if not alias:
|
||||
continue
|
||||
|
||||
area_names.append((remove_punctuation(alias).strip(), alias))
|
||||
area_names.append((alias, alias))
|
||||
|
||||
# Expose all floors.
|
||||
floors = fr.async_get(self.hass)
|
||||
floor_names = []
|
||||
for floor in floors.async_list_floors():
|
||||
floor_names.append((remove_punctuation(floor.name).strip(), floor.name))
|
||||
floor_names.append((floor.name, floor.name))
|
||||
if not floor.aliases:
|
||||
continue
|
||||
|
||||
@@ -1187,7 +1186,7 @@ class DefaultAgent(ConversationEntity):
|
||||
if not alias:
|
||||
continue
|
||||
|
||||
floor_names.append((remove_punctuation(alias).strip(), floor.name))
|
||||
floor_names.append((alias, floor.name))
|
||||
|
||||
# Build trie
|
||||
self._exposed_names_trie = Trie()
|
||||
|
||||
@@ -25,7 +25,7 @@ from homeassistant.components.climate import (
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ElectraSmartConfigEntry
|
||||
@@ -145,7 +145,6 @@ class ElectraClimateEntity(ClimateEntity):
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._electra_ac_device.mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, self._electra_ac_device.mac)},
|
||||
name=device.name,
|
||||
model=self._electra_ac_device.model,
|
||||
manufacturer=self._electra_ac_device.manufactor,
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"search": "IMAP search",
|
||||
"server": "Server",
|
||||
"ssl_cipher_list": "SSL cipher list",
|
||||
"ssl_cipher_list": "SSL cipher list (Advanced)",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from enum import Enum
|
||||
import logging
|
||||
@@ -49,7 +49,6 @@ from homeassistant.helpers.event import (
|
||||
async_track_state_report_event,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_MAX_SUB_INTERVAL,
|
||||
@@ -340,7 +339,8 @@ class IntegrationSensor(RestoreSensor):
|
||||
else max_sub_interval
|
||||
)
|
||||
self._max_sub_interval_exceeded_callback: CALLBACK_TYPE = lambda *args: None
|
||||
self._last_integration_time: datetime = dt_util.utcnow()
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._last_integration_time: datetime = datetime.now(tz=UTC)
|
||||
self._last_integration_trigger = _IntegrationTrigger.StateEvent
|
||||
self._attr_suggested_display_precision = round_digits or 2
|
||||
|
||||
@@ -499,7 +499,8 @@ class IntegrationSensor(RestoreSensor):
|
||||
old_timestamp, new_timestamp, old_state, new_state
|
||||
)
|
||||
self._last_integration_trigger = _IntegrationTrigger.StateEvent
|
||||
self._last_integration_time = dt_util.utcnow()
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._last_integration_time = datetime.now(tz=UTC)
|
||||
finally:
|
||||
# When max_sub_interval exceeds without state change the source is assumed
|
||||
# constant with the last known state (new_state).
|
||||
@@ -607,7 +608,8 @@ class IntegrationSensor(RestoreSensor):
|
||||
self._update_integral(area)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._last_integration_time = dt_util.utcnow()
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._last_integration_time = datetime.now(tz=UTC)
|
||||
self._last_integration_trigger = _IntegrationTrigger.TimeElapsed
|
||||
|
||||
self._schedule_max_sub_interval_exceeded_if_state_is_numeric(
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
"""LG IR Remote integration for Home Assistant."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.EVENT, Platform.MEDIA_PLAYER]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up LG IR from a config entry."""
|
||||
@@ -20,14 +16,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a LG IR config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate old config entry."""
|
||||
if entry.version == 1:
|
||||
# v1 used the infrared entity_id in the entry's unique_id, which is
|
||||
# not stable and was removed in v2.
|
||||
_LOGGER.debug("Migrating config entry from version 1 to 2")
|
||||
hass.config_entries.async_update_entry(entry, unique_id=None, version=2)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Config flow for LG IR integration."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -35,7 +35,7 @@ DEVICE_TYPE_NAMES: dict[LGDeviceType, str] = {
|
||||
class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle config flow for LG IR."""
|
||||
|
||||
VERSION = 2
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -49,39 +49,24 @@ class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID)
|
||||
receiver_id = user_input.get(CONF_INFRARED_RECEIVER_ENTITY_ID)
|
||||
if emitter_id or receiver_id:
|
||||
if entity_id := user_input.get(CONF_INFRARED_ENTITY_ID) or user_input.get(
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID
|
||||
):
|
||||
device_type = user_input[CONF_DEVICE_TYPE]
|
||||
|
||||
if emitter_id:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_DEVICE_TYPE: device_type,
|
||||
CONF_INFRARED_ENTITY_ID: emitter_id,
|
||||
}
|
||||
)
|
||||
if receiver_id:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_DEVICE_TYPE: device_type,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: receiver_id,
|
||||
}
|
||||
)
|
||||
await self.async_set_unique_id(f"lg_ir_{device_type}_{entity_id}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Get entity name for the title
|
||||
title_entity_id = emitter_id or receiver_id
|
||||
if TYPE_CHECKING:
|
||||
assert title_entity_id is not None
|
||||
ent_reg = er.async_get(self.hass)
|
||||
entry = ent_reg.async_get(title_entity_id)
|
||||
title_entity_name = (
|
||||
entry.name or entry.original_name or title_entity_id
|
||||
entry = ent_reg.async_get(entity_id)
|
||||
entity_name = (
|
||||
entry.name or entry.original_name or entity_id
|
||||
if entry
|
||||
else title_entity_id
|
||||
else entity_id
|
||||
)
|
||||
device_type_name = DEVICE_TYPE_NAMES[LGDeviceType(device_type)]
|
||||
title = f"LG {device_type_name} via {title_entity_name}"
|
||||
title = f"LG {device_type_name} via {entity_name}"
|
||||
|
||||
return self.async_create_entry(title=title, data=user_input)
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@ from .const import (
|
||||
DEFAULT_QOS,
|
||||
DEFAULT_TRANSPORT,
|
||||
DEFAULT_WILL,
|
||||
DEFAULT_WS_HEADERS,
|
||||
DEFAULT_WS_PATH,
|
||||
DOMAIN,
|
||||
MQTT_CONNECTION_STATE,
|
||||
@@ -414,7 +413,7 @@ class MqttClientSetup:
|
||||
tls_insecure = config.get(CONF_TLS_INSECURE)
|
||||
if transport == TRANSPORT_WEBSOCKETS:
|
||||
ws_path: str = config.get(CONF_WS_PATH, DEFAULT_WS_PATH)
|
||||
ws_headers: dict[str, str] = config.get(CONF_WS_HEADERS, DEFAULT_WS_HEADERS)
|
||||
ws_headers: dict[str, str] = config.get(CONF_WS_HEADERS, {})
|
||||
self._client.ws_set_options(ws_path, ws_headers)
|
||||
if certificate is not None:
|
||||
self._client.tls_set(
|
||||
|
||||
@@ -373,7 +373,6 @@ from .const import (
|
||||
DEFAULT_CLIMATE_INITIAL_TEMPERATURE,
|
||||
DEFAULT_DISCOVERY,
|
||||
DEFAULT_ENCODING,
|
||||
DEFAULT_KEEPALIVE,
|
||||
DEFAULT_ON_COMMAND_TYPE,
|
||||
DEFAULT_PAYLOAD_ARM_AWAY,
|
||||
DEFAULT_PAYLOAD_ARM_CUSTOM_BYPASS,
|
||||
@@ -414,7 +413,6 @@ from .const import (
|
||||
DEFAULT_TILT_OPEN_POSITION,
|
||||
DEFAULT_TRANSPORT,
|
||||
DEFAULT_WILL,
|
||||
DEFAULT_WS_PATH,
|
||||
DOMAIN,
|
||||
REMOTE_CODE,
|
||||
REMOTE_CODE_TEXT,
|
||||
@@ -441,7 +439,7 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 5
|
||||
|
||||
CONF_CLIENT_KEY_PASSWORD = "client_key_password"
|
||||
|
||||
ADVANCED_OPTIONS = "advanced_options"
|
||||
OTHER_SETTINGS = "other_settings"
|
||||
SET_CA_CERT = "set_ca_cert"
|
||||
SET_CLIENT_CERT = "set_client_cert"
|
||||
|
||||
@@ -4036,24 +4034,22 @@ def subentry_schema_default_data_from_fields(
|
||||
@callback
|
||||
def update_password_from_user_input(
|
||||
entry_password: str | None, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
) -> None:
|
||||
"""Update the password if the entry has been updated.
|
||||
|
||||
As we want to avoid reflecting the stored password in the UI,
|
||||
we replace the suggested value in the UI with a sentitel,
|
||||
and we change it back here if it was changed.
|
||||
"""
|
||||
substituted_used_data = dict(user_input)
|
||||
# Take out the password submitted
|
||||
user_password: str | None = substituted_used_data.pop(CONF_PASSWORD, None)
|
||||
user_password: str | None = user_input.pop(CONF_PASSWORD, None)
|
||||
# Only add the password if it has changed.
|
||||
# If the sentinel password is submitted, we replace that with our current
|
||||
# password from the config entry data.
|
||||
password_changed = user_password is not None and user_password != PWD_NOT_CHANGED
|
||||
password = user_password if password_changed else entry_password
|
||||
if password is not None:
|
||||
substituted_used_data[CONF_PASSWORD] = password
|
||||
return substituted_used_data
|
||||
user_input[CONF_PASSWORD] = password
|
||||
|
||||
|
||||
REAUTH_SCHEMA = vol.Schema(
|
||||
@@ -4063,6 +4059,35 @@ REAUTH_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
OTHER_SETTINGS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_CLIENT_ID): TEXT_SELECTOR,
|
||||
vol.Optional(CONF_KEEPALIVE): KEEPALIVE_SELECTOR,
|
||||
vol.Required(SET_CLIENT_CERT): BOOLEAN_SELECTOR,
|
||||
vol.Optional(CONF_CLIENT_CERT): CERT_UPLOAD_SELECTOR,
|
||||
vol.Optional(CONF_CLIENT_KEY): CERT_KEY_UPLOAD_SELECTOR,
|
||||
vol.Optional(CONF_CLIENT_KEY_PASSWORD): PASSWORD_SELECTOR,
|
||||
vol.Required(SET_CA_CERT): BROKER_VERIFICATION_SELECTOR,
|
||||
vol.Optional(CONF_CERTIFICATE): CA_CERT_UPLOAD_SELECTOR,
|
||||
vol.Optional(CONF_TLS_INSECURE): BOOLEAN_SELECTOR,
|
||||
vol.Required(CONF_TRANSPORT, default=DEFAULT_TRANSPORT): TRANSPORT_SELECTOR,
|
||||
vol.Optional(CONF_WS_PATH): TEXT_SELECTOR,
|
||||
vol.Optional(CONF_WS_HEADERS): WS_HEADERS_SELECTOR,
|
||||
}
|
||||
)
|
||||
CONFIG_DATAFLOW_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BROKER): TEXT_SELECTOR,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): PORT_SELECTOR,
|
||||
vol.Required(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): PROTOCOL_SELECTOR,
|
||||
vol.Optional(CONF_USERNAME): TEXT_SELECTOR,
|
||||
vol.Optional(CONF_PASSWORD): PASSWORD_SELECTOR,
|
||||
vol.Optional(OTHER_SETTINGS): section(
|
||||
OTHER_SETTINGS_SCHEMA, SectionConfig({"collapsed": True})
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
@@ -4072,11 +4097,13 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
_hassio_discovery: dict[str, Any] | None = None
|
||||
_addon_manager: AddonManager
|
||||
last_uploaded: dict[str, Any]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Set up flow instance."""
|
||||
self.install_task: asyncio.Task | None = None
|
||||
self.start_task: asyncio.Task | None = None
|
||||
self.last_uploaded = {}
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
@@ -4308,8 +4335,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
if user_input:
|
||||
substituted_used_data = update_password_from_user_input(
|
||||
reauth_entry.data.get(CONF_PASSWORD), user_input
|
||||
substituted_used_data = deepcopy(user_input)
|
||||
update_password_from_user_input(
|
||||
reauth_entry.data.get(CONF_PASSWORD), substituted_used_data
|
||||
)
|
||||
new_entry_data = {**reauth_entry.data, **substituted_used_data}
|
||||
if await self.hass.async_add_executor_job(
|
||||
@@ -4333,49 +4361,72 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_get_entry_defaults(self) -> dict[str, Any]:
|
||||
"""Load the default settings from the entry."""
|
||||
data = self._get_reconfigure_entry().data
|
||||
advanced_schema_settings: dict[str, Any] = {
|
||||
key: data[key] for key in OTHER_SETTINGS_SCHEMA.schema if key in data
|
||||
}
|
||||
advanced_schema_settings[SET_CLIENT_CERT] = (
|
||||
CONF_CLIENT_CERT in advanced_schema_settings
|
||||
) and (CONF_CLIENT_KEY in advanced_schema_settings)
|
||||
advanced_schema_settings.pop(CONF_CLIENT_CERT, None)
|
||||
advanced_schema_settings.pop(CONF_CLIENT_KEY, None)
|
||||
conf_cert = advanced_schema_settings.pop(CONF_CERTIFICATE, None)
|
||||
advanced_schema_settings[SET_CA_CERT] = (
|
||||
"auto"
|
||||
if conf_cert == "auto"
|
||||
else "custom"
|
||||
if conf_cert is not None
|
||||
else "off"
|
||||
)
|
||||
if CONF_WS_HEADERS in advanced_schema_settings:
|
||||
advanced_schema_settings[CONF_WS_HEADERS] = json_dumps(
|
||||
advanced_schema_settings.pop(CONF_WS_HEADERS)
|
||||
)
|
||||
|
||||
settings: dict[str, Any] = {
|
||||
key: data[key] for key in CONFIG_DATAFLOW_SCHEMA.schema if key in data
|
||||
}
|
||||
settings[OTHER_SETTINGS] = advanced_schema_settings
|
||||
if CONF_PASSWORD in settings:
|
||||
# Hide entry password
|
||||
settings[CONF_PASSWORD] = PWD_NOT_CHANGED
|
||||
return settings
|
||||
|
||||
async def async_step_broker(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the setup."""
|
||||
errors: dict[str, str] = {}
|
||||
fields: OrderedDict[Any, Any] = OrderedDict()
|
||||
validated_user_input: dict[str, Any] = {}
|
||||
schema = CONFIG_DATAFLOW_SCHEMA
|
||||
entry_config_update: dict[str, Any] = {}
|
||||
entry_defaults: dict[str, Any] | None = None
|
||||
if is_reconfigure := (self.source == SOURCE_RECONFIGURE):
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
if await async_get_broker_settings(
|
||||
entry_defaults = self.async_get_entry_defaults()
|
||||
if await async_validate_broker_settings(
|
||||
self,
|
||||
fields,
|
||||
reconfigure_entry.data if is_reconfigure else None,
|
||||
user_input,
|
||||
validated_user_input,
|
||||
entry_config_update,
|
||||
errors,
|
||||
):
|
||||
if is_reconfigure:
|
||||
validated_user_input = update_password_from_user_input(
|
||||
reconfigure_entry.data.get(CONF_PASSWORD), validated_user_input
|
||||
return self.async_update_and_abort(
|
||||
reconfigure_entry,
|
||||
data=entry_config_update,
|
||||
)
|
||||
|
||||
can_connect = await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
validated_user_input,
|
||||
return self.async_create_entry(
|
||||
title=entry_config_update[CONF_BROKER],
|
||||
data=entry_config_update,
|
||||
)
|
||||
|
||||
if can_connect:
|
||||
if is_reconfigure:
|
||||
return self.async_update_and_abort(
|
||||
reconfigure_entry,
|
||||
data=validated_user_input,
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=validated_user_input[CONF_BROKER],
|
||||
data=validated_user_input,
|
||||
)
|
||||
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="broker", data_schema=vol.Schema(fields), errors=errors
|
||||
schema = self.add_suggested_values_to_schema(
|
||||
schema, (entry_defaults or {}) | (user_input or {})
|
||||
)
|
||||
return self.async_show_form(step_id="broker", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -5248,331 +5299,162 @@ async def _get_uploaded_file(hass: HomeAssistant, id: str) -> bytes:
|
||||
return await hass.async_add_executor_job(_proces_uploaded_file)
|
||||
|
||||
|
||||
def _validate_pki_file(
|
||||
file_id: str | None, pem_data: str | None, errors: dict[str, str], error: str
|
||||
) -> bool:
|
||||
"""Return False if uploaded file could not be converted to PEM format."""
|
||||
if file_id and not pem_data:
|
||||
errors["base"] = error
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def async_get_broker_settings(
|
||||
flow: ConfigFlow | OptionsFlow,
|
||||
fields: OrderedDict[Any, Any],
|
||||
async def async_validate_broker_settings(
|
||||
flow: FlowHandler,
|
||||
entry_config: MappingProxyType[str, Any] | None,
|
||||
user_input: dict[str, Any] | None,
|
||||
validated_user_input: dict[str, Any],
|
||||
entry_config_update: dict[str, Any],
|
||||
errors: dict[str, str],
|
||||
) -> bool:
|
||||
"""Build the config flow schema to collect the broker settings.
|
||||
"""Validate the broker settings, and return the updated entry dataset."""
|
||||
|
||||
Shows advanced options if one or more are configured
|
||||
or when the advanced_broker_options checkbox was selected.
|
||||
Returns True when settings are collected successfully.
|
||||
"""
|
||||
hass = flow.hass
|
||||
advanced_broker_options: bool = False
|
||||
user_input_basic: dict[str, Any] = {}
|
||||
current_config: dict[str, Any] = (
|
||||
entry_config.copy() if entry_config is not None else {}
|
||||
)
|
||||
|
||||
async def _async_validate_broker_settings(
|
||||
config: dict[str, Any],
|
||||
user_input: dict[str, Any],
|
||||
validated_user_input: dict[str, Any],
|
||||
errors: dict[str, str],
|
||||
async def _async_process_file_upload(
|
||||
upload_id: str,
|
||||
field: str,
|
||||
pem_type: PEMType,
|
||||
error_code: str,
|
||||
password: str | None = None,
|
||||
) -> bool:
|
||||
"""Additional validation on broker settings for better error messages."""
|
||||
|
||||
if CONF_PROTOCOL not in validated_user_input:
|
||||
validated_user_input[CONF_PROTOCOL] = DEFAULT_PROTOCOL
|
||||
# Get current certificate settings from config entry
|
||||
certificate: str | None = (
|
||||
"auto"
|
||||
if user_input.get(SET_CA_CERT, "off") == "auto"
|
||||
else config.get(CONF_CERTIFICATE)
|
||||
if user_input.get(SET_CA_CERT, "off") == "custom"
|
||||
else None
|
||||
)
|
||||
client_certificate: str | None = (
|
||||
config.get(CONF_CLIENT_CERT) if user_input.get(SET_CLIENT_CERT) else None
|
||||
)
|
||||
client_key: str | None = (
|
||||
config.get(CONF_CLIENT_KEY) if user_input.get(SET_CLIENT_CERT) else None
|
||||
)
|
||||
|
||||
# Prepare entry update with uploaded files
|
||||
validated_user_input.update(user_input)
|
||||
client_certificate_id: str | None = user_input.get(CONF_CLIENT_CERT)
|
||||
client_key_id: str | None = user_input.get(CONF_CLIENT_KEY)
|
||||
# We do not store the private key password in the entry data
|
||||
client_key_password: str | None = validated_user_input.pop(
|
||||
CONF_CLIENT_KEY_PASSWORD, None
|
||||
)
|
||||
if (client_certificate_id and not client_key_id) or (
|
||||
not client_certificate_id and client_key_id
|
||||
):
|
||||
errors["base"] = "invalid_inclusion"
|
||||
return False
|
||||
certificate_id: str | None = user_input.get(CONF_CERTIFICATE)
|
||||
if certificate_id:
|
||||
certificate_data_raw = await _get_uploaded_file(hass, certificate_id)
|
||||
certificate = async_convert_to_pem(
|
||||
certificate_data_raw, PEMType.CERTIFICATE
|
||||
)
|
||||
if not _validate_pki_file(
|
||||
certificate_id, certificate, errors, "bad_certificate"
|
||||
):
|
||||
return False
|
||||
|
||||
# Return to form for file upload CA cert or client cert and key
|
||||
if (
|
||||
(
|
||||
not client_certificate
|
||||
and user_input.get(SET_CLIENT_CERT)
|
||||
and not client_certificate_id
|
||||
)
|
||||
or (
|
||||
not certificate
|
||||
and user_input.get(SET_CA_CERT, "off") == "custom"
|
||||
and not certificate_id
|
||||
)
|
||||
or (
|
||||
user_input.get(CONF_TRANSPORT) == TRANSPORT_WEBSOCKETS
|
||||
and CONF_WS_PATH not in user_input
|
||||
)
|
||||
):
|
||||
return False
|
||||
|
||||
if client_certificate_id:
|
||||
client_certificate_data = await _get_uploaded_file(
|
||||
hass, client_certificate_id
|
||||
)
|
||||
client_certificate = async_convert_to_pem(
|
||||
client_certificate_data, PEMType.CERTIFICATE
|
||||
)
|
||||
if not _validate_pki_file(
|
||||
client_certificate_id, client_certificate, errors, "bad_client_cert"
|
||||
):
|
||||
return False
|
||||
|
||||
if client_key_id:
|
||||
client_key_data = await _get_uploaded_file(hass, client_key_id)
|
||||
client_key = async_convert_to_pem(
|
||||
client_key_data, PEMType.PRIVATE_KEY, password=client_key_password
|
||||
)
|
||||
if not _validate_pki_file(
|
||||
client_key_id, client_key, errors, "client_key_error"
|
||||
):
|
||||
return False
|
||||
|
||||
certificate_data: dict[str, Any] = {}
|
||||
if certificate:
|
||||
certificate_data[CONF_CERTIFICATE] = certificate
|
||||
if client_certificate:
|
||||
certificate_data[CONF_CLIENT_CERT] = client_certificate
|
||||
certificate_data[CONF_CLIENT_KEY] = client_key
|
||||
|
||||
validated_user_input.update(certificate_data)
|
||||
await async_create_certificate_temp_files(hass, certificate_data)
|
||||
if error := await hass.async_add_executor_job(
|
||||
check_certicate_chain,
|
||||
):
|
||||
errors["base"] = error
|
||||
return False
|
||||
|
||||
validated_user_input.pop(SET_CA_CERT, None)
|
||||
validated_user_input.pop(SET_CLIENT_CERT, None)
|
||||
if validated_user_input.get(CONF_TRANSPORT, TRANSPORT_TCP) == TRANSPORT_TCP:
|
||||
validated_user_input.pop(CONF_WS_PATH, None)
|
||||
validated_user_input.pop(CONF_WS_HEADERS, None)
|
||||
return True
|
||||
"""Get uploaded file, or a preserved copy, and convert to a PEM file."""
|
||||
try:
|
||||
validated_user_input[CONF_WS_HEADERS] = json_loads(
|
||||
validated_user_input.get(CONF_WS_HEADERS, "{}")
|
||||
data_raw = await _get_uploaded_file(hass, upload_id)
|
||||
except ValueError:
|
||||
# Use preserved file if available.
|
||||
# When an uploaded file was read, but an error occurs,
|
||||
# the form will reload but the temporary file from the upload
|
||||
# will not be available any more. If it was processed correctly,
|
||||
# we can use the preserved copy.
|
||||
if upload_id in flow.last_uploaded:
|
||||
data_raw = flow.last_uploaded[upload_id]
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# Preserve a copy in case the validation fails,
|
||||
# and we need it later
|
||||
flow.last_uploaded[upload_id] = data_raw
|
||||
pem_data = async_convert_to_pem(data_raw, pem_type, password)
|
||||
if upload_id and not pem_data:
|
||||
errors["base"] = error_code
|
||||
return False
|
||||
entry_config_update[field] = pem_data
|
||||
return True
|
||||
|
||||
if user_input is None:
|
||||
return False
|
||||
|
||||
hass = flow.hass
|
||||
|
||||
# Copy basic and other entry fields
|
||||
entry_config_update |= user_input
|
||||
entry_config_update.update(entry_config_update.pop(OTHER_SETTINGS))
|
||||
# Pop incompatible fields for update
|
||||
for key in (
|
||||
SET_CA_CERT,
|
||||
SET_CLIENT_CERT,
|
||||
CONF_CERTIFICATE,
|
||||
CONF_CLIENT_CERT,
|
||||
CONF_CLIENT_KEY,
|
||||
CONF_CLIENT_KEY_PASSWORD,
|
||||
):
|
||||
entry_config_update.pop(key, None)
|
||||
|
||||
# Get current CA certificate settings from config entry
|
||||
if (set_ca_cert := user_input[OTHER_SETTINGS][SET_CA_CERT]) == "auto":
|
||||
entry_config_update[CONF_CERTIFICATE] = "auto"
|
||||
elif (
|
||||
entry_config is not None
|
||||
and set_ca_cert == "custom"
|
||||
and (current_cert := entry_config.get(CONF_CERTIFICATE))
|
||||
):
|
||||
entry_config_update[CONF_CERTIFICATE] = current_cert
|
||||
|
||||
# Prepare entry update with uploaded certificate files
|
||||
# converted to PEM format
|
||||
new_client_certificate: str | None = user_input[OTHER_SETTINGS].get(
|
||||
CONF_CLIENT_CERT
|
||||
)
|
||||
new_client_key: str | None = user_input[OTHER_SETTINGS].get(CONF_CLIENT_KEY)
|
||||
set_client_cert = user_input[OTHER_SETTINGS][SET_CLIENT_CERT]
|
||||
|
||||
if (new_client_certificate and not new_client_key) or (
|
||||
not new_client_certificate and new_client_key
|
||||
):
|
||||
errors["base"] = "invalid_inclusion"
|
||||
return False
|
||||
|
||||
if new_certificate := user_input[OTHER_SETTINGS].get(CONF_CERTIFICATE):
|
||||
if not await _async_process_file_upload(
|
||||
new_certificate, CONF_CERTIFICATE, PEMType.CERTIFICATE, "bad_certificate"
|
||||
):
|
||||
return False
|
||||
|
||||
if new_client_certificate:
|
||||
if not await _async_process_file_upload(
|
||||
new_client_certificate,
|
||||
CONF_CLIENT_CERT,
|
||||
PEMType.CERTIFICATE,
|
||||
"bad_client_cert",
|
||||
):
|
||||
return False
|
||||
elif (
|
||||
entry_config is not None
|
||||
and set_client_cert
|
||||
and (client_cert := entry_config.get(CONF_CLIENT_CERT))
|
||||
):
|
||||
entry_config_update[CONF_CLIENT_CERT] = client_cert
|
||||
|
||||
if new_client_key:
|
||||
if not await _async_process_file_upload(
|
||||
new_client_key,
|
||||
CONF_CLIENT_KEY,
|
||||
PEMType.PRIVATE_KEY,
|
||||
"client_key_error",
|
||||
password=user_input[OTHER_SETTINGS].get(CONF_CLIENT_KEY_PASSWORD),
|
||||
):
|
||||
return False
|
||||
elif (
|
||||
entry_config is not None
|
||||
and set_client_cert
|
||||
and (client_key := entry_config.get(CONF_CLIENT_KEY))
|
||||
):
|
||||
entry_config_update[CONF_CLIENT_KEY] = client_key
|
||||
|
||||
# We temporary create the current and new uploaded certificate files
|
||||
# and we check the certificate chain.
|
||||
await async_create_certificate_temp_files(hass, entry_config_update)
|
||||
if error := await hass.async_add_executor_job(
|
||||
check_certicate_chain,
|
||||
):
|
||||
errors["base"] = error
|
||||
return False
|
||||
|
||||
if user_input[OTHER_SETTINGS].get(CONF_TRANSPORT, TRANSPORT_TCP) == TRANSPORT_TCP:
|
||||
entry_config_update.pop(CONF_WS_PATH, None)
|
||||
entry_config_update.pop(CONF_WS_HEADERS, None)
|
||||
else:
|
||||
# Web socket transport
|
||||
try:
|
||||
entry_config_update[CONF_WS_HEADERS] = json_loads(
|
||||
user_input[OTHER_SETTINGS].get(CONF_WS_HEADERS, "{}")
|
||||
)
|
||||
schema = vol.Schema({cv.string: cv.template})
|
||||
schema(validated_user_input[CONF_WS_HEADERS])
|
||||
schema = vol.Schema({str: str})
|
||||
schema(entry_config_update[CONF_WS_HEADERS])
|
||||
except (*JSON_DECODE_EXCEPTIONS, vol.MultipleInvalid):
|
||||
errors["base"] = "bad_ws_headers"
|
||||
return False
|
||||
|
||||
# Test the configuration
|
||||
if entry_config is not None:
|
||||
update_password_from_user_input(
|
||||
entry_config.get(CONF_PASSWORD), entry_config_update
|
||||
)
|
||||
if await hass.async_add_executor_job(
|
||||
try_connection,
|
||||
entry_config_update,
|
||||
):
|
||||
return True
|
||||
|
||||
if user_input:
|
||||
user_input_basic = user_input.copy()
|
||||
advanced_broker_options = user_input_basic.get(ADVANCED_OPTIONS, False)
|
||||
if ADVANCED_OPTIONS not in user_input or advanced_broker_options is False:
|
||||
if await _async_validate_broker_settings(
|
||||
current_config,
|
||||
user_input_basic,
|
||||
validated_user_input,
|
||||
errors,
|
||||
):
|
||||
return True
|
||||
# Get defaults settings from previous post
|
||||
current_broker = user_input_basic.get(CONF_BROKER)
|
||||
current_port = user_input_basic.get(CONF_PORT, DEFAULT_PORT)
|
||||
current_user = user_input_basic.get(CONF_USERNAME)
|
||||
current_pass = user_input_basic.get(CONF_PASSWORD)
|
||||
else:
|
||||
# Get default settings from entry (if any)
|
||||
current_broker = current_config.get(CONF_BROKER)
|
||||
current_port = current_config.get(CONF_PORT, DEFAULT_PORT)
|
||||
current_user = current_config.get(CONF_USERNAME)
|
||||
# Return the sentinel password to avoid exposure
|
||||
current_entry_pass = current_config.get(CONF_PASSWORD)
|
||||
current_pass = PWD_NOT_CHANGED if current_entry_pass else None
|
||||
|
||||
# Treat the previous post as an update of the current settings
|
||||
# (if there was a basic broker setup step)
|
||||
current_config.update(user_input_basic)
|
||||
|
||||
# Get default settings for advanced broker options
|
||||
current_client_id = current_config.get(CONF_CLIENT_ID)
|
||||
current_keepalive = current_config.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE)
|
||||
current_ca_certificate = current_config.get(CONF_CERTIFICATE)
|
||||
current_client_certificate = current_config.get(CONF_CLIENT_CERT)
|
||||
current_client_key = current_config.get(CONF_CLIENT_KEY)
|
||||
current_tls_insecure = current_config.get(CONF_TLS_INSECURE, False)
|
||||
current_protocol = current_config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)
|
||||
current_transport = current_config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
|
||||
current_ws_path = current_config.get(CONF_WS_PATH, DEFAULT_WS_PATH)
|
||||
current_ws_headers = (
|
||||
json_dumps(current_config.get(CONF_WS_HEADERS))
|
||||
if CONF_WS_HEADERS in current_config
|
||||
else None
|
||||
)
|
||||
advanced_broker_options |= bool(
|
||||
current_client_id
|
||||
or current_keepalive != DEFAULT_KEEPALIVE
|
||||
or current_ca_certificate
|
||||
or current_client_certificate
|
||||
or current_client_key
|
||||
or current_tls_insecure
|
||||
or current_config.get(SET_CA_CERT, "off") != "off"
|
||||
or current_config.get(SET_CLIENT_CERT)
|
||||
or current_transport == TRANSPORT_WEBSOCKETS
|
||||
)
|
||||
|
||||
# Build form
|
||||
fields[vol.Required(CONF_BROKER, default=current_broker)] = TEXT_SELECTOR
|
||||
fields[vol.Required(CONF_PORT, default=current_port)] = PORT_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_PROTOCOL,
|
||||
description={"suggested_value": current_protocol},
|
||||
)
|
||||
] = PROTOCOL_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
description={"suggested_value": current_user},
|
||||
)
|
||||
] = TEXT_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
description={"suggested_value": current_pass},
|
||||
)
|
||||
] = PASSWORD_SELECTOR
|
||||
# show advanced options checkbox if no defaults
|
||||
# of the advanced options are overridden
|
||||
if not advanced_broker_options:
|
||||
fields[
|
||||
vol.Optional(
|
||||
ADVANCED_OPTIONS,
|
||||
)
|
||||
] = BOOLEAN_SELECTOR
|
||||
return False
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_CLIENT_ID,
|
||||
description={"suggested_value": current_client_id},
|
||||
)
|
||||
] = TEXT_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_KEEPALIVE,
|
||||
description={"suggested_value": current_keepalive},
|
||||
)
|
||||
] = KEEPALIVE_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
SET_CLIENT_CERT,
|
||||
default=current_client_certificate is not None
|
||||
or current_config.get(SET_CLIENT_CERT) is True,
|
||||
)
|
||||
] = BOOLEAN_SELECTOR
|
||||
if (
|
||||
current_client_certificate is not None
|
||||
or current_config.get(SET_CLIENT_CERT) is True
|
||||
):
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_CLIENT_CERT,
|
||||
description={"suggested_value": user_input_basic.get(CONF_CLIENT_CERT)},
|
||||
)
|
||||
] = CERT_UPLOAD_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_CLIENT_KEY,
|
||||
description={"suggested_value": user_input_basic.get(CONF_CLIENT_KEY)},
|
||||
)
|
||||
] = CERT_KEY_UPLOAD_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_CLIENT_KEY_PASSWORD,
|
||||
description={
|
||||
"suggested_value": user_input_basic.get(CONF_CLIENT_KEY_PASSWORD)
|
||||
},
|
||||
)
|
||||
] = PASSWORD_SELECTOR
|
||||
verification_mode = current_config.get(SET_CA_CERT) or (
|
||||
"off"
|
||||
if current_ca_certificate is None
|
||||
else "auto"
|
||||
if current_ca_certificate == "auto"
|
||||
else "custom"
|
||||
)
|
||||
fields[
|
||||
vol.Optional(
|
||||
SET_CA_CERT,
|
||||
default=verification_mode,
|
||||
)
|
||||
] = BROKER_VERIFICATION_SELECTOR
|
||||
if current_ca_certificate is not None or verification_mode == "custom":
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_CERTIFICATE,
|
||||
user_input_basic.get(CONF_CERTIFICATE),
|
||||
)
|
||||
] = CA_CERT_UPLOAD_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_TLS_INSECURE,
|
||||
description={"suggested_value": current_tls_insecure},
|
||||
)
|
||||
] = BOOLEAN_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_TRANSPORT,
|
||||
description={"suggested_value": current_transport},
|
||||
)
|
||||
] = TRANSPORT_SELECTOR
|
||||
if current_transport == TRANSPORT_WEBSOCKETS:
|
||||
fields[
|
||||
vol.Optional(CONF_WS_PATH, description={"suggested_value": current_ws_path})
|
||||
] = TEXT_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_WS_HEADERS, description={"suggested_value": current_ws_headers}
|
||||
)
|
||||
] = WS_HEADERS_SELECTOR
|
||||
|
||||
# Show form
|
||||
errors["base"] = "cannot_connect"
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -315,7 +315,6 @@ DEFAULT_TILT_MAX = 100
|
||||
DEFAULT_TILT_MIN = 0
|
||||
DEFAULT_TILT_OPEN_POSITION = 100
|
||||
DEFAULT_TILT_OPTIMISTIC = False
|
||||
DEFAULT_WS_HEADERS: dict[str, str] = {}
|
||||
DEFAULT_WS_PATH = "/"
|
||||
DEFAULT_POSITION_CLOSED = 0
|
||||
DEFAULT_POSITION_OPEN = 100
|
||||
|
||||
@@ -26,46 +26,53 @@
|
||||
"step": {
|
||||
"broker": {
|
||||
"data": {
|
||||
"advanced_options": "Advanced options",
|
||||
"broker": "Broker",
|
||||
"certificate": "Upload custom CA certificate file",
|
||||
"client_cert": "Upload client certificate file",
|
||||
"client_id": "Client ID (leave empty to randomly generated one)",
|
||||
"client_key": "Upload private key file",
|
||||
"client_key_password": "[%key:common::config_flow::data::password%]",
|
||||
"keepalive": "The time between sending keep alive messages",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"protocol": "MQTT protocol",
|
||||
"set_ca_cert": "Broker certificate validation",
|
||||
"set_client_cert": "Use a client certificate",
|
||||
"tls_insecure": "Ignore broker certificate validation",
|
||||
"transport": "MQTT transport",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"ws_headers": "WebSocket headers in JSON format",
|
||||
"ws_path": "WebSocket path"
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"advanced_options": "Enable and select **Submit** to set advanced options.",
|
||||
"broker": "The hostname or IP address of your MQTT broker.",
|
||||
"certificate": "The custom CA certificate file to validate your MQTT broker's certificate.",
|
||||
"client_cert": "The client certificate to authenticate against your MQTT broker.",
|
||||
"client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.",
|
||||
"client_key": "The private key file that belongs to your client certificate.",
|
||||
"client_key_password": "The password for the private key file (if set).",
|
||||
"keepalive": "A value less than 90 seconds is advised.",
|
||||
"password": "The password to log in to your MQTT broker.",
|
||||
"port": "The port your MQTT broker listens to. For example 1883.",
|
||||
"protocol": "The MQTT protocol version that is used. Note that Home Assistant will silently change to version 5 if your broker supports it.",
|
||||
"set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT broker's certificate.",
|
||||
"set_client_cert": "Enable and select **Next** to set a client certificate and private key to authenticate against your MQTT broker.",
|
||||
"tls_insecure": "Option to ignore validation of your MQTT broker's certificate.",
|
||||
"transport": "The transport to be used for the connection to your MQTT broker.",
|
||||
"username": "The username to log in to your MQTT broker.",
|
||||
"ws_headers": "The WebSocket headers to pass through the WebSocket-based connection to your MQTT broker.",
|
||||
"ws_path": "The WebSocket path to be used for the connection to your MQTT broker."
|
||||
"username": "The username to log in to your MQTT broker."
|
||||
},
|
||||
"description": "Please enter the connection information of your MQTT broker."
|
||||
"description": "Please enter the connection information of your MQTT broker.",
|
||||
"sections": {
|
||||
"other_settings": {
|
||||
"data": {
|
||||
"certificate": "Upload custom CA certificate file",
|
||||
"client_cert": "Upload client certificate file",
|
||||
"client_id": "Client ID (leave empty to randomly generated one)",
|
||||
"client_key": "Upload private key file",
|
||||
"client_key_password": "[%key:common::config_flow::data::password%]",
|
||||
"keepalive": "The time between sending keep alive messages",
|
||||
"set_ca_cert": "Broker certificate validation",
|
||||
"set_client_cert": "Use a client certificate",
|
||||
"tls_insecure": "Ignore broker certificate validation",
|
||||
"transport": "MQTT transport",
|
||||
"ws_headers": "WebSocket headers in JSON format",
|
||||
"ws_path": "WebSocket path"
|
||||
},
|
||||
"data_description": {
|
||||
"certificate": "The custom CA certificate file to validate your MQTT broker's certificate.",
|
||||
"client_cert": "The client certificate to authenticate against your MQTT broker.",
|
||||
"client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.",
|
||||
"client_key": "The private key file that belongs to your client certificate.",
|
||||
"client_key_password": "The password for the private key file (if set).",
|
||||
"keepalive": "A value less than 90 seconds is advised. Defaults to 60 seconds.",
|
||||
"set_ca_cert": "When already set to **Custom**, a custom CA validation certificate is configured. Select **Auto** for automatic CA validation, or upload a custom CA certificate, to allow validating your MQTT broker's certificate.",
|
||||
"set_client_cert": "When already selected, client certificate authentication is enabled. Upload a client certificate and key to enable.",
|
||||
"tls_insecure": "Option to ignore validation of your MQTT broker's certificate.",
|
||||
"transport": "The transport to be used for the connection to your MQTT broker.",
|
||||
"ws_headers": "The WebSocket headers to pass through the WebSocket-based connection to your MQTT broker.",
|
||||
"ws_path": "The WebSocket path to be used for the connection to your MQTT broker."
|
||||
},
|
||||
"name": "Other settings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hassio_confirm": {
|
||||
"description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the {addon} app?",
|
||||
@@ -1178,48 +1185,6 @@
|
||||
"invalid_inclusion": "[%key:component::mqtt::config::error::invalid_inclusion%]"
|
||||
},
|
||||
"step": {
|
||||
"broker": {
|
||||
"data": {
|
||||
"advanced_options": "[%key:component::mqtt::config::step::broker::data::advanced_options%]",
|
||||
"broker": "[%key:component::mqtt::config::step::broker::data::broker%]",
|
||||
"certificate": "[%key:component::mqtt::config::step::broker::data::certificate%]",
|
||||
"client_cert": "[%key:component::mqtt::config::step::broker::data::client_cert%]",
|
||||
"client_id": "[%key:component::mqtt::config::step::broker::data::client_id%]",
|
||||
"client_key": "[%key:component::mqtt::config::step::broker::data::client_key%]",
|
||||
"keepalive": "[%key:component::mqtt::config::step::broker::data::keepalive%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"protocol": "[%key:component::mqtt::config::step::broker::data::protocol%]",
|
||||
"set_ca_cert": "[%key:component::mqtt::config::step::broker::data::set_ca_cert%]",
|
||||
"set_client_cert": "[%key:component::mqtt::config::step::broker::data::set_client_cert%]",
|
||||
"tls_insecure": "[%key:component::mqtt::config::step::broker::data::tls_insecure%]",
|
||||
"transport": "[%key:component::mqtt::config::step::broker::data::transport%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"ws_headers": "[%key:component::mqtt::config::step::broker::data::ws_headers%]",
|
||||
"ws_path": "[%key:component::mqtt::config::step::broker::data::ws_path%]"
|
||||
},
|
||||
"data_description": {
|
||||
"advanced_options": "[%key:component::mqtt::config::step::broker::data_description::advanced_options%]",
|
||||
"broker": "[%key:component::mqtt::config::step::broker::data_description::broker%]",
|
||||
"certificate": "[%key:component::mqtt::config::step::broker::data_description::certificate%]",
|
||||
"client_cert": "[%key:component::mqtt::config::step::broker::data_description::client_cert%]",
|
||||
"client_id": "[%key:component::mqtt::config::step::broker::data_description::client_id%]",
|
||||
"client_key": "[%key:component::mqtt::config::step::broker::data_description::client_key%]",
|
||||
"keepalive": "[%key:component::mqtt::config::step::broker::data_description::keepalive%]",
|
||||
"password": "[%key:component::mqtt::config::step::broker::data_description::password%]",
|
||||
"port": "[%key:component::mqtt::config::step::broker::data_description::port%]",
|
||||
"protocol": "[%key:component::mqtt::config::step::broker::data_description::protocol%]",
|
||||
"set_ca_cert": "[%key:component::mqtt::config::step::broker::data_description::set_ca_cert%]",
|
||||
"set_client_cert": "[%key:component::mqtt::config::step::broker::data_description::set_client_cert%]",
|
||||
"tls_insecure": "[%key:component::mqtt::config::step::broker::data_description::tls_insecure%]",
|
||||
"transport": "[%key:component::mqtt::config::step::broker::data_description::transport%]",
|
||||
"username": "[%key:component::mqtt::config::step::broker::data_description::username%]",
|
||||
"ws_headers": "[%key:component::mqtt::config::step::broker::data_description::ws_headers%]",
|
||||
"ws_path": "[%key:component::mqtt::config::step::broker::data_description::ws_path%]"
|
||||
},
|
||||
"description": "[%key:component::mqtt::config::step::broker::description%]",
|
||||
"title": "Broker options"
|
||||
},
|
||||
"options": {
|
||||
"data": {
|
||||
"birth_enable": "Enable birth message",
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["opower==0.18.5"]
|
||||
"requirements": ["opower==0.18.4"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ from renson_endura_delta.field_enum import (
|
||||
)
|
||||
from renson_endura_delta.renson import RensonVentilation
|
||||
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -24,11 +24,10 @@ class RensonEntity(CoordinatorEntity[RensonCoordinator]):
|
||||
"""Initialize the Renson entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
mac = api.get_field_value(coordinator.data, MAC_ADDRESS.name)
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)},
|
||||
identifiers={
|
||||
(DOMAIN, api.get_field_value(coordinator.data, MAC_ADDRESS.name))
|
||||
},
|
||||
manufacturer="Renson",
|
||||
model=api.get_field_value(coordinator.data, DEVICE_NAME_FIELD.name),
|
||||
name="Ventilation",
|
||||
@@ -42,4 +41,6 @@ class RensonEntity(CoordinatorEntity[RensonCoordinator]):
|
||||
|
||||
self.api = api
|
||||
|
||||
self._attr_unique_id = f"{mac}{name}"
|
||||
self._attr_unique_id = (
|
||||
api.get_field_value(coordinator.data, MAC_ADDRESS.name) + f"{name}"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Reolink integration for HomeAssistant."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from random import uniform
|
||||
from time import time
|
||||
@@ -26,7 +26,6 @@ from homeassistant.helpers import (
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
|
||||
@@ -193,7 +192,7 @@ async def async_setup_entry(
|
||||
hass.config_entries.async_update_entry(config_entry, data=data)
|
||||
|
||||
# If camera WAN blocked, firmware check fails and takes long, do not prevent setup
|
||||
now = dt_util.utcnow()
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
check_time = timedelta(seconds=check_time_sec)
|
||||
delta_midnight = now - now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
firmware_check_delay = check_time - delta_midnight
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Sensoterra devices."""
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum, auto
|
||||
|
||||
from sensoterra.probe import Probe, Sensor
|
||||
@@ -22,7 +22,6 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONFIGURATION_URL, DOMAIN, SENSOR_EXPIRATION_DAYS
|
||||
from .coordinator import SensoterraConfigEntry, SensoterraCoordinator
|
||||
@@ -166,5 +165,5 @@ class SensoterraEntity(CoordinatorEntity[SensoterraCoordinator], SensorEntity):
|
||||
return False
|
||||
|
||||
# Expire sensor if no update within the last few days.
|
||||
expiration = dt_util.utcnow() - timedelta(days=SENSOR_EXPIRATION_DAYS)
|
||||
expiration = datetime.now(UTC) - timedelta(days=SENSOR_EXPIRATION_DAYS) # pylint: disable=home-assistant-enforce-utcnow
|
||||
return sensor.timestamp >= expiration
|
||||
|
||||
@@ -247,7 +247,7 @@ def _async_register_base_station(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, str(system.system_id))},
|
||||
manufacturer="SimpliSafe",
|
||||
model=str(system.version),
|
||||
model=system.version,
|
||||
name=system.address,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from sonos_websocket.exception import SonosWebsocketError
|
||||
|
||||
from homeassistant.components import media_source, spotify
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_INPUT_SOURCE,
|
||||
ATTR_MEDIA_ALBUM_NAME,
|
||||
ATTR_MEDIA_ANNOUNCE,
|
||||
ATTR_MEDIA_ARTIST,
|
||||
@@ -778,6 +779,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
||||
if self.media.queue_size:
|
||||
attributes["queue_size"] = self.media.queue_size
|
||||
|
||||
if self.source:
|
||||
attributes[ATTR_INPUT_SOURCE] = self.source
|
||||
|
||||
return attributes
|
||||
|
||||
async def async_get_browse_image(
|
||||
|
||||
@@ -9,7 +9,6 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator
|
||||
from .entity import StarlinkEntity
|
||||
@@ -64,7 +63,8 @@ def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time:
|
||||
hour -= 24
|
||||
minute = utc_minutes % 60
|
||||
try:
|
||||
utc = dt_util.utcnow().replace(
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
utc = datetime.now(UTC).replace(
|
||||
hour=hour, minute=minute, second=0, microsecond=0
|
||||
)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Provides triggers for timers."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta
|
||||
from typing import cast, override
|
||||
|
||||
@@ -129,17 +128,13 @@ class TimeRemainingTrigger(Trigger):
|
||||
schedule_for_state(entity_id, to_state, event.context)
|
||||
|
||||
@callback
|
||||
def on_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
"""Handle changes to the tracked entity set."""
|
||||
for entity_id in removed:
|
||||
if entity_id in scheduled:
|
||||
scheduled.pop(entity_id)()
|
||||
for entity_id in added:
|
||||
state = entity_states[entity_id]
|
||||
state = self._hass.states.get(entity_id)
|
||||
schedule_for_state(entity_id, state, state.context if state else None)
|
||||
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import VilfoConfigEntry
|
||||
@@ -72,20 +72,12 @@ class VilfoRouterSensor(SensorEntity):
|
||||
self.entity_description = description
|
||||
self.api = api
|
||||
self._attr_device_info = DeviceInfo(
|
||||
# This identifier is a non-standard 3-tuple kept as-is to avoid
|
||||
# migrating existing devices; only the connection is added here.
|
||||
identifiers={(DOMAIN, api.host, api.mac_address)}, # type: ignore[arg-type]
|
||||
name=ROUTER_DEFAULT_NAME,
|
||||
manufacturer=ROUTER_MANUFACTURER,
|
||||
model=ROUTER_DEFAULT_MODEL,
|
||||
sw_version=api.firmware_version,
|
||||
)
|
||||
# The router does not always report a MAC address (e.g. when set up by
|
||||
# host), so only attach the connection when one is available.
|
||||
if api.mac_address:
|
||||
self._attr_device_info["connections"] = {
|
||||
(CONNECTION_NETWORK_MAC, api.mac_address)
|
||||
}
|
||||
self._attr_unique_id = f"{api.unique_id}_{description.key}"
|
||||
|
||||
@property
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
import logging
|
||||
|
||||
from aiowebdav2.client import Client
|
||||
from aiowebdav2.exceptions import (
|
||||
ConnectionExceptionError,
|
||||
NoConnectionError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from aiowebdav2.exceptions import UnauthorizedError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
|
||||
@@ -39,11 +35,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bo
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_username_password",
|
||||
) from err
|
||||
except (ConnectionExceptionError, NoConnectionError, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
|
||||
# Check if we can connect to the WebDAV server
|
||||
# and access the root directory
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -16,7 +16,6 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_DEVICE_STATE, ATTR_LORA_INFO, DOMAIN, YOLINK_OFFLINE_TIME
|
||||
|
||||
@@ -73,7 +72,8 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]):
|
||||
device_reporttime = device_state_resp.data.get("reportAt")
|
||||
if device_reporttime is not None:
|
||||
rpt_time_delta = (
|
||||
dt_util.utcnow().replace(tzinfo=None)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
datetime.now(tz=UTC).replace(tzinfo=None)
|
||||
- datetime.strptime(device_reporttime, "%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
).total_seconds()
|
||||
self.dev_online = rpt_time_delta < YOLINK_OFFLINE_TIME
|
||||
|
||||
@@ -97,6 +97,10 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
|
||||
|
||||
async def _async_update_data(self) -> dict[str, YotoPlayer]:
|
||||
"""Fetch fresh data from the Yoto cloud."""
|
||||
# _async_setup already populated the client; skip the duplicate first fetch.
|
||||
if self.data is None:
|
||||
return self.client.players
|
||||
|
||||
try:
|
||||
await self._session.async_ensure_token_valid()
|
||||
except OAuth2TokenRequestReauthError as err:
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Diagnostics support for the Yoto integration."""
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import YotoConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"mac",
|
||||
"network_ssid",
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: YotoConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
return {
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"players": async_redact_data(
|
||||
{
|
||||
player_id: asdict(player)
|
||||
for player_id, player in coordinator.data.items()
|
||||
},
|
||||
TO_REDACT,
|
||||
),
|
||||
}
|
||||
@@ -45,7 +45,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: The integration supports local DHCP discovery (via hostname pattern), but does not implement a separate discovery update handling flow.
|
||||
@@ -76,4 +76,4 @@ rules:
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
strict-typing: todo
|
||||
|
||||
+4
-22
@@ -760,29 +760,11 @@ class UnitOfPrecipitationDepth(StrEnum):
|
||||
"""Derived from cm³/cm²"""
|
||||
|
||||
|
||||
class UnitOfDensity(StrEnum):
|
||||
"""Density units.
|
||||
|
||||
Ratio of a substance's mass to its volume.
|
||||
"""
|
||||
|
||||
GRAMS_PER_CUBIC_METER = "g/m³"
|
||||
MILLIGRAMS_PER_CUBIC_METER = "mg/m³"
|
||||
MICROGRAMS_PER_CUBIC_METER = "μg/m³"
|
||||
MICROGRAMS_PER_CUBIC_FOOT = "μg/ft³"
|
||||
|
||||
|
||||
# Concentration units
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = UnitOfDensity.GRAMS_PER_CUBIC_METER.value
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = (
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER.value
|
||||
)
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER.value
|
||||
)
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_FOOT.value
|
||||
)
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³"
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "μg/m³"
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = "μg/ft³"
|
||||
_DEPRECATED_CONCENTRATION_PARTS_PER_CUBIC_METER = DeprecatedConstant(
|
||||
"p/m³", "p/m³", "2027.7"
|
||||
)
|
||||
|
||||
@@ -491,10 +491,7 @@ class _HistoryPrimingManager:
|
||||
tracking its entities, or the read could miss a change still queued in the
|
||||
recorder and compute too generous an anchor. A condition therefore never
|
||||
rides a flush that was already running when it arrived (the lobby); it waits
|
||||
that one out and joins the next, and re-attempts if the flush it rode was
|
||||
cancelled before completing. This mirrors `ReloadServiceHelper` minus its
|
||||
target de-duplication, which does not apply because each condition reads its
|
||||
own entities.
|
||||
that one out and joins the next.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
@@ -502,7 +499,6 @@ class _HistoryPrimingManager:
|
||||
self._hass = hass
|
||||
self._flush_condition = asyncio.Condition()
|
||||
self._flushing = False
|
||||
self._flush_ok = False
|
||||
self._query_lock = asyncio.Lock()
|
||||
|
||||
async def async_prime[_T](
|
||||
@@ -524,30 +520,29 @@ class _HistoryPrimingManager:
|
||||
if self._flushing:
|
||||
await self._flush_condition.wait()
|
||||
|
||||
do_flush = False
|
||||
while True:
|
||||
async with self._flush_condition:
|
||||
if not self._flushing:
|
||||
# First past the lobby this generation: we run the flush.
|
||||
self._flushing = True
|
||||
do_flush = True
|
||||
break
|
||||
# A peer began a fresh flush after we cleared the lobby; ride it.
|
||||
# A peer began a fresh flush after we cleared the lobby; it
|
||||
# covers us too, so wait for it and ride it.
|
||||
await self._flush_condition.wait()
|
||||
if self._flush_ok:
|
||||
return
|
||||
# The flush we waited for was cancelled before completing (its owner
|
||||
# timed out): loop and start or wait for a fresh one rather than read
|
||||
# against a queue that was never flushed.
|
||||
break
|
||||
|
||||
if not do_flush:
|
||||
return
|
||||
|
||||
instance = get_instance(self._hass)
|
||||
flushed = False
|
||||
try:
|
||||
if (commit_future := instance.async_get_commit_future()) is not None:
|
||||
await commit_future
|
||||
flushed = True
|
||||
finally:
|
||||
async with self._flush_condition:
|
||||
self._flushing = False
|
||||
self._flush_ok = flushed
|
||||
self._flush_condition.notify_all()
|
||||
|
||||
|
||||
@@ -675,10 +670,7 @@ class EntityConditionBase(Condition):
|
||||
self._on_unload.append(unsub)
|
||||
|
||||
async def _async_on_entities_update(
|
||||
self,
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
_entity_states: Mapping[str, State | None],
|
||||
self, added: set[str], removed: set[str]
|
||||
) -> None:
|
||||
"""Handle changes to the tracked entity set.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine, Mapping
|
||||
from collections.abc import Callable, Coroutine
|
||||
import dataclasses
|
||||
import logging
|
||||
from logging import Logger
|
||||
@@ -21,7 +21,6 @@ from homeassistant.core import (
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -44,19 +43,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@dataclasses.dataclass(slots=True, frozen=True)
|
||||
class TargetStateChangedData:
|
||||
"""Data for state change events related to targets.
|
||||
|
||||
`targeted_entity_states` holds the states of all targeted entities as of
|
||||
the state change event. State change events are dispatched one event loop
|
||||
iteration after the state machine is updated, so the live state machine
|
||||
may already contain later changes; this mapping does not. It is only
|
||||
valid during the synchronous callback: it is updated in place as
|
||||
subsequent events are dispatched.
|
||||
"""
|
||||
"""Data for state change events related to targets."""
|
||||
|
||||
state_change_event: Event[EventStateChangedData]
|
||||
targeted_entity_ids: set[str]
|
||||
targeted_entity_states: Mapping[str, State | None]
|
||||
|
||||
|
||||
def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]:
|
||||
@@ -370,8 +360,7 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
action: Callable[[TargetStateChangedData], Any],
|
||||
entity_filter: Callable[[set[str]], set[str]],
|
||||
on_entities_update: Callable[
|
||||
[set[str], set[str], Mapping[str, State | None]],
|
||||
Coroutine[Any, Any, None] | None,
|
||||
[set[str], set[str]], Coroutine[Any, Any, None] | None
|
||||
]
|
||||
| None = None,
|
||||
*,
|
||||
@@ -382,10 +371,7 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
`on_entities_update` may be a plain callback or a coroutine function.
|
||||
A coroutine is awaited for the initial entity set (so setup is
|
||||
deterministic) and scheduled as a background task for later
|
||||
registry-driven changes. It is called with the added and removed
|
||||
entity ids and the states of all currently targeted entities; the
|
||||
states mapping is only valid during the synchronous call, so a
|
||||
coroutine must copy what it needs before awaiting.
|
||||
registry-driven changes.
|
||||
"""
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -397,7 +383,6 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
self._on_entities_update = on_entities_update
|
||||
self._state_change_unsub: CALLBACK_TYPE | None = None
|
||||
self._tracked_entities: set[str] = set()
|
||||
self._tracked_entity_states: dict[str, State | None] = {}
|
||||
self._update_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
async def async_setup(self) -> Callable[[], None]:
|
||||
@@ -433,49 +418,25 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
previous_entities = self._tracked_entities
|
||||
self._tracked_entities = tracked_entities
|
||||
|
||||
# Carry over the tracked states of still-tracked entities: they are
|
||||
# consistent with the already-dispatched event stream, while the live
|
||||
# state machine may be ahead of it. Only entities new to the view are
|
||||
# read from the live state machine.
|
||||
previous_states = self._tracked_entity_states
|
||||
tracked_entity_states = {
|
||||
entity_id: (
|
||||
previous_states[entity_id]
|
||||
if entity_id in previous_states
|
||||
else self._hass.states.get(entity_id)
|
||||
)
|
||||
for entity_id in tracked_entities
|
||||
}
|
||||
self._tracked_entity_states = tracked_entity_states
|
||||
|
||||
result: Coroutine[Any, Any, None] | None = None
|
||||
if self._on_entities_update is not None:
|
||||
added = tracked_entities - previous_entities
|
||||
removed = previous_entities - tracked_entities
|
||||
if added or removed:
|
||||
result = self._on_entities_update(added, removed, tracked_entity_states)
|
||||
result = self._on_entities_update(added, removed)
|
||||
|
||||
@callback
|
||||
def state_change_listener(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle state change events."""
|
||||
if (entity_id := event.data["entity_id"]) not in tracked_entities:
|
||||
return
|
||||
tracked_entity_states[entity_id] = event.data["new_state"]
|
||||
self._action(
|
||||
TargetStateChangedData(event, tracked_entities, tracked_entity_states)
|
||||
)
|
||||
if event.data["entity_id"] in tracked_entities:
|
||||
self._action(TargetStateChangedData(event, tracked_entities))
|
||||
|
||||
_LOGGER.debug("Tracking state changes for entities: %s", tracked_entities)
|
||||
# Subscribe before unsubscribing the previous listener: if this
|
||||
# tracker is the only subscriber, unsubscribing first tears down the
|
||||
# shared state change tracker, dropping events which have been fired
|
||||
# but not yet dispatched.
|
||||
previous_unsub = self._state_change_unsub
|
||||
if self._state_change_unsub:
|
||||
self._state_change_unsub()
|
||||
self._state_change_unsub = async_track_state_change_event(
|
||||
self._hass, tracked_entities, state_change_listener
|
||||
)
|
||||
if previous_unsub:
|
||||
previous_unsub()
|
||||
return result
|
||||
|
||||
def _unsubscribe(self) -> None:
|
||||
@@ -494,10 +455,7 @@ async def async_track_target_selector_state_change_event(
|
||||
target_selector_config: ConfigType,
|
||||
action: Callable[[TargetStateChangedData], Any],
|
||||
entity_filter: Callable[[set[str]], set[str]] = lambda x: x,
|
||||
on_entities_update: Callable[
|
||||
[set[str], set[str], Mapping[str, State | None]],
|
||||
Coroutine[Any, Any, None] | None,
|
||||
]
|
||||
on_entities_update: Callable[[set[str], set[str]], Coroutine[Any, Any, None] | None]
|
||||
| None = None,
|
||||
*,
|
||||
primary_entities_only: bool = True,
|
||||
@@ -509,11 +467,9 @@ async def async_track_target_selector_state_change_event(
|
||||
expansion (via device, area, and floor) skips entities
|
||||
with an `entity_category` (config or diagnostic entities).
|
||||
|
||||
`on_entities_update` is called with the added and removed entity ids and
|
||||
the states of all currently targeted entities. It may be a coroutine
|
||||
function; it is awaited for the initial entity set and scheduled as a
|
||||
task for later registry-driven changes, so this function must itself be
|
||||
awaited. The states mapping is only valid during the synchronous call.
|
||||
`on_entities_update` may be a coroutine function; it is awaited for the
|
||||
initial entity set and scheduled as a task for later registry-driven
|
||||
changes, so this function must itself be awaited.
|
||||
"""
|
||||
target_selection = TargetSelection(target_selector_config)
|
||||
if not target_selection.has_any_target:
|
||||
|
||||
@@ -5,7 +5,7 @@ import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Coroutine, Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
@@ -75,7 +75,7 @@ from .automation import (
|
||||
get_relative_description_key,
|
||||
move_options_fields_to_top_level,
|
||||
)
|
||||
from .event import async_call_later
|
||||
from .event import async_track_same_state
|
||||
from .integration_platform import async_process_integration_platforms
|
||||
from .selector import (
|
||||
NumericThresholdMode,
|
||||
@@ -438,11 +438,7 @@ class EntityTriggerBase(Trigger):
|
||||
"""
|
||||
return state.state not in self._excluded_states
|
||||
|
||||
def count_matches(
|
||||
self,
|
||||
entity_ids: Iterable[str],
|
||||
states: Mapping[str, State | None] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
def count_matches(self, entity_ids: set[str]) -> tuple[int, int]:
|
||||
"""Return (matches, included) for the entity set.
|
||||
|
||||
`matches` is the number of entities that pass `_should_include` AND
|
||||
@@ -451,19 +447,11 @@ class EntityTriggerBase(Trigger):
|
||||
Callers can use the pair to distinguish vacuous truth
|
||||
(`included == 0`) from a genuine all-match
|
||||
(`matches == included > 0`).
|
||||
|
||||
Entity states are read from `states` when provided, otherwise from
|
||||
the live state machine. Pass the targeted entity states received
|
||||
with a state change event to evaluate the event against the states
|
||||
as they were when the event fired.
|
||||
"""
|
||||
matches = 0
|
||||
included = 0
|
||||
for entity_id in entity_ids:
|
||||
if states is not None:
|
||||
state = states[entity_id]
|
||||
else:
|
||||
state = self._hass.states.get(entity_id)
|
||||
state = self._hass.states.get(entity_id)
|
||||
if state is None or not self._should_include(state):
|
||||
continue
|
||||
included += 1
|
||||
@@ -471,60 +459,6 @@ class EntityTriggerBase(Trigger):
|
||||
matches += 1
|
||||
return matches, included
|
||||
|
||||
@callback
|
||||
def _cancel_invalidated_timers(
|
||||
self,
|
||||
behavior: str,
|
||||
pending_timers: dict[str, CALLBACK_TYPE],
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Cancel pending duration timers invalidated by a state change.
|
||||
|
||||
Runs on every delivered state change, before the trigger's own
|
||||
validity checks: an event which cannot fire the trigger, e.g. an
|
||||
entity becoming unavailable, may still invalidate a pending timer.
|
||||
The targeted entity states have already been updated with this
|
||||
event, so the first/all check can simply recount.
|
||||
"""
|
||||
event = target_state_change_data.state_change_event
|
||||
if behavior == BEHAVIOR_EACH:
|
||||
entity_id = event.data["entity_id"]
|
||||
if entity_id not in pending_timers:
|
||||
return
|
||||
to_state = event.data["new_state"]
|
||||
if (
|
||||
to_state is None
|
||||
or to_state.state in self._excluded_states
|
||||
or not self.is_valid_state(to_state)
|
||||
):
|
||||
pending_timers.pop(entity_id)()
|
||||
return
|
||||
if behavior not in pending_timers:
|
||||
return
|
||||
if not self._combined_state_still_valid(
|
||||
behavior,
|
||||
target_state_change_data.targeted_entity_ids,
|
||||
target_state_change_data.targeted_entity_states,
|
||||
):
|
||||
pending_timers.pop(behavior)()
|
||||
|
||||
def _combined_state_still_valid(
|
||||
self,
|
||||
behavior: str,
|
||||
entity_ids: Iterable[str],
|
||||
states: Mapping[str, State | None],
|
||||
) -> bool:
|
||||
"""Check the combined first/all state for a pending duration timer."""
|
||||
matches, included = self.count_matches(entity_ids, states)
|
||||
if behavior == BEHAVIOR_FIRST:
|
||||
return matches >= 1
|
||||
# Require at least one included entity to avoid keeping the timer
|
||||
# alive when every targeted entity has been filtered out since it
|
||||
# started — a vacuous all-match (`included == 0`) would otherwise
|
||||
# let the action fire after `for:` even though no entity still
|
||||
# matches.
|
||||
return included > 0 and matches == included
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
@@ -532,32 +466,7 @@ class EntityTriggerBase(Trigger):
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
behavior: str = self._options.get(ATTR_BEHAVIOR, BEHAVIOR_EACH)
|
||||
# Pending `for:` duration timers, keyed by entity_id for behavior
|
||||
# each and by the behavior for first/all.
|
||||
pending_timers: dict[str, CALLBACK_TYPE] = {}
|
||||
|
||||
@callback
|
||||
def handle_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
"""Re-validate pending duration timers on target changes.
|
||||
|
||||
Timers of entities no longer targeted are cancelled, and the
|
||||
combined first/all condition is recounted over the updated
|
||||
target: e.g. a non-matching entity added to the target breaks a
|
||||
pending all-match.
|
||||
"""
|
||||
for entity_id in removed:
|
||||
if (cancel := pending_timers.pop(entity_id, None)) is not None:
|
||||
cancel()
|
||||
if behavior not in pending_timers:
|
||||
return
|
||||
if not self._combined_state_still_valid(
|
||||
behavior, entity_states.keys(), entity_states
|
||||
):
|
||||
pending_timers.pop(behavior)()
|
||||
unsub_track_same: dict[str, Callable[[], None]] = {}
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
@@ -569,10 +478,35 @@ class EntityTriggerBase(Trigger):
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
if pending_timers:
|
||||
self._cancel_invalidated_timers(
|
||||
behavior, pending_timers, target_state_change_data
|
||||
)
|
||||
def state_still_valid(
|
||||
_: str, from_state: State | None, to_state: State | None
|
||||
) -> bool:
|
||||
"""Check if the state is still valid during the duration wait.
|
||||
|
||||
Called by async_track_same_state on each state change to
|
||||
determine whether to cancel the timer.
|
||||
For behavior each, checks the individual entity's state.
|
||||
For behavior first/all, checks the combined state.
|
||||
"""
|
||||
if behavior == BEHAVIOR_ALL:
|
||||
matches, included = self.count_matches(
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
# Require at least one included entity to avoid keeping
|
||||
# the timer alive when every targeted entity has been
|
||||
# filtered out since it started — a vacuous all-match
|
||||
# (`included == 0`) would otherwise let the action fire
|
||||
# after `for:` even though no entity still matches.
|
||||
return included > 0 and matches == included
|
||||
if behavior == BEHAVIOR_FIRST:
|
||||
matches, _included = self.count_matches(
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
return matches >= 1
|
||||
# Behavior each: check the individual entity's state
|
||||
if not to_state or to_state.state in self._excluded_states:
|
||||
return False
|
||||
return self.is_valid_state(to_state)
|
||||
|
||||
if not from_state or not to_state:
|
||||
return
|
||||
@@ -592,15 +526,9 @@ class EntityTriggerBase(Trigger):
|
||||
):
|
||||
return
|
||||
|
||||
# Count against the targeted entity states as of this event, not
|
||||
# the live state machine: state change events are dispatched one
|
||||
# event loop iteration after the state machine is updated, so the
|
||||
# state machine may already contain later changes to other
|
||||
# targeted entities.
|
||||
if behavior == BEHAVIOR_ALL:
|
||||
matches, included = self.count_matches(
|
||||
target_state_change_data.targeted_entity_ids,
|
||||
target_state_change_data.targeted_entity_states,
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
if matches != included:
|
||||
return
|
||||
@@ -609,8 +537,7 @@ class EntityTriggerBase(Trigger):
|
||||
# were previously 2 matches the transition would not be valid and we
|
||||
# would have returned already.
|
||||
matches, _ = self.count_matches(
|
||||
target_state_change_data.targeted_entity_ids,
|
||||
target_state_change_data.targeted_entity_states,
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
if matches != 1:
|
||||
return
|
||||
@@ -638,19 +565,18 @@ class EntityTriggerBase(Trigger):
|
||||
return
|
||||
|
||||
subscription_key = entity_id if behavior == BEHAVIOR_EACH else behavior
|
||||
if (
|
||||
previous_timer := pending_timers.pop(subscription_key, None)
|
||||
) is not None:
|
||||
previous_timer()
|
||||
|
||||
@callback
|
||||
def fire_after_duration(_now: datetime) -> None:
|
||||
"""Fire the action once the state has held for the duration."""
|
||||
del pending_timers[subscription_key]
|
||||
call_action()
|
||||
|
||||
pending_timers[subscription_key] = async_call_later(
|
||||
self._hass, self._duration, fire_after_duration
|
||||
if subscription_key in unsub_track_same:
|
||||
unsub_track_same.pop(subscription_key)()
|
||||
unsub_track_same[subscription_key] = async_track_same_state(
|
||||
self._hass,
|
||||
self._duration,
|
||||
call_action,
|
||||
state_still_valid,
|
||||
entity_ids=(
|
||||
entity_id
|
||||
if behavior == BEHAVIOR_EACH
|
||||
else target_state_change_data.targeted_entity_ids
|
||||
),
|
||||
)
|
||||
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
@@ -658,7 +584,6 @@ class EntityTriggerBase(Trigger):
|
||||
self._target,
|
||||
state_change_listener,
|
||||
self.entity_filter,
|
||||
handle_entities_update if self._duration else None,
|
||||
primary_entities_only=self._primary_entities_only,
|
||||
)
|
||||
|
||||
@@ -666,9 +591,9 @@ class EntityTriggerBase(Trigger):
|
||||
def async_remove() -> None:
|
||||
"""Remove state listeners async."""
|
||||
unsub()
|
||||
for cancel_timer in pending_timers.values():
|
||||
cancel_timer()
|
||||
pending_timers.clear()
|
||||
for async_remove in unsub_track_same.values():
|
||||
async_remove()
|
||||
unsub_track_same.clear()
|
||||
|
||||
return async_remove
|
||||
|
||||
|
||||
@@ -31,9 +31,7 @@ DISCOVERY_INTEGRATIONS: dict[str, Iterable[str]] = {
|
||||
}
|
||||
DEPRECATED_PACKAGES: dict[str, tuple[str, str]] = {
|
||||
# old_package_name: (reason, breaks_in_ha_version)
|
||||
"pyserial": ("should be replaced by serialx", "2027.1"),
|
||||
"pyserial-asyncio": ("should be replaced by serialx", "2027.1"),
|
||||
"pyserial-asyncio-fast": ("should be replaced by serialx", "2027.1"),
|
||||
"pyserial-asyncio": ("should be replaced by pyserial-asyncio-fast", "2026.7"),
|
||||
}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ from functools import lru_cache
|
||||
from math import floor, log10
|
||||
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
@@ -14,7 +17,6 @@ from homeassistant.const import (
|
||||
UnitOfBloodGlucoseConcentration,
|
||||
UnitOfConductivity,
|
||||
UnitOfDataRate,
|
||||
UnitOfDensity,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -246,18 +248,18 @@ class CarbonMonoxideConcentrationConverter(BaseUnitConverter):
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1e6,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: (
|
||||
_CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e3
|
||||
),
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -492,14 +494,14 @@ class MassVolumeConcentrationConverter(BaseUnitConverter):
|
||||
|
||||
UNIT_CLASS = "concentration"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: 1_000_000.0, # 1000 µg/m³ = 1 mg/m³
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³
|
||||
UnitOfDensity.GRAMS_PER_CUBIC_METER: 1.0,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1_000_000.0, # 1000 µg/m³ = 1 mg/m³
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER: 1.0,
|
||||
}
|
||||
VALID_UNITS = {
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -510,14 +512,14 @@ class NitrogenDioxideConcentrationConverter(BaseUnitConverter):
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1e6,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_NITROGEN_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -527,13 +529,13 @@ class NitrogenMonoxideConcentrationConverter(BaseUnitConverter):
|
||||
UNIT_CLASS = "nitrogen_monoxide"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_NITROGEN_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -544,14 +546,14 @@ class OzoneConcentrationConverter(BaseUnitConverter):
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1e6,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_OZONE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -749,13 +751,13 @@ class SulphurDioxideConcentrationConverter(BaseUnitConverter):
|
||||
UNIT_CLASS = "sulphur_dioxide"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_SULPHUR_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6180,16 +6180,6 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.yoto.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.youtube.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
Generated
+2
-2
@@ -766,7 +766,7 @@ colorlog==6.10.1
|
||||
colorthief==0.2.1
|
||||
|
||||
# homeassistant.components.compit
|
||||
compit-inext-api==0.9.1
|
||||
compit-inext-api==0.8.0
|
||||
|
||||
# homeassistant.components.concord232
|
||||
concord232==0.15.1
|
||||
@@ -1788,7 +1788,7 @@ openwrt-luci-rpc==1.1.17
|
||||
openwrt-ubus-rpc==0.0.3
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.18.5
|
||||
opower==0.18.4
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.1.0
|
||||
|
||||
@@ -255,88 +255,6 @@ async def test_punctuation(hass: HomeAssistant) -> None:
|
||||
assert result.response.intent.slots["name"]["text"] == "test light"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sentence",
|
||||
[
|
||||
# STT may or may not insert the comma based on speech cadence
|
||||
"Turn off upstairs, hallway",
|
||||
"Turn off upstairs hallway",
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_punctuation_in_alias(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
sentence: str,
|
||||
) -> None:
|
||||
"""Test that an alias containing punctuation can still be matched.
|
||||
|
||||
The input is matched with punctuation removed, so the alias must be too.
|
||||
"""
|
||||
entity_registry.async_get_or_create(
|
||||
"light", "demo", "1234", suggested_object_id="test_light"
|
||||
)
|
||||
entity_registry.async_update_entity(
|
||||
"light.test_light", aliases=["Upstairs, hallway"]
|
||||
)
|
||||
hass.states.async_set(
|
||||
"light.test_light",
|
||||
"on",
|
||||
attributes={ATTR_FRIENDLY_NAME: "Test light"},
|
||||
)
|
||||
expose_entity(hass, "light.test_light", True)
|
||||
|
||||
calls = async_mock_service(hass, "light", "turn_off")
|
||||
result = await conversation.async_converse(hass, sentence, None, Context(), None)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data["entity_id"][0] == "light.test_light"
|
||||
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sentence",
|
||||
[
|
||||
# STT may or may not insert the comma based on speech cadence
|
||||
"Turn on lights in second, floor",
|
||||
"Turn on lights in second floor",
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_punctuation_in_area_alias(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
sentence: str,
|
||||
) -> None:
|
||||
"""Test that an area alias containing punctuation can still be matched.
|
||||
|
||||
The input is matched with punctuation removed, so the alias must be too.
|
||||
"""
|
||||
area = area_registry.async_get_or_create("area_id")
|
||||
area = area_registry.async_update(area.id, aliases={"Second, floor"})
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
"light", "demo", "1234", suggested_object_id="test_light"
|
||||
)
|
||||
entity_registry.async_update_entity("light.test_light", area_id=area.id)
|
||||
hass.states.async_set(
|
||||
"light.test_light",
|
||||
"off",
|
||||
attributes={ATTR_FRIENDLY_NAME: "Test light"},
|
||||
)
|
||||
expose_entity(hass, "light.test_light", True)
|
||||
|
||||
calls = async_mock_service(hass, "light", "turn_on")
|
||||
result = await conversation.async_converse(hass, sentence, None, Context(), None)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data["entity_id"][0] == "light.test_light"
|
||||
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
assert result.response.intent is not None
|
||||
assert result.response.intent.slots["area"]["value"] == area.id
|
||||
|
||||
|
||||
async def test_expose_flag_automatically_set(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
|
||||
@@ -695,8 +695,6 @@ async def test_if_position(
|
||||
assert service_calls[6].data["some"] == "is_pos_not_gt_45 - event - test_event1"
|
||||
|
||||
for record in caplog.records:
|
||||
if record.name == "asyncio" and record.getMessage().startswith("Executing "):
|
||||
continue
|
||||
assert record.levelname in ("DEBUG", "INFO")
|
||||
|
||||
|
||||
@@ -859,6 +857,4 @@ async def test_if_tilt_position(
|
||||
assert service_calls[6].data["some"] == "is_pos_not_gt_45 - event - test_event1"
|
||||
|
||||
for record in caplog.records:
|
||||
if record.name == "asyncio" and record.getMessage().startswith("Executing "):
|
||||
continue
|
||||
assert record.levelname in ("DEBUG", "INFO")
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'a8:03:2a:b1:23:45',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'electrasmart',
|
||||
'a8032ab12345',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Electra',
|
||||
'model': 'Electra A/C',
|
||||
'model_id': None,
|
||||
'name': 'Living Room',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Tests for the Electra Smart integration setup."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from electrasmart.device import OperationMode
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.electrasmart.const import (
|
||||
CONF_IMEI,
|
||||
CONF_PHONE_NUMBER,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_device")
|
||||
def mock_device_fixture() -> Mock:
|
||||
"""Return a mocked Electra AC device."""
|
||||
device = Mock(
|
||||
mac="a8032ab12345",
|
||||
model="Electra A/C",
|
||||
manufactor="Electra",
|
||||
features=[],
|
||||
is_disconnected=Mock(return_value=False),
|
||||
is_on=Mock(return_value=False),
|
||||
is_horizontal_swing=Mock(return_value=False),
|
||||
is_vertical_swing=Mock(return_value=False),
|
||||
get_fan_speed=Mock(return_value=OperationMode.FAN_SPEED_AUTO),
|
||||
get_mode=Mock(return_value=OperationMode.MODE_COOL),
|
||||
get_sensor_temperature=Mock(return_value=24),
|
||||
get_temperature=Mock(return_value=22),
|
||||
get_shabat_mode=Mock(return_value=False),
|
||||
)
|
||||
# `name` is a reserved Mock kwarg, so it must be set after construction.
|
||||
device.name = "Living Room"
|
||||
return device
|
||||
|
||||
|
||||
async def test_device_registry(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_device: Mock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the device registry entry, including the network MAC connection."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="0521234567",
|
||||
data={
|
||||
CONF_TOKEN: "token",
|
||||
CONF_IMEI: "2b950000024051000000000000000000",
|
||||
CONF_PHONE_NUMBER: "0521234567",
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
mock_api = Mock(devices=[mock_device], fetch_devices=AsyncMock())
|
||||
with patch(
|
||||
"homeassistant.components.electrasmart.ElectraAPI", return_value=mock_api
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "a8032ab12345")}
|
||||
)
|
||||
assert device_entry == snapshot
|
||||
@@ -39,6 +39,7 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID,
|
||||
},
|
||||
unique_id=f"lg_ir_tv_{MOCK_INFRARED_EMITTER_ENTITY_ID}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ from tests.components.infrared import (
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "expected_title"),
|
||||
("config", "expected_title", "unique_id_entity_id"),
|
||||
[
|
||||
(
|
||||
{CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id},
|
||||
"LG TV via Test IR emitter",
|
||||
mock_infrared_emitter_entity_id,
|
||||
),
|
||||
(
|
||||
{
|
||||
@@ -34,10 +35,12 @@ from tests.components.infrared import (
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id,
|
||||
},
|
||||
"LG TV via Test IR emitter",
|
||||
mock_infrared_emitter_entity_id,
|
||||
),
|
||||
(
|
||||
{CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id},
|
||||
"LG TV via Test IR receiver",
|
||||
mock_infrared_receiver_entity_id,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -48,6 +51,7 @@ async def test_user_flow_success(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, str],
|
||||
expected_title: str,
|
||||
unique_id_entity_id: str,
|
||||
) -> None:
|
||||
"""Test successful user config flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -65,7 +69,7 @@ async def test_user_flow_success(
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == expected_title
|
||||
assert result["data"] == {CONF_DEVICE_TYPE: LGDeviceType.TV, **config}
|
||||
assert result["result"].unique_id is None
|
||||
assert result["result"].unique_id == f"lg_ir_tv_{unique_id_entity_id}"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
|
||||
@@ -86,33 +90,9 @@ async def test_user_flow_requires_emitter_or_receiver(
|
||||
assert result["errors"] == {"base": "missing_infrared_entity"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_infrared_emitter_entity", "mock_infrared_receiver_entity"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"user_input",
|
||||
[
|
||||
pytest.param(
|
||||
{CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id},
|
||||
id="emitter_conflict",
|
||||
),
|
||||
pytest.param(
|
||||
{CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id},
|
||||
id="receiver_conflict",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id,
|
||||
},
|
||||
id="both_conflict",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
user_input: dict[str, str],
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test user flow aborts when entry is already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
@@ -125,7 +105,10 @@ async def test_user_flow_already_configured(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_DEVICE_TYPE: LGDeviceType.TV, **user_input},
|
||||
user_input={
|
||||
CONF_DEVICE_TYPE: LGDeviceType.TV,
|
||||
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -172,5 +155,6 @@ async def test_user_flow_title_from_entity_name(
|
||||
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == expected_title
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
"""Tests for the LG Infrared integration setup."""
|
||||
|
||||
from homeassistant.components.lg_infrared.const import (
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_INFRARED_ENTITY_ID,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID,
|
||||
DOMAIN,
|
||||
LGDeviceType,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.infrared import (
|
||||
EMITTER_ENTITY_ID as MOCK_INFRARED_EMITTER_ENTITY_ID,
|
||||
RECEIVER_ENTITY_ID as MOCK_INFRARED_RECEIVER_ENTITY_ID,
|
||||
)
|
||||
from tests.components.infrared.common import (
|
||||
MockInfraredEmitterEntity,
|
||||
MockInfraredReceiverEntity,
|
||||
)
|
||||
|
||||
|
||||
async def test_setup_and_unload_entry(
|
||||
@@ -32,30 +17,3 @@ async def test_setup_and_unload_entry(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_migrate_v1_to_v2(
|
||||
hass: HomeAssistant,
|
||||
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
|
||||
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
|
||||
mock_lg_tv_code_to_command: None,
|
||||
) -> None:
|
||||
"""Test migration from v1 (legacy unique_id) to v2 (no unique_id)."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
version=1,
|
||||
unique_id=f"lg_ir_tv_{MOCK_INFRARED_EMITTER_ENTITY_ID}",
|
||||
data={
|
||||
CONF_DEVICE_TYPE: LGDeviceType.TV,
|
||||
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID,
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.version == 2
|
||||
assert entry.unique_id is None
|
||||
|
||||
@@ -21,7 +21,6 @@ from homeassistant.components.mcp.const import (
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -105,7 +104,8 @@ async def mock_credential(hass: HomeAssistant) -> None:
|
||||
@pytest.fixture(name="config_entry_token_expiration")
|
||||
def mock_config_entry_token_expiration() -> datetime.datetime:
|
||||
"""Fixture to mock the token expiration."""
|
||||
return dt_util.utcnow() + datetime.timedelta(days=1)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
return datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry_with_auth")
|
||||
|
||||
@@ -87,6 +87,23 @@ ADD_ON_DISCOVERY_INFO = {
|
||||
"ssl": False,
|
||||
}
|
||||
|
||||
MOCK_BROKER_FORM_DATA = {
|
||||
"broker": "127.0.0.1",
|
||||
"port": "1883",
|
||||
"protocol": "5",
|
||||
"other_settings": {
|
||||
"transport": "tcp",
|
||||
"set_client_cert": False,
|
||||
"set_ca_cert": "off",
|
||||
},
|
||||
}
|
||||
MOCK_BROKER_ENTRY_DATA = {
|
||||
"broker": "127.0.0.1",
|
||||
"port": 1883,
|
||||
"protocol": "5",
|
||||
"transport": "tcp",
|
||||
}
|
||||
|
||||
MOCK_CA_CERT = (
|
||||
b"-----BEGIN CERTIFICATE-----\n"
|
||||
b"## mock CA certificate file ##"
|
||||
@@ -128,13 +145,6 @@ MOCK_CLIENT_KEY_DER = b"## mock DER formatted key file ##\n"
|
||||
MOCK_ENCRYPTED_CLIENT_KEY_DER = b"## mock DER formatted encrypted key file ##\n"
|
||||
|
||||
|
||||
MOCK_ENTRY_DATA = {
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PROTOCOL: "5",
|
||||
CONF_PORT: 1234,
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
}
|
||||
MOCK_ENTRY_OPTIONS = {
|
||||
mqtt.CONF_DISCOVERY: True,
|
||||
mqtt.CONF_BIRTH_MESSAGE: {
|
||||
@@ -380,15 +390,11 @@ async def test_user_connection_works(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"broker": "127.0.0.1"}
|
||||
result["flow_id"], MOCK_BROKER_FORM_DATA
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["result"].data == {
|
||||
"broker": "127.0.0.1",
|
||||
"protocol": "5",
|
||||
"port": 1883,
|
||||
}
|
||||
assert result["result"].data == MOCK_BROKER_ENTRY_DATA
|
||||
# Check we have the latest Config Entry version
|
||||
assert result["result"].version == 2
|
||||
assert result["result"].minor_version == 1
|
||||
@@ -423,15 +429,11 @@ async def test_user_connection_works_with_supervisor(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"broker": "127.0.0.1"}
|
||||
result["flow_id"], MOCK_BROKER_FORM_DATA
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["result"].data == {
|
||||
"broker": "127.0.0.1",
|
||||
"protocol": "5",
|
||||
"port": 1883,
|
||||
}
|
||||
assert result["result"].data == MOCK_BROKER_ENTRY_DATA
|
||||
# Check we tried the connection
|
||||
assert len(mock_try_connection.mock_calls) == 1
|
||||
# Check config entry got setup
|
||||
@@ -453,27 +455,14 @@ async def test_user_v5_connection_works(
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"broker": "127.0.0.1", "advanced_options": True}
|
||||
)
|
||||
|
||||
assert result["step_id"] == "broker"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "another-broker",
|
||||
CONF_PORT: 2345,
|
||||
CONF_PROTOCOL: "5",
|
||||
},
|
||||
user_input=MOCK_BROKER_FORM_DATA,
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["result"].data == {
|
||||
"broker": "another-broker",
|
||||
"port": 2345,
|
||||
"protocol": "5",
|
||||
}
|
||||
assert result["result"].data == MOCK_BROKER_ENTRY_DATA
|
||||
# Check we tried the connection
|
||||
assert len(mock_try_connection.mock_calls) == 1
|
||||
# Check config entry got setup
|
||||
@@ -492,14 +481,16 @@ async def test_user_connection_fails(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"broker": "127.0.0.1"}
|
||||
result["flow_id"], MOCK_BROKER_FORM_DATA | {"broker": "127.0.0.1"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "cannot_connect"
|
||||
|
||||
# Check we tried the connection
|
||||
assert len(mock_try_connection_time_out.mock_calls)
|
||||
mock_try_connection_time_out.connect_async.assert_called_once_with(
|
||||
"127.0.0.1", 1883
|
||||
)
|
||||
# Check config entry did not setup
|
||||
assert len(mock_finish_setup.mock_calls) == 0
|
||||
|
||||
@@ -510,7 +501,7 @@ async def test_manual_config_set(
|
||||
mock_try_connection: MqttMockPahoClient,
|
||||
mock_finish_setup: MagicMock,
|
||||
) -> None:
|
||||
"""Test manual config does not create an entry, and entry can be setup late."""
|
||||
"""Test manual config does not create an entry, and entry can be set up late."""
|
||||
assert len(mock_finish_setup.mock_calls) == 0
|
||||
|
||||
mock_try_connection.return_value = True
|
||||
@@ -521,28 +512,19 @@ async def test_manual_config_set(
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
# Submit form data
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"broker": "127.0.0.1", "port": "1883"}
|
||||
result["flow_id"], MOCK_BROKER_FORM_DATA
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["result"].data == {
|
||||
"broker": "127.0.0.1",
|
||||
"protocol": "5",
|
||||
"port": 1883,
|
||||
}
|
||||
assert result["result"].data == MOCK_BROKER_ENTRY_DATA
|
||||
|
||||
# Check we tried the connection, with precedence for config entry settings
|
||||
mock_try_connection.assert_called_once_with(
|
||||
{
|
||||
"broker": "127.0.0.1",
|
||||
"protocol": "5",
|
||||
"port": 1883,
|
||||
},
|
||||
)
|
||||
mock_try_connection.assert_called_once_with(MOCK_BROKER_ENTRY_DATA)
|
||||
# Check config entry got setup
|
||||
assert len(mock_finish_setup.mock_calls) == 1
|
||||
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert config_entry.title == "127.0.0.1"
|
||||
assert config_entry.title == MOCK_BROKER_ENTRY_DATA["broker"]
|
||||
|
||||
|
||||
async def test_user_single_instance(hass: HomeAssistant) -> None:
|
||||
@@ -1159,6 +1141,7 @@ async def test_option_flow(
|
||||
assert yaml_mock.await_count
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_ca_cert")
|
||||
@pytest.mark.parametrize(
|
||||
("mock_ca_cert", "mock_client_cert", "mock_client_key", "client_key_password"),
|
||||
[
|
||||
@@ -1195,7 +1178,7 @@ async def test_option_flow(
|
||||
None,
|
||||
],
|
||||
)
|
||||
async def test_bad_certificate(
|
||||
async def test_bad_certificate_validation(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
mock_try_connection_success: MqttMockPahoClient,
|
||||
@@ -1203,9 +1186,8 @@ async def test_bad_certificate(
|
||||
mock_process_uploaded_file: MagicMock,
|
||||
test_error: str | None,
|
||||
client_key_password: str,
|
||||
mock_ca_cert: bytes,
|
||||
) -> None:
|
||||
"""Test bad certificate tests."""
|
||||
"""Test bad certificate validation in config and reconfig flow."""
|
||||
|
||||
def _side_effect_on_client_cert(data: bytes) -> MagicMock:
|
||||
"""Raise on client cert only.
|
||||
@@ -1227,12 +1209,16 @@ async def test_bad_certificate(
|
||||
test_input = {
|
||||
mqtt.CONF_BROKER: "another-broker",
|
||||
CONF_PORT: 2345,
|
||||
mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE],
|
||||
mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT],
|
||||
mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY],
|
||||
"client_key_password": client_key_password,
|
||||
"set_ca_cert": set_ca_cert,
|
||||
"set_client_cert": True,
|
||||
mqtt.CONF_PROTOCOL: "5",
|
||||
"other_settings": {
|
||||
mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE],
|
||||
mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT],
|
||||
mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY],
|
||||
"client_key_password": client_key_password,
|
||||
"set_ca_cert": set_ca_cert,
|
||||
"set_client_cert": True,
|
||||
mqtt.CONF_TRANSPORT: "tcp",
|
||||
},
|
||||
}
|
||||
if test_error == "bad_certificate":
|
||||
# CA chain is not loading
|
||||
@@ -1255,22 +1241,16 @@ async def test_bad_certificate(
|
||||
mock_ssl_context["context"]().load_cert_chain.side_effect = SSLError
|
||||
elif test_error == "invalid_inclusion":
|
||||
# Client key file without client cert, client cert without key file
|
||||
test_input.pop(mqtt.CONF_CLIENT_KEY)
|
||||
test_input["other_settings"].pop(mqtt.CONF_CLIENT_KEY)
|
||||
|
||||
test_input["other_settings"]["set_client_cert"] = set_client_cert
|
||||
test_input["other_settings"]["set_ca_cert"] = set_ca_cert
|
||||
test_input["other_settings"]["tls_insecure"] = tls_insecure
|
||||
|
||||
# Test errors in reconfigure flow
|
||||
mqtt_mock = await mqtt_mock_entry()
|
||||
config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
# Add at least one advanced option to get the full form
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 1234,
|
||||
CONF_CLIENT_ID: "custom1234",
|
||||
mqtt.CONF_KEEPALIVE: 60,
|
||||
mqtt.CONF_TLS_INSECURE: False,
|
||||
CONF_PROTOCOL: "3.1.1",
|
||||
},
|
||||
)
|
||||
hass.config_entries.async_update_entry(config_entry, data=MOCK_BROKER_ENTRY_DATA)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mqtt_mock.async_connect.reset_mock()
|
||||
@@ -1279,23 +1259,6 @@ async def test_bad_certificate(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "broker"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "another-broker",
|
||||
CONF_PORT: 2345,
|
||||
mqtt.CONF_KEEPALIVE: 60,
|
||||
"set_client_cert": set_client_cert,
|
||||
"set_ca_cert": set_ca_cert,
|
||||
mqtt.CONF_TLS_INSECURE: tls_insecure,
|
||||
CONF_PROTOCOL: "3.1.1",
|
||||
CONF_CLIENT_ID: "custom1234",
|
||||
},
|
||||
)
|
||||
test_input["set_client_cert"] = set_client_cert
|
||||
test_input["set_ca_cert"] = set_ca_cert
|
||||
test_input["tls_insecure"] = tls_insecure
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=test_input,
|
||||
@@ -1305,6 +1268,23 @@ async def test_bad_certificate(
|
||||
return
|
||||
assert "errors" not in result
|
||||
|
||||
# Remove existing MQTT config entry
|
||||
await hass.config_entries.async_remove(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
# Test config flow
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=test_input
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
if test_error is not None:
|
||||
assert result["errors"]["base"] == test_error
|
||||
return
|
||||
assert "errors" not in result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_value", "error"),
|
||||
@@ -1327,25 +1307,12 @@ async def test_keepalive_validation(
|
||||
) -> None:
|
||||
"""Test validation of the keep alive option."""
|
||||
|
||||
test_input = {
|
||||
mqtt.CONF_BROKER: "another-broker",
|
||||
CONF_PORT: 2345,
|
||||
mqtt.CONF_KEEPALIVE: input_value,
|
||||
}
|
||||
test_input = deepcopy(MOCK_BROKER_FORM_DATA)
|
||||
test_input["other_settings"][mqtt.CONF_KEEPALIVE] = input_value
|
||||
|
||||
mqtt_mock = await mqtt_mock_entry()
|
||||
mock_try_connection.return_value = True
|
||||
config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
# Add at least one advanced option to get the full form
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 1234,
|
||||
CONF_CLIENT_ID: "custom1234",
|
||||
},
|
||||
)
|
||||
|
||||
mqtt_mock.async_connect.reset_mock()
|
||||
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
@@ -1860,10 +1827,7 @@ async def test_reconfigure_user_connection_fails(
|
||||
config_entry.add_to_hass(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 1234,
|
||||
},
|
||||
data=MOCK_BROKER_ENTRY_DATA,
|
||||
)
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
@@ -1871,19 +1835,19 @@ async def test_reconfigure_user_connection_fails(
|
||||
mock_try_connection_time_out.reset_mock()
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={mqtt.CONF_BROKER: "bad-broker", CONF_PORT: 2345},
|
||||
user_input=MOCK_BROKER_FORM_DATA
|
||||
| {mqtt.CONF_BROKER: "bad-broker", CONF_PORT: 2345},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "cannot_connect"
|
||||
|
||||
# Check we tried the connection
|
||||
assert len(mock_try_connection_time_out.mock_calls)
|
||||
mock_try_connection_time_out.connect_async.assert_called_once_with(
|
||||
"bad-broker", 2345
|
||||
)
|
||||
# Check config entry did not update
|
||||
assert config_entry.data == {
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 1234,
|
||||
}
|
||||
assert config_entry.data == MOCK_BROKER_ENTRY_DATA
|
||||
|
||||
|
||||
async def test_options_bad_birth_message_fails(
|
||||
@@ -1967,12 +1931,15 @@ async def test_options_bad_will_message_fails(
|
||||
[MOCK_CLIENT_KEY, MOCK_EC_CLIENT_KEY, MOCK_RSA_CLIENT_KEY],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file")
|
||||
async def test_try_connection_with_advanced_parameters(
|
||||
async def test_reconfigure_with_tls_client_key_formats(
|
||||
hass: HomeAssistant,
|
||||
mock_try_connection_success: MqttMockPahoClient,
|
||||
mock_context_client_key: bytes,
|
||||
) -> None:
|
||||
"""Test config flow with advanced parameters from config."""
|
||||
"""Test config flow with different PEM client keys.
|
||||
|
||||
Also test if default and suggested values are loaded correctly.
|
||||
"""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
version=mqtt.CONFIG_ENTRY_VERSION,
|
||||
@@ -1995,19 +1962,6 @@ async def test_try_connection_with_advanced_parameters(
|
||||
mqtt.CONF_WS_PATH: "/path/",
|
||||
mqtt.CONF_WS_HEADERS: {"h1": "v1", "h2": "v2"},
|
||||
mqtt.CONF_KEEPALIVE: 30,
|
||||
mqtt.CONF_DISCOVERY: True,
|
||||
mqtt.CONF_BIRTH_MESSAGE: {
|
||||
mqtt.ATTR_TOPIC: "ha_state/online",
|
||||
mqtt.ATTR_PAYLOAD: "online",
|
||||
mqtt.ATTR_QOS: 1,
|
||||
mqtt.ATTR_RETAIN: True,
|
||||
},
|
||||
mqtt.CONF_WILL_MESSAGE: {
|
||||
mqtt.ATTR_TOPIC: "ha_state/offline",
|
||||
mqtt.ATTR_PAYLOAD: "offline",
|
||||
mqtt.ATTR_QOS: 2,
|
||||
mqtt.ATTR_RETAIN: False,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2015,25 +1969,32 @@ async def test_try_connection_with_advanced_parameters(
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "broker"
|
||||
defaults = {
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 1234,
|
||||
"set_client_cert": True,
|
||||
"set_ca_cert": "auto",
|
||||
}
|
||||
defaults = {CONF_PORT: 1883}
|
||||
suggested = {
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: PWD_NOT_CHANGED,
|
||||
mqtt.CONF_TLS_INSECURE: True,
|
||||
mqtt.CONF_TLS_INSECURE: None,
|
||||
CONF_PROTOCOL: "5",
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_PATH: "/path/",
|
||||
mqtt.CONF_WS_HEADERS: '{"h1":"v1","h2":"v2"}',
|
||||
}
|
||||
suggested_other_settings = {
|
||||
"username": None,
|
||||
"password": None,
|
||||
"tls_insecure": True,
|
||||
"protocol": None,
|
||||
"other_settings": None,
|
||||
}
|
||||
|
||||
for k, v in defaults.items():
|
||||
assert get_default(result["data_schema"].schema, k) == v
|
||||
for k, v in suggested.items():
|
||||
assert get_schema_suggested_value(result["data_schema"].schema, k) == v
|
||||
for k, v in suggested_other_settings.items():
|
||||
assert (
|
||||
get_schema_suggested_value(
|
||||
result["data_schema"].schema["other_settings"].schema.schema, k
|
||||
)
|
||||
== v
|
||||
)
|
||||
|
||||
# test we can change username and password
|
||||
mock_try_connection_success.reset_mock()
|
||||
@@ -2042,14 +2003,17 @@ async def test_try_connection_with_advanced_parameters(
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "another-broker",
|
||||
CONF_PORT: 2345,
|
||||
CONF_PROTOCOL: "5",
|
||||
CONF_USERNAME: "us3r",
|
||||
CONF_PASSWORD: "p4ss",
|
||||
"set_ca_cert": "auto",
|
||||
"set_client_cert": True,
|
||||
mqtt.CONF_TLS_INSECURE: True,
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_PATH: "/new/path",
|
||||
mqtt.CONF_WS_HEADERS: '{"h3": "v3"}',
|
||||
"other_settings": {
|
||||
"set_ca_cert": "auto",
|
||||
"set_client_cert": True,
|
||||
mqtt.CONF_TLS_INSECURE: True,
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_PATH: "/new/path",
|
||||
mqtt.CONF_WS_HEADERS: '{"h3": "v3"}',
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -2117,23 +2081,14 @@ async def test_setup_with_advanced_settings(
|
||||
minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PROTOCOL: "5",
|
||||
CONF_PORT: 1234,
|
||||
},
|
||||
)
|
||||
hass.config_entries.async_update_entry(config_entry, data=MOCK_BROKER_ENTRY_DATA)
|
||||
|
||||
mock_try_connection.return_value = True
|
||||
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "broker"
|
||||
assert result["data_schema"].schema["advanced_options"]
|
||||
|
||||
# first iteration, basic settings
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
@@ -2141,100 +2096,66 @@ async def test_setup_with_advanced_settings(
|
||||
CONF_PORT: 2345,
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "secret",
|
||||
"advanced_options": True,
|
||||
CONF_PROTOCOL: "5",
|
||||
"other_settings": {
|
||||
mqtt.CONF_KEEPALIVE: 30,
|
||||
"set_ca_cert": "auto",
|
||||
"set_client_cert": True,
|
||||
mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT],
|
||||
mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY],
|
||||
mqtt.CONF_TLS_INSECURE: True,
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_PATH: "/custom_path/",
|
||||
mqtt.CONF_WS_HEADERS: (
|
||||
'{"header_1": "content_header_1", "header_2": "content_header_2"'
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "broker"
|
||||
assert "advanced_options" not in result["data_schema"].schema
|
||||
assert result["data_schema"].schema[CONF_CLIENT_ID]
|
||||
assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE]
|
||||
assert result["data_schema"].schema["set_client_cert"]
|
||||
assert result["data_schema"].schema["set_ca_cert"]
|
||||
assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE]
|
||||
|
||||
assert result["data_schema"].schema[CONF_PROTOCOL]
|
||||
assert result["data_schema"].schema[mqtt.CONF_TRANSPORT]
|
||||
assert mqtt.CONF_CLIENT_CERT not in result["data_schema"].schema
|
||||
assert mqtt.CONF_CLIENT_KEY not in result["data_schema"].schema
|
||||
|
||||
# second iteration, advanced settings with request for client cert
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 2345,
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "secret",
|
||||
mqtt.CONF_KEEPALIVE: 30,
|
||||
"set_ca_cert": "auto",
|
||||
"set_client_cert": True,
|
||||
mqtt.CONF_TLS_INSECURE: True,
|
||||
CONF_PROTOCOL: "3.1.1",
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "broker"
|
||||
assert "advanced_options" not in result["data_schema"].schema
|
||||
assert result["data_schema"].schema[CONF_CLIENT_ID]
|
||||
assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE]
|
||||
assert result["data_schema"].schema["set_client_cert"]
|
||||
assert result["data_schema"].schema["set_ca_cert"]
|
||||
assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE]
|
||||
assert result["data_schema"].schema[CONF_PROTOCOL]
|
||||
assert result["data_schema"].schema[mqtt.CONF_CLIENT_CERT]
|
||||
assert result["data_schema"].schema[mqtt.CONF_CLIENT_KEY]
|
||||
assert result["data_schema"].schema[mqtt.CONF_TRANSPORT]
|
||||
assert result["data_schema"].schema[mqtt.CONF_WS_PATH]
|
||||
assert result["data_schema"].schema[mqtt.CONF_WS_HEADERS]
|
||||
|
||||
# third iteration, advanced settings with client cert and key
|
||||
# set and bad json payload
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 2345,
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "secret",
|
||||
mqtt.CONF_KEEPALIVE: 30,
|
||||
"set_ca_cert": "auto",
|
||||
"set_client_cert": True,
|
||||
mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT],
|
||||
mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY],
|
||||
mqtt.CONF_TLS_INSECURE: True,
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_PATH: "/custom_path/",
|
||||
mqtt.CONF_WS_HEADERS: (
|
||||
'{"header_1": "content_header_1", "header_2": "content_header_2"'
|
||||
),
|
||||
},
|
||||
)
|
||||
for key in (
|
||||
CONF_CLIENT_ID,
|
||||
mqtt.CONF_KEEPALIVE,
|
||||
"set_client_cert",
|
||||
"set_ca_cert",
|
||||
mqtt.CONF_TLS_INSECURE,
|
||||
mqtt.CONF_CLIENT_CERT,
|
||||
mqtt.CONF_CLIENT_KEY,
|
||||
mqtt.CONF_TRANSPORT,
|
||||
mqtt.CONF_WS_PATH,
|
||||
mqtt.CONF_WS_HEADERS,
|
||||
):
|
||||
assert result["data_schema"].schema["other_settings"].schema.schema[key]
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "broker"
|
||||
assert result["errors"]["base"] == "bad_ws_headers"
|
||||
|
||||
# fourth iteration, advanced settings with client cert and key set
|
||||
# next iteration, with client cert and key set
|
||||
# and correct json payload for ws_headers
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 2345,
|
||||
CONF_PROTOCOL: "5",
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "secret",
|
||||
mqtt.CONF_KEEPALIVE: 30,
|
||||
"set_ca_cert": "auto",
|
||||
"set_client_cert": True,
|
||||
mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT],
|
||||
mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY],
|
||||
mqtt.CONF_TLS_INSECURE: True,
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_PATH: "/custom_path/",
|
||||
mqtt.CONF_WS_HEADERS: (
|
||||
'{"header_1": "content_header_1", "header_2": "content_header_2"}'
|
||||
),
|
||||
"other_settings": {
|
||||
mqtt.CONF_KEEPALIVE: 30,
|
||||
"set_ca_cert": "auto",
|
||||
"set_client_cert": True,
|
||||
mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT],
|
||||
mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY],
|
||||
mqtt.CONF_TLS_INSECURE: True,
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_PATH: "/custom_path/",
|
||||
mqtt.CONF_WS_HEADERS: (
|
||||
'{"header_1": "content_header_1", "header_2": "content_header_2"}'
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2297,111 +2218,103 @@ async def test_setup_with_certificates(
|
||||
"""Test config flow setup with PEM and DER encoded certificates."""
|
||||
file_id = mock_process_uploaded_file.file_id
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
version=mqtt.CONFIG_ENTRY_VERSION,
|
||||
minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION,
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
mock_try_connection.return_value = False
|
||||
|
||||
# Flow raises an error with stale file id's
|
||||
# This test is just for coverage purpose
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.mqtt.config_flow.process_uploaded_file",
|
||||
side_effect=ValueError("File does not exist"),
|
||||
),
|
||||
pytest.raises(ValueError),
|
||||
):
|
||||
await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 2345,
|
||||
CONF_PROTOCOL: "5",
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "secret",
|
||||
"other_settings": {
|
||||
mqtt.CONF_KEEPALIVE: 30,
|
||||
"set_ca_cert": "custom",
|
||||
"set_client_cert": True,
|
||||
"client_key_password": client_key_password,
|
||||
mqtt.CONF_CERTIFICATE: str(uuid4()),
|
||||
mqtt.CONF_CLIENT_CERT: str(uuid4()),
|
||||
mqtt.CONF_CLIENT_KEY: str(uuid4()),
|
||||
mqtt.CONF_TLS_INSECURE: False,
|
||||
mqtt.CONF_TRANSPORT: "tcp",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Repeat the test with valid files, but connection fails
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 2345,
|
||||
CONF_PROTOCOL: "5",
|
||||
CONF_PORT: 1234,
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "secret",
|
||||
"other_settings": {
|
||||
mqtt.CONF_KEEPALIVE: 30,
|
||||
"set_ca_cert": "custom",
|
||||
"set_client_cert": True,
|
||||
"client_key_password": client_key_password,
|
||||
mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE],
|
||||
mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT],
|
||||
mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY],
|
||||
mqtt.CONF_TLS_INSECURE: False,
|
||||
mqtt.CONF_TRANSPORT: "tcp",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
# Now retry, but using the preserved uploaded files
|
||||
mock_try_connection.return_value = True
|
||||
with patch(
|
||||
"homeassistant.components.mqtt.config_flow.process_uploaded_file",
|
||||
side_effect=ValueError("File does not exist"),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 1234,
|
||||
CONF_PROTOCOL: "5",
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "secret",
|
||||
"other_settings": {
|
||||
mqtt.CONF_KEEPALIVE: 30,
|
||||
"set_ca_cert": "custom",
|
||||
"set_client_cert": True,
|
||||
"client_key_password": client_key_password,
|
||||
mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE],
|
||||
mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT],
|
||||
mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY],
|
||||
mqtt.CONF_TLS_INSECURE: False,
|
||||
mqtt.CONF_TRANSPORT: "tcp",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "broker"
|
||||
assert result["data_schema"].schema["advanced_options"]
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
# first iteration, basic settings
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 2345,
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "secret",
|
||||
"advanced_options": True,
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "broker"
|
||||
assert "advanced_options" not in result["data_schema"].schema
|
||||
assert result["data_schema"].schema[CONF_CLIENT_ID]
|
||||
assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE]
|
||||
assert result["data_schema"].schema["set_client_cert"]
|
||||
assert result["data_schema"].schema["set_ca_cert"]
|
||||
assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE]
|
||||
assert result["data_schema"].schema[CONF_PROTOCOL]
|
||||
assert result["data_schema"].schema[mqtt.CONF_TRANSPORT]
|
||||
assert mqtt.CONF_CLIENT_CERT not in result["data_schema"].schema
|
||||
assert mqtt.CONF_CLIENT_KEY not in result["data_schema"].schema
|
||||
|
||||
# second iteration, advanced settings with request for client cert
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 2345,
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "secret",
|
||||
mqtt.CONF_KEEPALIVE: 30,
|
||||
"set_ca_cert": "custom",
|
||||
"set_client_cert": True,
|
||||
mqtt.CONF_TLS_INSECURE: False,
|
||||
CONF_PROTOCOL: "5",
|
||||
mqtt.CONF_TRANSPORT: "tcp",
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "broker"
|
||||
assert "advanced_options" not in result["data_schema"].schema
|
||||
assert result["data_schema"].schema[CONF_CLIENT_ID]
|
||||
assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE]
|
||||
assert result["data_schema"].schema["set_client_cert"]
|
||||
assert result["data_schema"].schema["set_ca_cert"]
|
||||
assert result["data_schema"].schema["client_key_password"]
|
||||
assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE]
|
||||
assert result["data_schema"].schema[CONF_PROTOCOL]
|
||||
assert result["data_schema"].schema[mqtt.CONF_CERTIFICATE]
|
||||
assert result["data_schema"].schema[mqtt.CONF_CLIENT_CERT]
|
||||
assert result["data_schema"].schema[mqtt.CONF_CLIENT_KEY]
|
||||
assert result["data_schema"].schema[mqtt.CONF_TRANSPORT]
|
||||
|
||||
# third iteration, advanced settings with client cert and key and CA certificate
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 2345,
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "secret",
|
||||
mqtt.CONF_KEEPALIVE: 30,
|
||||
"set_ca_cert": "custom",
|
||||
"set_client_cert": True,
|
||||
"client_key_password": client_key_password,
|
||||
mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE],
|
||||
mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT],
|
||||
mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY],
|
||||
mqtt.CONF_TLS_INSECURE: False,
|
||||
mqtt.CONF_TRANSPORT: "tcp",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
# Check config entry result
|
||||
assert config_entry.data == {
|
||||
# Check config entry data is set
|
||||
assert result["data"] == {
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PROTOCOL: "5",
|
||||
CONF_PORT: 2345,
|
||||
CONF_PORT: 1234,
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "secret",
|
||||
mqtt.CONF_KEEPALIVE: 30,
|
||||
@@ -2441,9 +2354,6 @@ async def test_change_websockets_transport_to_tcp(
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "broker"
|
||||
assert result["data_schema"].schema["transport"]
|
||||
assert result["data_schema"].schema["ws_path"]
|
||||
assert result["data_schema"].schema["ws_headers"]
|
||||
|
||||
# Change transport to tcp
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
@@ -2451,9 +2361,14 @@ async def test_change_websockets_transport_to_tcp(
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PORT: 1234,
|
||||
mqtt.CONF_TRANSPORT: "tcp",
|
||||
mqtt.CONF_WS_HEADERS: '{"header_1": "custom_header1"}',
|
||||
mqtt.CONF_WS_PATH: "/some_path",
|
||||
CONF_PROTOCOL: "5",
|
||||
"other_settings": {
|
||||
"set_ca_cert": "off",
|
||||
"set_client_cert": False,
|
||||
mqtt.CONF_TRANSPORT: "tcp",
|
||||
mqtt.CONF_WS_HEADERS: '{"header_1": "custom_header1"}',
|
||||
mqtt.CONF_WS_PATH: "/some_path",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -2468,7 +2383,7 @@ async def test_change_websockets_transport_to_tcp(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file")
|
||||
@pytest.mark.usefixtures("mock_ssl_context")
|
||||
@pytest.mark.parametrize(
|
||||
"mqtt_config_entry_data",
|
||||
[
|
||||
@@ -2485,24 +2400,53 @@ async def test_reconfigure_flow_form(
|
||||
hass: HomeAssistant,
|
||||
mock_try_connection: MagicMock,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
mock_process_uploaded_file: MagicMock,
|
||||
) -> None:
|
||||
"""Test reconfigure flow."""
|
||||
"""Test reconfigure flow with existing certificates set in the config entry."""
|
||||
await mqtt_mock_entry()
|
||||
entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
|
||||
# Add certificates to the current entry
|
||||
entry_data = MOCK_BROKER_ENTRY_DATA | {
|
||||
mqtt.CONF_CERTIFICATE: MOCK_GENERIC_CERT.decode(encoding="utf-8"),
|
||||
mqtt.CONF_CLIENT_CERT: MOCK_GENERIC_CERT.decode(encoding="utf-8"),
|
||||
mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"),
|
||||
}
|
||||
hass.config_entries.async_update_entry(entry, data=entry_data)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "broker"
|
||||
assert result["errors"] == {}
|
||||
assert (
|
||||
get_schema_suggested_value(
|
||||
result["data_schema"].schema["other_settings"].schema.schema,
|
||||
"set_client_cert",
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
get_schema_suggested_value(
|
||||
result["data_schema"].schema["other_settings"].schema.schema, "set_ca_cert"
|
||||
)
|
||||
== "custom"
|
||||
)
|
||||
|
||||
# Keep current certificate files
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
mqtt.CONF_BROKER: "10.10.10,10",
|
||||
CONF_PORT: 1234,
|
||||
CONF_PROTOCOL: "5",
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_HEADERS: '{"header_1": "custom_header1"}',
|
||||
mqtt.CONF_WS_PATH: "/some_new_path",
|
||||
"other_settings": {
|
||||
"set_ca_cert": "custom",
|
||||
"set_client_cert": True,
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_HEADERS: '{"header_1": "custom_header1"}',
|
||||
mqtt.CONF_WS_PATH: "/some_new_path",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2515,6 +2459,9 @@ async def test_reconfigure_flow_form(
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"},
|
||||
mqtt.CONF_WS_PATH: "/some_new_path",
|
||||
mqtt.CONF_CERTIFICATE: MOCK_GENERIC_CERT.decode(encoding="utf-8"),
|
||||
mqtt.CONF_CLIENT_CERT: MOCK_GENERIC_CERT.decode(encoding="utf-8"),
|
||||
mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"),
|
||||
}
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
@@ -2555,9 +2502,13 @@ async def test_reconfigure_no_changed_password(
|
||||
CONF_USERNAME: "mqtt-user",
|
||||
CONF_PASSWORD: PWD_NOT_CHANGED,
|
||||
CONF_PORT: 1234,
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_HEADERS: '{"header_1": "custom_header1"}',
|
||||
mqtt.CONF_WS_PATH: "/some_new_path",
|
||||
"other_settings": {
|
||||
"set_ca_cert": "auto",
|
||||
"set_client_cert": False,
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_HEADERS: '{"header_1": "custom_header1"}',
|
||||
mqtt.CONF_WS_PATH: "/some_new_path",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2569,6 +2520,7 @@ async def test_reconfigure_no_changed_password(
|
||||
CONF_PASSWORD: "mqtt-password",
|
||||
CONF_PORT: 1234,
|
||||
CONF_PROTOCOL: "5",
|
||||
mqtt.CONF_CERTIFICATE: "auto",
|
||||
mqtt.CONF_TRANSPORT: "websockets",
|
||||
mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"},
|
||||
mqtt.CONF_WS_PATH: "/some_new_path",
|
||||
@@ -2586,9 +2538,9 @@ async def test_reconfigure_no_changed_password(
|
||||
"expected_minor_version",
|
||||
),
|
||||
[
|
||||
(1, 1, MOCK_ENTRY_DATA | MOCK_ENTRY_OPTIONS, {}, 2, 1),
|
||||
(1, 2, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 2, 1),
|
||||
(2, 1, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 2, 1),
|
||||
(1, 1, MOCK_BROKER_ENTRY_DATA | MOCK_ENTRY_OPTIONS, {}, 2, 1),
|
||||
(1, 2, MOCK_BROKER_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 2, 1),
|
||||
(2, 1, MOCK_BROKER_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 2, 1),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_reload_after_entry_update")
|
||||
@@ -2617,7 +2569,8 @@ async def test_migrate_config_entry(
|
||||
await mqtt_mock_entry()
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
config_entry.data | config_entry.options == MOCK_ENTRY_DATA | MOCK_ENTRY_OPTIONS
|
||||
config_entry.data | config_entry.options
|
||||
== MOCK_BROKER_ENTRY_DATA | MOCK_ENTRY_OPTIONS
|
||||
)
|
||||
assert config_entry.version == expected_version
|
||||
assert config_entry.minor_version == expected_minor_version
|
||||
|
||||
@@ -1622,7 +1622,8 @@ async def test_remove_stale_media(
|
||||
event_media = media_files[0]
|
||||
assert event_media.name.endswith(".mp4")
|
||||
|
||||
event_time1 = dt_util.utcnow() - datetime.timedelta(days=8)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
event_time1 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=8)
|
||||
extra_media1 = (
|
||||
device_path / f"{int(event_time1.timestamp())}-camera_motion-test.mp4"
|
||||
)
|
||||
@@ -1633,7 +1634,8 @@ async def test_remove_stale_media(
|
||||
)
|
||||
extra_media2.write_bytes(mp4.getvalue())
|
||||
# This event will not be garbage collected because it is too recent
|
||||
event_time3 = dt_util.utcnow() - datetime.timedelta(days=3)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
event_time3 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=3)
|
||||
extra_media3 = (
|
||||
device_path / f"{int(event_time3.timestamp())}-camera_motion-test.mp4"
|
||||
)
|
||||
@@ -1643,7 +1645,8 @@ async def test_remove_stale_media(
|
||||
|
||||
# Advance the clock to invoke the garbage collector. This will remove extra
|
||||
# files that are not valid events that are old enough.
|
||||
point_in_time = dt_util.utcnow() + datetime.timedelta(days=1)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
point_in_time = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
|
||||
with freeze_time(point_in_time):
|
||||
async_fire_time_changed(hass, point_in_time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'80:7d:3a:bd:1e:32',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': '8.0',
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'renson',
|
||||
'80:7d:3a:bd:1e:32',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Renson',
|
||||
'model': 'Endura Delta',
|
||||
'model_id': None,
|
||||
'name': 'Ventilation',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': 'Firmware version 4.9.1',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Tests for the Renson integration setup."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.renson.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_device_registry(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the device registry entry, including the network MAC connection."""
|
||||
all_data = {
|
||||
"ModifiedItems": [
|
||||
{"Name": "MAC", "Value": "80:7d:3a:bd:1e:32"},
|
||||
{"Name": "Device name", "Value": "Endura Delta"},
|
||||
{"Name": "Firmware version", "Value": "Firmware version 4.9.1"},
|
||||
{"Name": "Hardware version", "Value": "8.0"},
|
||||
]
|
||||
}
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "1.1.1.1"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_api.connect.return_value = True
|
||||
mock_api.get_all_data.return_value = all_data
|
||||
|
||||
def _get_field_value(data: dict, fieldname: str) -> str:
|
||||
for item in data["ModifiedItems"]:
|
||||
if item["Name"] == fieldname:
|
||||
return item["Value"]
|
||||
return ""
|
||||
|
||||
mock_api.get_field_value.side_effect = _get_field_value
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.renson.RensonVentilation",
|
||||
return_value=mock_api,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.renson.PLATFORMS",
|
||||
[Platform.SENSOR],
|
||||
),
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "80:7d:3a:bd:1e:32")}
|
||||
)
|
||||
assert device_entry == snapshot
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
@@ -47,7 +47,6 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .conftest import (
|
||||
CONF_BC_CONNECT,
|
||||
@@ -1206,7 +1205,7 @@ async def test_firmware_update_delay(
|
||||
call_count: int,
|
||||
) -> None:
|
||||
"""Test delay of firmware update check."""
|
||||
now = dt_util.utcnow()
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
check_delay = (
|
||||
now
|
||||
+ timedelta(seconds=seconds)
|
||||
|
||||
@@ -49,21 +49,6 @@ async def test_base_station_migration(
|
||||
assert device_registry.async_get_device(identifiers=new_identifiers) is not None
|
||||
|
||||
|
||||
async def test_base_station_model_is_string(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
config_entry: MockConfigEntry,
|
||||
patch_simplisafe_api,
|
||||
) -> None:
|
||||
"""Test that the base station model is stored as a string in the device registry."""
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, "12345")})
|
||||
assert device is not None
|
||||
assert isinstance(device.model, str)
|
||||
|
||||
|
||||
async def test_coordinator_update_triggers_reauth_on_invalid_credentials(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry[with_mac]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'ff:00:00:00:00:00',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vilfo',
|
||||
'testadmin.vilfo.com',
|
||||
'FF-00-00-00-00-00',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Vilfo AB',
|
||||
'model': 'Vilfo Router',
|
||||
'model_id': None,
|
||||
'name': 'Vilfo Router',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '1.1.0',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_device_registry[without_mac]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vilfo',
|
||||
'testadmin.vilfo.com',
|
||||
None,
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Vilfo AB',
|
||||
'model': 'Vilfo Router',
|
||||
'model_id': None,
|
||||
'name': 'Vilfo Router',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '1.1.0',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Tests for the Vilfo Router integration setup."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.vilfo.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mac", "identifiers"),
|
||||
[
|
||||
pytest.param(
|
||||
"FF-00-00-00-00-00",
|
||||
{(DOMAIN, "testadmin.vilfo.com", "FF-00-00-00-00-00")},
|
||||
id="with_mac",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
{(DOMAIN, "testadmin.vilfo.com", None)},
|
||||
id="without_mac",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_device_registry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
mac: str | None,
|
||||
identifiers: set[tuple[str, str | None]],
|
||||
) -> None:
|
||||
"""Test the device registry entry.
|
||||
|
||||
The network MAC connection is only attached when the router reports a MAC;
|
||||
a router set up by host may not report one.
|
||||
"""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.vilfo.VilfoClient", autospec=True
|
||||
) as mock_client:
|
||||
client = mock_client.return_value
|
||||
client.mac = mac
|
||||
client.get_board_information.return_value = {
|
||||
"version": "1.1.0",
|
||||
"bootTime": "2024-01-01T00:00:00+00:00",
|
||||
}
|
||||
client.get_load.return_value = 30
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers=identifiers)
|
||||
assert device_entry == snapshot
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiowebdav2.exceptions import (
|
||||
AccessDeniedError,
|
||||
ConnectionExceptionError,
|
||||
NoConnectionError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from aiowebdav2.exceptions import AccessDeniedError, UnauthorizedError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN
|
||||
@@ -33,29 +28,8 @@ from tests.common import MockConfigEntry
|
||||
"Access denied to /access_denied",
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
),
|
||||
(
|
||||
ConnectionExceptionError(ConnectionError("Connection refused")),
|
||||
"Connection refused",
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
),
|
||||
(
|
||||
NoConnectionError("webdav.demo"),
|
||||
"No connection with webdav.demo",
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
),
|
||||
(
|
||||
TimeoutError(),
|
||||
"",
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"UnauthorizedError",
|
||||
"AccessDeniedError",
|
||||
"ConnectionExceptionError",
|
||||
"NoConnectionError",
|
||||
"TimeoutError",
|
||||
],
|
||||
ids=["UnauthorizedError", "AccessDeniedError"],
|
||||
)
|
||||
async def test_error_during_setup(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_entry_diagnostics
|
||||
dict({
|
||||
'entry': dict({
|
||||
'data': dict({
|
||||
'auth_implementation': 'yoto',
|
||||
'token': dict({
|
||||
'access_token': '**REDACTED**',
|
||||
'expires_in': 3600,
|
||||
'refresh_token': '**REDACTED**',
|
||||
'scope': 'offline_access family:view family:devices:view family:devices:control family:devices:manage family:library:view user:content:view user:icons:manage',
|
||||
'token_type': 'Bearer',
|
||||
}),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'discovery_keys': dict({
|
||||
}),
|
||||
'domain': 'yoto',
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'user',
|
||||
'subentries': list([
|
||||
]),
|
||||
'title': 'Yoto',
|
||||
'unique_id': 'auth0|user-test',
|
||||
'version': 1,
|
||||
}),
|
||||
'players': dict({
|
||||
'player-test': dict({
|
||||
'device': dict({
|
||||
'description': None,
|
||||
'device_family': 'v3',
|
||||
'device_group': None,
|
||||
'device_id': 'player-test',
|
||||
'device_type': 'v3',
|
||||
'form_factor': None,
|
||||
'generation': 'gen3',
|
||||
'has_user_given_name': False,
|
||||
'name': 'Nursery Yoto',
|
||||
'release_channel': None,
|
||||
}),
|
||||
'devices_refreshed_at': '2026-05-08T12:00:00+00:00',
|
||||
'extended_status': dict({
|
||||
'active_card': None,
|
||||
'ambient_light_sensor_reading': None,
|
||||
'average_download_speed_bytes_second': None,
|
||||
'battery_level_percentage': None,
|
||||
'battery_level_raw': None,
|
||||
'battery_profile': None,
|
||||
'battery_temperature': None,
|
||||
'battery_voltage_mv': None,
|
||||
'card_insertion_state': None,
|
||||
'current_display_brightness': None,
|
||||
'day_mode': None,
|
||||
'free_disk_space_bytes': None,
|
||||
'is_audio_device_connected': None,
|
||||
'is_background_download_active': None,
|
||||
'is_bluetooth_audio_connected': None,
|
||||
'is_charging': None,
|
||||
'network_ssid': None,
|
||||
'nightlight_mode': None,
|
||||
'power_source': None,
|
||||
'system_volume_percentage': None,
|
||||
'temperature_celcius': None,
|
||||
'total_disk_space_bytes': None,
|
||||
'updated_at': None,
|
||||
'uptime': None,
|
||||
'user_volume_percentage': None,
|
||||
'utc_offset_seconds': None,
|
||||
'utc_time': None,
|
||||
'wifi_strength': None,
|
||||
}),
|
||||
'info': dict({
|
||||
'activation_pop_code': None,
|
||||
'config': dict({
|
||||
'alarms': list([
|
||||
]),
|
||||
'bluetooth_enabled': None,
|
||||
'bt_headphones_enabled': None,
|
||||
'clock_face': None,
|
||||
'day_ambient_colour': None,
|
||||
'day_display_brightness': None,
|
||||
'day_display_brightness_auto': None,
|
||||
'day_max_volume_limit': None,
|
||||
'day_sounds_off': None,
|
||||
'day_time': dict({
|
||||
'__type': "<class 'datetime.time'>",
|
||||
'isoformat': '07:00:00',
|
||||
}),
|
||||
'day_yoto_daily': None,
|
||||
'day_yoto_radio': None,
|
||||
'display_dim_brightness': None,
|
||||
'display_dim_timeout': None,
|
||||
'headphones_volume_limited': None,
|
||||
'hour_format': None,
|
||||
'locale': None,
|
||||
'log_level': None,
|
||||
'night_ambient_colour': None,
|
||||
'night_display_brightness': None,
|
||||
'night_display_brightness_auto': None,
|
||||
'night_max_volume_limit': None,
|
||||
'night_sounds_off': None,
|
||||
'night_time': dict({
|
||||
'__type': "<class 'datetime.time'>",
|
||||
'isoformat': '19:00:00',
|
||||
}),
|
||||
'night_yoto_daily': None,
|
||||
'night_yoto_radio': None,
|
||||
'pause_power_button': None,
|
||||
'pause_volume_down': None,
|
||||
'repeat_all': None,
|
||||
'show_diagnostics': None,
|
||||
'shutdown_timeout': None,
|
||||
'system_volume': None,
|
||||
'timezone': None,
|
||||
'volume_level': None,
|
||||
}),
|
||||
'device_family': None,
|
||||
'device_group': None,
|
||||
'device_type': None,
|
||||
'error_code': None,
|
||||
'firmware_version': 'v2.17.5',
|
||||
'geo_timezone': None,
|
||||
'mac': '**REDACTED**',
|
||||
'name': None,
|
||||
'pop_code': None,
|
||||
'release_channel_id': None,
|
||||
}),
|
||||
'info_refreshed_at': '2026-05-08T12:00:00+00:00',
|
||||
'is_online': True,
|
||||
'last_event': dict({
|
||||
'card_id': 'card-test',
|
||||
'chapter_key': '01',
|
||||
'chapter_title': 'Chapter 1',
|
||||
'event_utc': None,
|
||||
'playback_status': 'playing',
|
||||
'playback_wait': None,
|
||||
'player_id': 'player-test',
|
||||
'position': 120,
|
||||
'repeat_all': None,
|
||||
'request_id': None,
|
||||
'sleep_timer_active': None,
|
||||
'sleep_timer_seconds': None,
|
||||
'source': None,
|
||||
'streaming': None,
|
||||
'track_key': '01-INT',
|
||||
'track_length': 300,
|
||||
'track_title': 'Introduction',
|
||||
'volume': 8,
|
||||
'volume_max': 16,
|
||||
}),
|
||||
'last_event_received_at': '2026-05-08T12:00:00+00:00',
|
||||
'online_refreshed_at': None,
|
||||
'status': dict({
|
||||
'active_card': None,
|
||||
'ambient_light_sensor_reading': None,
|
||||
'battery_level_percentage': 75,
|
||||
'card_insertion_state': 1,
|
||||
'current_display_brightness': None,
|
||||
'day_mode': 1,
|
||||
'free_disk_space_bytes': None,
|
||||
'is_audio_device_connected': False,
|
||||
'is_bluetooth_audio_connected': False,
|
||||
'is_charging': True,
|
||||
'nightlight_mode': None,
|
||||
'system_volume_percentage': None,
|
||||
'updated_at': None,
|
||||
'user_volume_percentage': None,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Tests for the diagnostics data provided by the Yoto integration."""
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from syrupy.filters import props
|
||||
|
||||
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
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("setup_credentials", "mock_yoto_client")
|
||||
|
||||
|
||||
async def test_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test config entry diagnostics."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert await get_diagnostics_for_config_entry(
|
||||
hass, hass_client, mock_config_entry
|
||||
) == snapshot(exclude=props("entry_id", "created_at", "modified_at", "expires_at"))
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Test the condition helper."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Mapping
|
||||
from contextlib import AbstractContextManager, nullcontext as does_not_raise
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
@@ -5908,19 +5908,6 @@ async def test_history_priming_manager_serializes_queries(
|
||||
assert max_running == 1
|
||||
|
||||
|
||||
async def _advance_until(predicate: Callable[[], bool]) -> None:
|
||||
"""Pump the event loop until predicate holds, failing if it never does.
|
||||
|
||||
Avoids coupling tests to an exact number of internal await hops while still
|
||||
failing cleanly rather than hanging on a regression.
|
||||
"""
|
||||
for _ in range(1000):
|
||||
if predicate():
|
||||
return
|
||||
await asyncio.sleep(0)
|
||||
pytest.fail("condition was not reached")
|
||||
|
||||
|
||||
async def test_history_priming_manager_does_not_ride_in_flight_flush(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
@@ -5930,8 +5917,8 @@ async def test_history_priming_manager_does_not_ride_in_flight_flush(
|
||||
sees them. A condition that started tracking after an in-flight flush began
|
||||
could miss its own just-queued change if it rode that flush, so it waits the
|
||||
flush out and a fresh one is performed for it. Without the lobby step this
|
||||
test fails: the late arrivals would ride the first flush (it would stay at
|
||||
one flush total) instead of sharing a second, fresh one.
|
||||
test fails: the late arrivals would ride the first flush (one flush total)
|
||||
instead of sharing a second, fresh one.
|
||||
"""
|
||||
manager = _HistoryPrimingManager(hass)
|
||||
instance = get_instance(hass)
|
||||
@@ -5949,69 +5936,32 @@ async def test_history_priming_manager_does_not_ride_in_flight_flush(
|
||||
with patch.object(instance, "async_get_commit_future", _spy_commit_future):
|
||||
# C0 claims the flush and is mid-flush (its commit future is pending).
|
||||
c0 = asyncio.create_task(manager.async_prime(_job))
|
||||
await _advance_until(lambda: len(flush_futures) == 1)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if flush_futures:
|
||||
break
|
||||
assert len(flush_futures) == 1
|
||||
|
||||
# Two conditions arrive while C0's flush runs; they must not ride it.
|
||||
c1 = asyncio.create_task(manager.async_prime(_job))
|
||||
c2 = asyncio.create_task(manager.async_prime(_job))
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
# Parked in the lobby: no new flush yet, none finished.
|
||||
assert len(flush_futures) == 1
|
||||
assert not c1.done()
|
||||
assert not c2.done()
|
||||
|
||||
# C0's flush completes; C1 then performs a fresh flush and C2 rides it.
|
||||
# C0's flush completes; C1 now performs a fresh flush and C2 rides it.
|
||||
flush_futures[0].set_result(None)
|
||||
assert await c0 == "done"
|
||||
await _advance_until(lambda: len(flush_futures) == 2)
|
||||
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
# Exactly one fresh flush is shared by C1 and C2, not one each: this is
|
||||
# the assertion that fails without the lobby (it would stay 1).
|
||||
assert len(flush_futures) == 2
|
||||
flush_futures[1].set_result(None)
|
||||
assert await asyncio.gather(c1, c2) == ["done", "done"]
|
||||
# One fresh flush shared by C1 and C2, not one each (and not C0's stale
|
||||
# one): C1 flushed, C2 rode it.
|
||||
assert len(flush_futures) == 2
|
||||
|
||||
|
||||
async def test_history_priming_manager_retries_after_cancelled_flush(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""A rider re-flushes when the flush it rode was cancelled before completing.
|
||||
|
||||
If the condition performing a generation's shared flush is cancelled by its
|
||||
timeout while awaiting the commit, the riders must not read against the
|
||||
unflushed queue — they perform a fresh flush instead. Without that retry this
|
||||
test fails: the rider would proceed on the cancelled flush and never make a
|
||||
second one.
|
||||
"""
|
||||
manager = _HistoryPrimingManager(hass)
|
||||
instance = get_instance(hass)
|
||||
|
||||
flush_futures: list[asyncio.Future[None]] = []
|
||||
|
||||
def _spy_commit_future() -> asyncio.Future[None]:
|
||||
fut = hass.loop.create_future()
|
||||
flush_futures.append(fut)
|
||||
return fut
|
||||
|
||||
async def _job(_recorder: Recorder) -> str:
|
||||
return "done"
|
||||
|
||||
with patch.object(instance, "async_get_commit_future", _spy_commit_future):
|
||||
# C0 takes the lobby so c1 and c2 form one generation behind it.
|
||||
c0 = asyncio.create_task(manager.async_prime(_job))
|
||||
await _advance_until(lambda: len(flush_futures) == 1)
|
||||
c1 = asyncio.create_task(manager.async_prime(_job))
|
||||
c2 = asyncio.create_task(manager.async_prime(_job))
|
||||
flush_futures[0].set_result(None)
|
||||
assert await c0 == "done"
|
||||
|
||||
# c1 performs the generation's flush (the second one) and c2 rides it.
|
||||
await _advance_until(lambda: len(flush_futures) == 2)
|
||||
|
||||
# c1 is cancelled mid-flush, as its timeout would do. c2 must then run
|
||||
# its own fresh flush rather than ride c1's cancelled one.
|
||||
c1.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await c1
|
||||
await _advance_until(lambda: len(flush_futures) == 3)
|
||||
|
||||
flush_futures[2].set_result(None)
|
||||
assert await c2 == "done"
|
||||
|
||||
|
||||
async def test_history_priming_manager_cancelled_lobby_waiter(
|
||||
@@ -6037,11 +5987,14 @@ async def test_history_priming_manager_cancelled_lobby_waiter(
|
||||
|
||||
with patch.object(instance, "async_get_commit_future", _spy_commit_future):
|
||||
c0 = asyncio.create_task(manager.async_prime(_job))
|
||||
await _advance_until(lambda: len(flush_futures) == 1)
|
||||
# A second priming parks in the lobby (reached in one step, as its lock
|
||||
# acquire is uncontended), then its timeout cancels it.
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if flush_futures:
|
||||
break
|
||||
# A second priming parks in the lobby, then its timeout cancels it.
|
||||
waiter = asyncio.create_task(manager.async_prime(_job))
|
||||
await asyncio.sleep(0)
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
waiter.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await waiter
|
||||
@@ -6050,7 +6003,9 @@ async def test_history_priming_manager_cancelled_lobby_waiter(
|
||||
flush_futures[0].set_result(None)
|
||||
assert await c0 == "done"
|
||||
later = asyncio.create_task(manager.async_prime(_job))
|
||||
await _advance_until(lambda: len(flush_futures) == 2)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
assert len(flush_futures) == 2
|
||||
flush_futures[1].set_result(None)
|
||||
assert await later == "done"
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Test service helpers."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -17,7 +16,7 @@ from homeassistant.const import (
|
||||
STATE_ON,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
@@ -806,20 +805,16 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test on_entities_update callback reports added and removed entities."""
|
||||
entity_updates: list[tuple[set[str], set[str], set[str]]] = []
|
||||
entity_updates: list[tuple[set[str], set[str]]] = []
|
||||
|
||||
@callback
|
||||
def state_change_callback(event: target.TargetStateChangedData) -> None:
|
||||
"""Handle state change events."""
|
||||
|
||||
@callback
|
||||
def on_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
"""Track entity set changes."""
|
||||
entity_updates.append((added, removed, set(entity_states)))
|
||||
entity_updates.append((added, removed))
|
||||
|
||||
config_entry = MockConfigEntry(domain="test")
|
||||
config_entry.add_to_hass(hass)
|
||||
@@ -849,10 +844,9 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
on_entities_update=on_entities_update,
|
||||
)
|
||||
|
||||
# Initial setup fires on_entities_update with all entities as "added".
|
||||
# The states mapping covers the currently targeted entities.
|
||||
# Initial setup fires on_entities_update with all entities as "added"
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == ({entity_a.entity_id}, set(), {entity_a.entity_id})
|
||||
assert entity_updates[-1] == ({entity_a.entity_id}, set())
|
||||
entity_updates.clear()
|
||||
|
||||
# Add label to entity_b → added
|
||||
@@ -860,11 +854,7 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == (
|
||||
{entity_b.entity_id},
|
||||
set(),
|
||||
{entity_a.entity_id, entity_b.entity_id},
|
||||
)
|
||||
assert entity_updates[-1] == ({entity_b.entity_id}, set())
|
||||
entity_updates.clear()
|
||||
|
||||
# Remove label from entity_a → removed
|
||||
@@ -872,7 +862,7 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == (set(), {entity_a.entity_id}, {entity_b.entity_id})
|
||||
assert entity_updates[-1] == (set(), {entity_a.entity_id})
|
||||
entity_updates.clear()
|
||||
|
||||
# Remove label from entity_b → removed
|
||||
@@ -880,7 +870,7 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == (set(), {entity_b.entity_id}, set())
|
||||
assert entity_updates[-1] == (set(), {entity_b.entity_id})
|
||||
entity_updates.clear()
|
||||
|
||||
# Re-add both labels at once — entity_a first, then entity_b
|
||||
@@ -890,12 +880,8 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(entity_updates) == 2
|
||||
assert entity_updates[0] == ({entity_a.entity_id}, set(), {entity_a.entity_id})
|
||||
assert entity_updates[1] == (
|
||||
{entity_b.entity_id},
|
||||
set(),
|
||||
{entity_a.entity_id, entity_b.entity_id},
|
||||
)
|
||||
assert entity_updates[0] == ({entity_a.entity_id}, set())
|
||||
assert entity_updates[1] == ({entity_b.entity_id}, set())
|
||||
entity_updates.clear()
|
||||
|
||||
# After unsubscribing, no more callbacks
|
||||
@@ -917,11 +903,7 @@ async def test_async_track_target_selector_cancels_update_task_on_unsubscribe(
|
||||
def state_change_callback(event: target.TargetStateChangedData) -> None:
|
||||
"""Handle state change events."""
|
||||
|
||||
async def on_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
async def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
nonlocal cancelled
|
||||
started.set()
|
||||
try:
|
||||
|
||||
+47
-249
@@ -4735,11 +4735,15 @@ async def test_entity_trigger_duration_each_cancelled_when_entity_leaves_target(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test an each duration timer is cancelled when its entity is untargeted.
|
||||
"""Test an each duration timer when its entity is untargeted mid-wait.
|
||||
|
||||
A pending `for:` wait does not outlive the entity's membership of the
|
||||
A pending `for:` wait should not outlive the entity's membership of the
|
||||
target: when a registry change removes the entity from the target, the
|
||||
timer is cancelled and the trigger does not fire.
|
||||
timer should be cancelled.
|
||||
|
||||
This test documents existing unwanted behavior: the duration timer
|
||||
keeps running and the trigger fires for an entity which is no longer
|
||||
targeted.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test Each Removal")
|
||||
@@ -4768,66 +4772,13 @@ async def test_entity_trigger_duration_each_cancelled_when_entity_leaves_target(
|
||||
entity_registry.async_update_entity(entry.entity_id, labels=set())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original duration — should NOT fire
|
||||
# Advance past the original duration. Unwanted: the trigger fires for
|
||||
# the no-longer-targeted entity.
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_each_cancelled_on_entity_rename(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test an each duration timer is cancelled when its entity is renamed.
|
||||
|
||||
A rename is delivered as the old entity id leaving the target and the
|
||||
new entity id joining it, so the pending per-entity timer is cancelled
|
||||
rather than transferred to the new id. The entity stays on and targeted
|
||||
under the new id, but never had an off→on transition as the new id, so
|
||||
no timer is armed for it and the trigger does not fire.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test Each Rename")
|
||||
entry = entity_registry.async_get_or_create("test", "test", "labeled")
|
||||
entity_registry.async_update_entity(entry.entity_id, labels={label.label_id})
|
||||
hass.states.async_set(entry.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass,
|
||||
[],
|
||||
BEHAVIOR_EACH,
|
||||
calls,
|
||||
duration={"seconds": 5},
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
hass.states.async_set(entry.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Rename the entity mid-wait. It keeps its label, so it stays targeted
|
||||
# under the new id; the state follows the rename like the entity
|
||||
# component does.
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
new_entity_id = "test.renamed"
|
||||
entity_registry.async_update_entity(entry.entity_id, new_entity_id=new_entity_id)
|
||||
hass.states.async_remove(entry.entity_id)
|
||||
hass.states.async_set(new_entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original duration — should NOT fire: the timer for
|
||||
# the old id was cancelled, and the new id never transitioned off→on.
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entry.entity_id
|
||||
|
||||
unsub()
|
||||
|
||||
@@ -4837,12 +4788,17 @@ async def test_entity_trigger_duration_all_survives_entity_leaving_target(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test a pending all timer ignores an entity removed from the target.
|
||||
"""Test a pending all timer when an entity is removed from the target.
|
||||
|
||||
Once an entity is removed from the target, it no longer gates the
|
||||
all-match: the timer keeps running and fires if the remaining targeted
|
||||
entities stay matching, even if the removed entity changes to a
|
||||
non-matching state.
|
||||
Once an entity is removed from the target it should no longer gate the
|
||||
all-match: the timer should keep running and fire if the remaining
|
||||
targeted entities stay matching, even if the removed entity changes to
|
||||
a non-matching state.
|
||||
|
||||
This test documents existing unwanted behavior: the duration cancel
|
||||
check still tracks the entity set frozen when the timer was armed, so
|
||||
the removed entity turning off cancels the timer and the trigger does
|
||||
not fire.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test All Removal")
|
||||
@@ -4870,8 +4826,8 @@ async def test_entity_trigger_duration_all_survives_entity_leaving_target(
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# B leaves the target mid-wait and turns off: it no longer gates the
|
||||
# all-match, so the timer keeps running.
|
||||
# B leaves the target mid-wait and turns off: it should no longer gate
|
||||
# the all-match.
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels=set())
|
||||
@@ -4879,177 +4835,14 @@ async def test_entity_trigger_duration_all_survives_entity_leaving_target(
|
||||
hass.states.async_set(entry_b.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The remaining targeted entity stayed on for the duration — fires
|
||||
# Unwanted: the remaining targeted entity stayed on for the duration,
|
||||
# so the trigger should fire — but the no-longer-targeted entity
|
||||
# cancelled the timer.
|
||||
freezer.tick(datetime.timedelta(seconds=4))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entry_b.entity_id
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("added_entity_state", "expected_calls"),
|
||||
[
|
||||
pytest.param(STATE_OFF, 0, id="added_entity_breaks_all_match"),
|
||||
pytest.param(STATE_ON, 1, id="added_entity_keeps_all_match"),
|
||||
],
|
||||
)
|
||||
async def test_entity_trigger_duration_all_revalidated_when_entity_joins_target(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
added_entity_state: str,
|
||||
expected_calls: int,
|
||||
) -> None:
|
||||
"""Test a pending all timer is re-validated when an entity is added.
|
||||
|
||||
An entity added to the target mid-wait participates in the all-match:
|
||||
a non-matching entity cancels the pending timer, while a matching one
|
||||
leaves it running and the trigger fires after the duration.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test All Addition")
|
||||
entry_a = entity_registry.async_get_or_create("test", "test", "labeled_a")
|
||||
entry_b = entity_registry.async_get_or_create("test", "test", "labeled_b")
|
||||
entity_registry.async_update_entity(entry_a.entity_id, labels={label.label_id})
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels={label.label_id})
|
||||
hass.states.async_set(entry_a.entity_id, STATE_OFF)
|
||||
hass.states.async_set(entry_b.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass,
|
||||
[],
|
||||
BEHAVIOR_ALL,
|
||||
calls,
|
||||
duration={"seconds": 5},
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
hass.states.async_set(entry_a.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entry_b.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# A third entity joins the target mid-wait
|
||||
entry_c = entity_registry.async_get_or_create("test", "test", "labeled_c")
|
||||
hass.states.async_set(entry_c.entity_id, added_entity_state)
|
||||
await hass.async_block_till_done()
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_c.entity_id, labels={label.label_id})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the duration
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == expected_calls
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_first_cancelled_when_match_leaves_target(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test a pending first timer when the matching entity is untargeted.
|
||||
|
||||
Removing the only matching entity from the target mid-wait leaves the
|
||||
target without a matching entity, so the pending timer is cancelled and
|
||||
the trigger does not fire.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test First Removal")
|
||||
entry_a = entity_registry.async_get_or_create("test", "test", "labeled_a")
|
||||
entry_b = entity_registry.async_get_or_create("test", "test", "labeled_b")
|
||||
entity_registry.async_update_entity(entry_a.entity_id, labels={label.label_id})
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels={label.label_id})
|
||||
hass.states.async_set(entry_a.entity_id, STATE_OFF)
|
||||
hass.states.async_set(entry_b.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass,
|
||||
[],
|
||||
BEHAVIOR_FIRST,
|
||||
calls,
|
||||
duration={"seconds": 5},
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
hass.states.async_set(entry_a.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# The only matching entity leaves the target mid-wait
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_a.entity_id, labels=set())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original duration — should NOT fire
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_first_survives_entity_joining_target(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test a pending first timer when an entity is added to the target.
|
||||
|
||||
The added entity does not match, but at least one targeted entity
|
||||
still does, so the timer keeps running and the trigger fires.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test First Addition")
|
||||
entry_a = entity_registry.async_get_or_create("test", "test", "labeled_a")
|
||||
entity_registry.async_update_entity(entry_a.entity_id, labels={label.label_id})
|
||||
hass.states.async_set(entry_a.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass,
|
||||
[],
|
||||
BEHAVIOR_FIRST,
|
||||
calls,
|
||||
duration={"seconds": 5},
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
hass.states.async_set(entry_a.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# A non-matching entity joins the target mid-wait
|
||||
entry_b = entity_registry.async_get_or_create("test", "test", "labeled_b")
|
||||
hass.states.async_set(entry_b.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels={label.label_id})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The matching entity stayed on for the duration — fires
|
||||
freezer.tick(datetime.timedelta(seconds=4))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entry_a.entity_id
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@@ -5058,15 +4851,19 @@ async def test_entity_trigger_first_nested_state_revert(
|
||||
) -> None:
|
||||
"""Test a synchronous bus listener reverting a state change.
|
||||
|
||||
A synchronous bus listener turns entity_a off again from within the
|
||||
dispatch of its turn-on event. The event bus queues the nested off-event
|
||||
and dispatches it after the on-event, so the target tracker observes the
|
||||
two events in fire order and its tracked states view stays consistent.
|
||||
Writing states from a synchronous bus listener during state change
|
||||
dispatch is not supported: the nested state write is dispatched to the
|
||||
target tracker before the event that caused it, inverting per-entity
|
||||
delivery order. Supported state change tracking via
|
||||
async_track_state_change_event or async_track_state_change_filtered is
|
||||
deferred precisely so callbacks cannot run inside the dispatch loop and
|
||||
cause this.
|
||||
|
||||
The trigger fires for entity_a — it was the first entity to match, even
|
||||
though it was immediately reverted — consistent with behavior each and
|
||||
with a same-iteration blip. Because the view is not left stale, entity_b
|
||||
turning on later is correctly recognized as a first match and fires too.
|
||||
This test documents the resulting behavior rather than guaranteeing it:
|
||||
both of entity_a's events are evaluated against the live state machine,
|
||||
which already shows the entity off again, so the trigger does not fire
|
||||
for the blip; entity_b turning on later counts as the only match and
|
||||
fires.
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
@@ -5084,6 +4881,9 @@ async def test_entity_trigger_first_nested_state_revert(
|
||||
):
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
|
||||
# Registered before the trigger is armed, so it runs before the state
|
||||
# change tracker's bus listener and its nested write is dispatched to
|
||||
# the tracker first.
|
||||
unsub_revert = hass.bus.async_listen(EVENT_STATE_CHANGED, revert_entity_a)
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
@@ -5092,19 +4892,17 @@ async def test_entity_trigger_first_nested_state_revert(
|
||||
)
|
||||
|
||||
# entity_a turns on and is synchronously reverted to off. The trigger
|
||||
# receives (off→on) then (on→off) in fire order and fires for the
|
||||
# on-event: entity_a was the first matching entity.
|
||||
# receives (on→off) then (off→on); the on-event counts no matches in
|
||||
# the live state machine and the trigger does not fire.
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_a
|
||||
assert len(calls) == 0
|
||||
|
||||
# entity_a is off again, so entity_b is now the first matching entity
|
||||
# and the trigger fires for it.
|
||||
# entity_a is off, so entity_b is the first matching entity and fires.
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 2
|
||||
assert calls[1]["entity_id"] == entity_b
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_b
|
||||
|
||||
unsub()
|
||||
unsub_revert()
|
||||
|
||||
@@ -679,30 +679,12 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None:
|
||||
" 2020.12, please create a bug report at https://github.com/home-assistant/"
|
||||
"core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial",
|
||||
False,
|
||||
"Detected that custom integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial>=3.5",
|
||||
True,
|
||||
"Detected that integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial-asyncio",
|
||||
False,
|
||||
"Detected that custom integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"which should be replaced by pyserial-asyncio-fast. This will stop"
|
||||
" working in Home Assistant 2026.7, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
@@ -710,26 +692,8 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None:
|
||||
"pyserial-asyncio>=0.6",
|
||||
True,
|
||||
"Detected that integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial-asyncio-fast",
|
||||
False,
|
||||
"Detected that custom integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial-asyncio-fast>=0.6",
|
||||
True,
|
||||
"Detected that integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"which should be replaced by pyserial-asyncio-fast. This will stop"
|
||||
" working in Home Assistant 2026.7, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
|
||||
@@ -6,6 +6,9 @@ from itertools import chain
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
@@ -14,7 +17,6 @@ from homeassistant.const import (
|
||||
UnitOfBloodGlucoseConcentration,
|
||||
UnitOfConductivity,
|
||||
UnitOfDataRate,
|
||||
UnitOfDensity,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -126,7 +128,7 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo
|
||||
18.016,
|
||||
),
|
||||
CarbonMonoxideConcentrationConverter: (
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1.16441,
|
||||
),
|
||||
@@ -162,22 +164,22 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo
|
||||
InformationConverter: (UnitOfInformation.BITS, UnitOfInformation.BYTES, 8),
|
||||
MassConverter: (UnitOfMass.STONES, UnitOfMass.KILOGRAMS, 0.157473),
|
||||
MassVolumeConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
1000,
|
||||
),
|
||||
NitrogenDioxideConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.912503,
|
||||
),
|
||||
NitrogenMonoxideConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.247389,
|
||||
),
|
||||
OzoneConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.995417,
|
||||
),
|
||||
@@ -199,7 +201,7 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo
|
||||
1.609343,
|
||||
),
|
||||
SulphurDioxideConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
2.6633,
|
||||
),
|
||||
@@ -334,13 +336,13 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.16441,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
0.00116441,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# PPM to other units
|
||||
(
|
||||
@@ -353,51 +355,51 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1.16441,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1164.41,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# MICROGRAMS_PER_CUBIC_METER to other units
|
||||
(
|
||||
120000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
103056.5,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
(
|
||||
120000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
103.0565,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
(
|
||||
120000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
120,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# MILLIGRAMS_PER_CUBIC_METER to other units
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
103056.5,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
103.0565,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
120000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
],
|
||||
NitrogenDioxideConcentrationConverter: [
|
||||
@@ -405,11 +407,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.912503,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
62.744976,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
@@ -417,11 +419,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1912.503,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
0.062744976,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
@@ -443,11 +445,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.247389,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
96.200906,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
@@ -801,11 +803,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.995417,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
60.1378,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
@@ -813,11 +815,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1995.417,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
0.0601378,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
@@ -1003,11 +1005,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
2.6633,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
45.056879,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
@@ -1058,23 +1060,23 @@ _CONVERTED_VALUE: dict[
|
||||
# 1000 µg/m³ = 1 mg/m³
|
||||
(
|
||||
1000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
1,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# 2 mg/m³ = 2000 µg/m³
|
||||
(
|
||||
2,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
2000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# 3 g/m³ = 3000 mg/m³
|
||||
(
|
||||
3,
|
||||
UnitOfDensity.GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
3000,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
],
|
||||
VolumeConverter: [
|
||||
|
||||
Reference in New Issue
Block a user