Compare commits

..

1 Commits

Author SHA1 Message Date
abmantis c2f11d25e1 Add button platform to Edifier Infrared 2026-06-16 19:26:27 +01:00
85 changed files with 939 additions and 3761 deletions
-1
View File
@@ -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.*
Generated
-2
View File
@@ -1063,8 +1063,6 @@ CLAUDE.md @home-assistant/core
/tests/components/madvr/ @iloveicedgreentea
/homeassistant/components/marantz_infrared/ @balloob
/tests/components/marantz_infrared/ @balloob
/homeassistant/components/marantz_rs232/ @balloob
/tests/components/marantz_rs232/ @balloob
/homeassistant/components/mastodon/ @fabaff @andrew-codechimp
/tests/components/mastodon/ @fabaff @andrew-codechimp
/homeassistant/components/matrix/ @PaarthShah
+6 -6
View File
@@ -30,7 +30,7 @@ from homeassistant.exceptions import (
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADDITIONAL_SETTINGS
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
from .coordinator import (
AirOSConfigEntry,
AirOSDataUpdateCoordinator,
@@ -55,14 +55,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
# By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(
hass, verify_ssl=entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL]
hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL]
)
conn_data = {
CONF_HOST: entry.data[CONF_HOST],
CONF_USERNAME: entry.data[CONF_USERNAME],
CONF_PASSWORD: entry.data[CONF_PASSWORD],
"use_ssl": entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL],
"use_ssl": entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
"session": session,
}
@@ -116,15 +116,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Migrate old config entry."""
# 1.1 Migrate config_entry to add additional ssl settings
# 1.1 Migrate config_entry to add advanced ssl settings
if entry.version == 1 and entry.minor_version == 1:
new_minor_version = 2
new_data = {**entry.data}
additional_data = {
advanced_data = {
CONF_SSL: DEFAULT_SSL,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
}
new_data[SECTION_ADDITIONAL_SETTINGS] = additional_data
new_data[SECTION_ADVANCED_SETTINGS] = advanced_data
hass.config_entries.async_update_entry(
entry,
@@ -52,7 +52,7 @@ from .const import (
HOSTNAME,
IP_ADDRESS,
MAC_ADDRESS,
SECTION_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS,
)
_LOGGER = logging.getLogger(__name__)
@@ -66,7 +66,7 @@ STEP_DISCOVERY_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(SECTION_ADDITIONAL_SETTINGS): section(
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Required(CONF_SSL, default=DEFAULT_SSL): bool,
@@ -134,7 +134,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(
self.hass,
verify_ssl=config_data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL],
verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
)
try:
@@ -143,7 +143,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
username=config_data[CONF_USERNAME],
password=config_data[CONF_PASSWORD],
session=session,
use_ssl=config_data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL],
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
)
except (
@@ -234,18 +234,18 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
autocomplete="current-password",
)
),
vol.Required(SECTION_ADDITIONAL_SETTINGS): section(
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Required(
CONF_SSL,
default=current_data[SECTION_ADDITIONAL_SETTINGS][
default=current_data[SECTION_ADVANCED_SETTINGS][
CONF_SSL
],
): bool,
vol.Required(
CONF_VERIFY_SSL,
default=current_data[SECTION_ADDITIONAL_SETTINGS][
default=current_data[SECTION_ADVANCED_SETTINGS][
CONF_VERIFY_SSL
],
): bool,
+1 -1
View File
@@ -12,7 +12,7 @@ MANUFACTURER = "Ubiquiti"
DEFAULT_VERIFY_SSL = False
DEFAULT_SSL = True
SECTION_ADDITIONAL_SETTINGS = "additional_settings"
SECTION_ADVANCED_SETTINGS = "advanced_settings"
# Discovery related
DEFAULT_USERNAME = "ubnt"
+2 -2
View File
@@ -4,7 +4,7 @@ from homeassistant.const import CONF_HOST, CONF_SSL
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, SECTION_ADDITIONAL_SETTINGS
from .const import DOMAIN, MANUFACTURER, SECTION_ADVANCED_SETTINGS
from .coordinator import AirOSDataUpdateCoordinator
@@ -20,7 +20,7 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
airos_data = self.coordinator.data
url_schema = (
"https"
if coordinator.config_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL]
if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
else "http"
)
+12 -12
View File
@@ -33,16 +33,16 @@
},
"description": "Enter the username and password for {device_name}",
"sections": {
"additional_settings": {
"advanced_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data::ssl%]",
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::verify_ssl%]"
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::manual::sections::additional_settings::name%]"
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
}
}
},
@@ -58,7 +58,7 @@
"username": "Administrator username for the airOS device, normally 'ubnt'"
},
"sections": {
"additional_settings": {
"advanced_settings": {
"data": {
"ssl": "Use HTTPS",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
@@ -67,7 +67,7 @@
"ssl": "Whether the connection should be encrypted (required for most devices)",
"verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates"
},
"name": "Additional settings"
"name": "Advanced settings"
}
}
},
@@ -87,16 +87,16 @@
"password": "[%key:component::airos::config::step::manual::data_description::password%]"
},
"sections": {
"additional_settings": {
"advanced_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data::ssl%]",
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::verify_ssl%]"
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::manual::sections::additional_settings::name%]"
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
}
}
},
@@ -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",
+16 -11
View File
@@ -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()
@@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -0,0 +1,180 @@
"""Button platform for Edifier infrared integration."""
from dataclasses import dataclass
from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, EdifierCode
from .entity import EdifierIrEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class EdifierIrButtonEntityDescription(ButtonEntityDescription):
"""Describes Edifier IR button entity."""
command_code: EdifierCode
COMMAND_SET_BUTTONS: dict[
EdifierCommandSet,
tuple[EdifierIrButtonEntityDescription, ...],
] = {
EdifierCommandSet.R1700BT: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierR1700BTCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="line_1",
translation_key="line_1",
command_code=EdifierR1700BTCode.LINE_1,
),
EdifierIrButtonEntityDescription(
key="line_2",
translation_key="line_2",
command_code=EdifierR1700BTCode.LINE_2,
),
EdifierIrButtonEntityDescription(
key="fx_on",
translation_key="fx_on",
command_code=EdifierR1700BTCode.FX_ON,
),
EdifierIrButtonEntityDescription(
key="fx_off",
translation_key="fx_off",
command_code=EdifierR1700BTCode.FX_OFF,
),
),
EdifierCommandSet.R1280DB: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierR1280DBCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="line_1",
translation_key="line_1",
command_code=EdifierR1280DBCode.LINE_1,
),
EdifierIrButtonEntityDescription(
key="line_2",
translation_key="line_2",
command_code=EdifierR1280DBCode.LINE_2,
),
EdifierIrButtonEntityDescription(
key="optical",
translation_key="optical",
command_code=EdifierR1280DBCode.OPTICAL,
),
EdifierIrButtonEntityDescription(
key="coax",
translation_key="coax",
command_code=EdifierR1280DBCode.COAX,
),
),
EdifierCommandSet.S360DB: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierS360DBCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="optical",
translation_key="optical",
command_code=EdifierS360DBCode.OPTICAL,
),
EdifierIrButtonEntityDescription(
key="coax",
translation_key="coax",
command_code=EdifierS360DBCode.COAX,
),
EdifierIrButtonEntityDescription(
key="pc",
translation_key="pc",
command_code=EdifierS360DBCode.PC,
),
EdifierIrButtonEntityDescription(
key="aux",
translation_key="aux",
command_code=EdifierS360DBCode.AUX,
),
),
EdifierCommandSet.RC20G: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierRC20GCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="pc",
translation_key="pc",
command_code=EdifierRC20GCode.PC,
),
EdifierIrButtonEntityDescription(
key="aux",
translation_key="aux",
command_code=EdifierRC20GCode.AUX,
),
EdifierIrButtonEntityDescription(
key="optical",
translation_key="optical",
command_code=EdifierRC20GCode.OPTICAL,
),
EdifierIrButtonEntityDescription(
key="coax",
translation_key="coax",
command_code=EdifierRC20GCode.COAX,
),
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Edifier IR buttons from a config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
command_set = EdifierCommandSet(entry.data[CONF_COMMAND_SET])
model = EdifierModel(entry.data[CONF_MODEL])
async_add_entities(
EdifierIrButton(entry, model, infrared_entity_id, description)
for description in COMMAND_SET_BUTTONS.get(command_set, ())
)
class EdifierIrButton(EdifierIrEntity, InfraredEmitterConsumerEntity, ButtonEntity):
"""Edifier IR button entity."""
entity_description: EdifierIrButtonEntityDescription
def __init__(
self,
entry: ConfigEntry,
model: EdifierModel,
infrared_entity_id: str,
description: EdifierIrButtonEntityDescription,
) -> None:
"""Initialize Edifier IR button."""
super().__init__(entry, model, unique_id_suffix=description.key)
self._infrared_emitter_entity_id = infrared_entity_id
self.entity_description = description
async def async_press(self) -> None:
"""Press the button."""
await self._send_command(self.entity_description.command_code.to_command())
@@ -18,5 +18,36 @@
"title": "Set up Edifier IR speaker"
}
}
},
"entity": {
"button": {
"aux": {
"name": "AUX"
},
"bluetooth": {
"name": "Bluetooth"
},
"coax": {
"name": "Coaxial"
},
"fx_off": {
"name": "FX off"
},
"fx_on": {
"name": "FX on"
},
"line_1": {
"name": "Line 1"
},
"line_2": {
"name": "Line 2"
},
"optical": {
"name": "Optical"
},
"pc": {
"name": "PC"
}
}
}
}
@@ -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,
+1 -1
View File
@@ -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)
@@ -1,75 +0,0 @@
"""The Marantz RS-232 integration."""
from marantz_rs232 import (
MarantzV2003Receiver,
MarantzV2007Receiver,
MarantzV2015Receiver,
V2003ReceiverState,
V2007ReceiverState,
V2015ReceiverState,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_DEVICE, CONF_MODEL, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from .config_flow import MODEL_MODERN, V2003_MODELS, V2007_MODELS
from .const import LOGGER, MarantzReceiver, MarantzRS232ConfigEntry
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(
hass: HomeAssistant, entry: MarantzRS232ConfigEntry
) -> bool:
"""Set up Marantz RS-232 from a config entry."""
port = entry.data[CONF_DEVICE]
model_key = entry.data[CONF_MODEL]
receiver: MarantzReceiver
if model_key == MODEL_MODERN:
receiver = MarantzV2015Receiver(port)
elif model_key in V2003_MODELS:
receiver = MarantzV2003Receiver(port)
else:
receiver = MarantzV2007Receiver(port, model=V2007_MODELS[model_key])
try:
await receiver.connect()
await receiver.query_state()
except (ConnectionError, OSError, TimeoutError) as err:
LOGGER.error("Error connecting to Marantz receiver at %s: %s", port, err)
if receiver.connected:
await receiver.disconnect()
raise ConfigEntryNotReady from err
entry.runtime_data = receiver
@callback
def _on_disconnect(
state: V2015ReceiverState | V2007ReceiverState | V2003ReceiverState | None,
) -> None:
# Only reload if the entry is still loaded. During entry removal,
# disconnect() fires this callback but the entry is already gone.
if state is None and entry.state is ConfigEntryState.LOADED:
LOGGER.warning("Marantz receiver disconnected, reloading config entry")
hass.config_entries.async_schedule_reload(entry.entry_id)
entry.async_on_unload(receiver.subscribe(_on_disconnect))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: MarantzRS232ConfigEntry
) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
await entry.runtime_data.disconnect()
return unload_ok
@@ -1,122 +0,0 @@
"""Config flow for the Marantz RS-232 integration."""
from typing import Any
from marantz_rs232 import (
MarantzV2003Receiver,
MarantzV2007Receiver,
MarantzV2015Receiver,
V2007Model,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE, CONF_MODEL
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
SerialPortSelector,
)
from .const import DOMAIN, LOGGER
MODEL_MODERN = "modern"
MODEL_SR7002 = "sr7002"
MODEL_SR8002 = "sr8002"
MODEL_SR9300 = "sr9300"
MODEL_SR8300 = "sr8300"
MODEL_NAMES: dict[str, str] = {
MODEL_MODERN: "Modern",
MODEL_SR7002: "SR7002",
MODEL_SR8002: "SR8002",
MODEL_SR9300: "SR9300",
MODEL_SR8300: "SR8300",
}
V2007_MODELS: dict[str, V2007Model] = {
MODEL_SR7002: V2007Model.SR7002,
MODEL_SR8002: V2007Model.SR8002,
}
V2003_MODELS = frozenset({MODEL_SR9300, MODEL_SR8300})
async def _async_attempt_connect(port: str, model_key: str) -> str | None:
"""Attempt to connect to the receiver at the given port.
Returns None on success, error on failure.
"""
receiver: MarantzV2015Receiver | MarantzV2007Receiver | MarantzV2003Receiver
if model_key == MODEL_MODERN:
receiver = MarantzV2015Receiver(port)
elif model_key in V2003_MODELS:
receiver = MarantzV2003Receiver(port)
else:
receiver = MarantzV2007Receiver(port, model=V2007_MODELS[model_key])
try:
await receiver.connect()
except (
# When the port contains invalid connection data
ValueError,
# If it is a remote port, and we cannot connect
ConnectionError,
OSError,
TimeoutError,
):
return "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
return "unknown"
else:
await receiver.disconnect()
return None
class MarantzRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Marantz RS-232."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
model_key = user_input[CONF_MODEL]
self._async_abort_entries_match({CONF_DEVICE: user_input[CONF_DEVICE]})
error = await _async_attempt_connect(user_input[CONF_DEVICE], model_key)
if not error:
return self.async_create_entry(
title=MODEL_NAMES[model_key],
data={
CONF_DEVICE: user_input[CONF_DEVICE],
CONF_MODEL: model_key,
},
)
errors["base"] = error
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_MODEL, default=MODEL_MODERN): SelectSelector(
SelectSelectorConfig(
options=list(MODEL_NAMES),
mode=SelectSelectorMode.DROPDOWN,
translation_key="model",
)
),
vol.Required(CONF_DEVICE): SerialPortSelector(),
}
),
user_input or {},
),
errors=errors,
)
@@ -1,19 +0,0 @@
"""Constants for the Marantz RS-232 integration."""
import logging
from marantz_rs232 import (
MarantzV2003Receiver,
MarantzV2007Receiver,
MarantzV2015Receiver,
)
from homeassistant.config_entries import ConfigEntry
LOGGER = logging.getLogger(__package__)
DOMAIN = "marantz_rs232"
type MarantzReceiver = (
MarantzV2015Receiver | MarantzV2007Receiver | MarantzV2003Receiver
)
type MarantzRS232ConfigEntry = ConfigEntry[MarantzReceiver]
@@ -1,13 +0,0 @@
{
"domain": "marantz_rs232",
"name": "Marantz RS-232",
"codeowners": ["@balloob"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/marantz_rs232",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["marantz_rs232"],
"quality_scale": "bronze",
"requirements": ["marantz-rs232==2.0.0"]
}
@@ -1,567 +0,0 @@
"""Media player platform for the Marantz RS-232 integration."""
import math
from typing import cast
from marantz_rs232 import (
V2015_MIN_VOLUME_DB,
V2015_VOLUME_DB_RANGE,
MarantzV2003Receiver,
MarantzV2007Receiver,
MarantzV2015Receiver,
V2003MainPlayer,
V2003MultiRoomPlayer,
V2003ReceiverState,
V2003Source,
V2007MainPlayer,
V2007MultiRoomPlayer,
V2007ReceiverState,
V2007Source,
V2015InputSource,
V2015MainPlayer,
V2015ReceiverState,
V2015ZonePlayer,
)
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .config_flow import MODEL_NAMES
from .const import DOMAIN, MarantzRS232ConfigEntry
V2003_MIN_VOLUME_DB = -90.0
V2003_VOLUME_DB_RANGE = 189.0 # -90..+99
INPUT_SOURCE_V2015_TO_HA: dict[V2015InputSource, str] = {
V2015InputSource.PHONO: "phono",
V2015InputSource.CD: "cd",
V2015InputSource.TUNER: "tuner",
V2015InputSource.DVD: "dvd",
V2015InputSource.BD: "bd",
V2015InputSource.TV: "tv",
V2015InputSource.SAT_CBL: "sat_cbl",
V2015InputSource.SAT: "sat",
V2015InputSource.MPLAY: "mplay",
V2015InputSource.VCR: "vcr",
V2015InputSource.GAME: "game",
V2015InputSource.V_AUX: "v_aux",
V2015InputSource.HDRADIO: "hdradio",
V2015InputSource.SIRIUS: "sirius",
V2015InputSource.SPOTIFY: "spotify",
V2015InputSource.SIRIUSXM: "siriusxm",
V2015InputSource.RHAPSODY: "rhapsody",
V2015InputSource.PANDORA: "pandora",
V2015InputSource.NAPSTER: "napster",
V2015InputSource.LASTFM: "lastfm",
V2015InputSource.FLICKR: "flickr",
V2015InputSource.IRADIO: "iradio",
V2015InputSource.SERVER: "server",
V2015InputSource.FAVORITES: "favorites",
V2015InputSource.CDR: "cdr",
V2015InputSource.AUX1: "aux1",
V2015InputSource.AUX2: "aux2",
V2015InputSource.AUX3: "aux3",
V2015InputSource.AUX4: "aux4",
V2015InputSource.AUX5: "aux5",
V2015InputSource.AUX6: "aux6",
V2015InputSource.AUX7: "aux7",
V2015InputSource.NET: "net",
V2015InputSource.NET_USB: "net_usb",
V2015InputSource.BT: "bt",
V2015InputSource.M_XPORT: "m_xport",
V2015InputSource.USB_IPOD: "usb_ipod",
}
INPUT_SOURCE_V2007_TO_HA: dict[V2007Source, str] = {
V2007Source.TV: "tv",
V2007Source.DVD: "dvd",
V2007Source.VCR1: "vcr1",
V2007Source.DSS_VCR2: "dss_vcr2",
V2007Source.AUX1: "aux1",
V2007Source.AUX2: "aux2",
V2007Source.CD_CDR: "cd_cdr",
V2007Source.TAPE: "tape",
V2007Source.TUNER1: "tuner",
V2007Source.FM1: "fm",
V2007Source.AM1: "am",
V2007Source.XM1: "xm",
}
INPUT_SOURCE_V2003_TO_HA: dict[V2003Source, str] = {
V2003Source.DSS: "dss",
V2003Source.TV: "tv",
V2003Source.LD: "ld",
V2003Source.DVD: "dvd",
V2003Source.VCR1: "vcr1",
V2003Source.VCR2_DVDR: "vcr2_dvdr",
V2003Source.AUX1: "aux1",
V2003Source.AUX2: "aux2",
V2003Source.DVDR: "dvdr",
V2003Source.CD: "cd",
V2003Source.TAPE: "tape",
V2003Source.CDR: "cdr",
V2003Source.FM: "fm",
V2003Source.AM: "am",
V2003Source.MW: "mw",
V2003Source.LW: "lw",
V2003Source.TUNER: "tuner",
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MarantzRS232ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Marantz RS-232 media player."""
receiver = config_entry.runtime_data
entities: list[MediaPlayerEntity]
if isinstance(receiver, MarantzV2015Receiver):
entities = [
MarantzV2015MediaPlayer(receiver, receiver.main, config_entry, "main")
]
if receiver.zone_2.power is not None:
entities.append(
MarantzV2015MediaPlayer(
receiver, receiver.zone_2, config_entry, "zone_2"
)
)
if receiver.zone_3.power is not None:
entities.append(
MarantzV2015MediaPlayer(
receiver, receiver.zone_3, config_entry, "zone_3"
)
)
elif isinstance(receiver, MarantzV2003Receiver):
entities = [
MarantzV2003MediaPlayer(receiver, receiver.main, config_entry, "main")
]
if receiver.multi_room.power is not None:
entities.append(
MarantzV2003MediaPlayer(
receiver, receiver.multi_room, config_entry, "multi_room"
)
)
else:
entities = [
MarantzV2007MediaPlayer(receiver, receiver.main, config_entry, "main")
]
if receiver.multi_room_a.power is not None:
entities.append(
MarantzV2007MediaPlayer(
receiver, receiver.multi_room_a, config_entry, "multi_room_a"
)
)
async_add_entities(entities)
class MarantzV2015MediaPlayer(MediaPlayerEntity):
"""Representation of a modern Marantz receiver controlled over RS-232."""
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_has_entity_name = True
_attr_translation_key = "receiver"
_attr_should_poll = False
_volume_min = V2015_MIN_VOLUME_DB
_volume_range = V2015_VOLUME_DB_RANGE
def __init__(
self,
receiver: MarantzV2015Receiver,
player: V2015MainPlayer | V2015ZonePlayer,
config_entry: MarantzRS232ConfigEntry,
zone: str,
) -> None:
"""Initialize the media player."""
self._receiver = receiver
self._player = player
self._is_main = zone == "main"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="Marantz",
model_id=MODEL_NAMES.get(config_entry.data["model"]),
)
self._attr_unique_id = f"{config_entry.entry_id}_{zone}"
self._attr_source_list = sorted(INPUT_SOURCE_V2015_TO_HA.values())
self._attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.SELECT_SOURCE
)
if zone == "main":
self._attr_name = None
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
elif zone == "zone_2":
self._attr_name = "Zone 2"
else:
self._attr_name = "Zone 3"
self._async_update_from_player()
async def async_added_to_hass(self) -> None:
"""Subscribe to receiver state updates."""
self.async_on_remove(self._receiver.subscribe(self._async_on_state_update))
@callback
def _async_on_state_update(self, state: V2015ReceiverState | None) -> None:
if state is None:
self._attr_available = False
else:
self._attr_available = True
self._async_update_from_player()
self.async_write_ha_state()
@callback
def _async_update_from_player(self) -> None:
if self._player.power is None:
self._attr_state = None
else:
self._attr_state = (
MediaPlayerState.ON if self._player.power else MediaPlayerState.OFF
)
source = self._player.input_source
self._attr_source = INPUT_SOURCE_V2015_TO_HA.get(source) if source else None
volume_min = self._player.volume_min
volume_max = self._player.volume_max
if volume_min is not None:
self._volume_min = volume_min
if volume_max is not None and volume_max > volume_min:
self._volume_range = volume_max - volume_min
volume = self._player.volume
if volume is not None:
self._attr_volume_level = (volume - self._volume_min) / self._volume_range
else:
self._attr_volume_level = None
if self._is_main:
self._attr_is_volume_muted = cast(V2015MainPlayer, self._player).mute
async def async_turn_on(self) -> None:
"""Turn the receiver on."""
await self._player.power_on()
async def async_turn_off(self) -> None:
"""Turn the receiver off."""
await self._player.power_off()
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
db = volume * self._volume_range + self._volume_min
await self._player.set_volume(db)
async def async_volume_up(self) -> None:
"""Volume up."""
await self._player.volume_up()
async def async_volume_down(self) -> None:
"""Volume down."""
await self._player.volume_down()
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute."""
player = cast(V2015MainPlayer, self._player)
if mute:
await player.mute_on()
else:
await player.mute_off()
async def async_select_source(self, source: str) -> None:
"""Select input source."""
input_source = next(
(
input_source
for input_source, ha_source in INPUT_SOURCE_V2015_TO_HA.items()
if ha_source == source
),
None,
)
if input_source is None:
raise HomeAssistantError("Invalid source")
await self._player.select_source(input_source)
class MarantzV2007MediaPlayer(MediaPlayerEntity):
"""Representation of a 2007-era Marantz receiver controlled over RS-232."""
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_has_entity_name = True
_attr_translation_key = "receiver"
_attr_should_poll = False
_volume_min = V2015_MIN_VOLUME_DB
_volume_range = V2015_VOLUME_DB_RANGE
def __init__(
self,
receiver: MarantzV2007Receiver,
player: V2007MainPlayer | V2007MultiRoomPlayer,
config_entry: MarantzRS232ConfigEntry,
zone: str,
) -> None:
"""Initialize the v2007 media player."""
self._receiver = receiver
self._player = player
if isinstance(player, V2007MainPlayer):
self._set_volume = player.set_volume
self._volume_up = player.volume_up
self._volume_down = player.volume_down
else:
self._set_volume = player.set_line_volume
self._volume_up = player.line_volume_up
self._volume_down = player.line_volume_down
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="Marantz",
model_id=MODEL_NAMES.get(config_entry.data["model"]),
)
self._attr_unique_id = f"{config_entry.entry_id}_{zone}"
self._attr_source_list = sorted(INPUT_SOURCE_V2007_TO_HA.values())
self._attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.SELECT_SOURCE
)
if zone == "main":
self._attr_name = None
else:
self._attr_name = "Multi Room"
self._async_update_from_player()
async def async_added_to_hass(self) -> None:
"""Subscribe to receiver state updates."""
self.async_on_remove(self._receiver.subscribe(self._async_on_state_update))
@callback
def _async_on_state_update(self, state: V2007ReceiverState | None) -> None:
if state is None:
self._attr_available = False
else:
self._attr_available = True
self._async_update_from_player()
self.async_write_ha_state()
@callback
def _async_update_from_player(self) -> None:
if self._player.power is None:
self._attr_state = None
else:
self._attr_state = (
MediaPlayerState.ON if self._player.power else MediaPlayerState.OFF
)
source = self._player.input_source
self._attr_source = INPUT_SOURCE_V2007_TO_HA.get(source) if source else None
if isinstance(self._player, V2007MainPlayer):
volume = self._player.volume
else:
volume = self._player.line_volume
if volume is not None:
self._attr_volume_level = (volume - self._volume_min) / self._volume_range
else:
self._attr_volume_level = None
self._attr_is_volume_muted = self._player.mute
async def async_turn_on(self) -> None:
"""Turn the receiver on."""
await self._player.power_on()
async def async_turn_off(self) -> None:
"""Turn the receiver off."""
await self._player.power_off()
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
db = volume * self._volume_range + self._volume_min
await self._set_volume(db)
async def async_volume_up(self) -> None:
"""Volume up."""
await self._volume_up()
async def async_volume_down(self) -> None:
"""Volume down."""
await self._volume_down()
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute."""
if mute:
await self._player.mute_on()
else:
await self._player.mute_off()
async def async_select_source(self, source: str) -> None:
"""Select input source."""
v2007_source = next(
(
ls
for ls, ha_source in INPUT_SOURCE_V2007_TO_HA.items()
if ha_source == source
),
None,
)
if v2007_source is None:
raise HomeAssistantError("Invalid source")
await self._player.select_source(v2007_source)
class MarantzV2003MediaPlayer(MediaPlayerEntity):
"""Representation of a 2003-era Marantz receiver controlled over RS-232."""
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_has_entity_name = True
_attr_translation_key = "receiver"
_attr_should_poll = False
_volume_min = V2003_MIN_VOLUME_DB
_volume_range = V2003_VOLUME_DB_RANGE
def __init__(
self,
receiver: MarantzV2003Receiver,
player: V2003MainPlayer | V2003MultiRoomPlayer,
config_entry: MarantzRS232ConfigEntry,
zone: str,
) -> None:
"""Initialize the v2003 media player."""
self._receiver = receiver
self._player = player
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="Marantz",
model_id=MODEL_NAMES.get(config_entry.data["model"]),
)
self._attr_unique_id = f"{config_entry.entry_id}_{zone}"
self._attr_source_list = sorted(INPUT_SOURCE_V2003_TO_HA.values())
if zone == "main":
self._attr_name = None
self._attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.SELECT_SOURCE
)
else:
self._attr_name = "Multi Room"
self._attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.SELECT_SOURCE
)
self._async_update_from_player()
async def async_added_to_hass(self) -> None:
"""Subscribe to receiver state updates."""
self.async_on_remove(self._receiver.subscribe(self._async_on_state_update))
@callback
def _async_on_state_update(self, state: V2003ReceiverState | None) -> None:
if state is None:
self._attr_available = False
else:
self._attr_available = True
self._async_update_from_player()
self.async_write_ha_state()
@callback
def _async_update_from_player(self) -> None:
power = self._player.power
if power is None:
self._attr_state = None
else:
self._attr_state = MediaPlayerState.ON if power else MediaPlayerState.OFF
source = self._player.input_source
self._attr_source = (
INPUT_SOURCE_V2003_TO_HA.get(source) if source is not None else None
)
volume = self._player.volume
if volume is not None and volume != -math.inf:
self._attr_volume_level = (volume - self._volume_min) / self._volume_range
else:
self._attr_volume_level = None
if isinstance(self._player, V2003MainPlayer):
self._attr_is_volume_muted = self._player.mute
async def async_turn_on(self) -> None:
"""Turn the receiver on."""
await self._player.power_on()
async def async_turn_off(self) -> None:
"""Turn the receiver off."""
await self._player.power_off()
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1. Main zone only."""
db = round(volume * self._volume_range + self._volume_min)
await cast(V2003MainPlayer, self._player).set_volume(db)
async def async_volume_up(self) -> None:
"""Volume up."""
await self._player.volume_up()
async def async_volume_down(self) -> None:
"""Volume down."""
await self._player.volume_down()
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute. Main zone only."""
player = cast(V2003MainPlayer, self._player)
if mute:
await player.mute_on()
else:
await player.mute_off()
async def async_select_source(self, source: str) -> None:
"""Select input source."""
v2003_source = next(
(
vs
for vs, ha_source in INPUT_SOURCE_V2003_TO_HA.items()
if ha_source == source
),
None,
)
if v2003_source is None:
raise HomeAssistantError("Invalid source")
await self._player.select_source(v2003_source)
@@ -1,64 +0,0 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: "The integration does not create dynamic devices."
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: exempt
comment: "The integration does not create devices that can become stale."
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo
@@ -1,96 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"device": "[%key:common::config_flow::data::port%]",
"model": "Receiver model"
},
"data_description": {
"device": "Serial port path to connect to",
"model": "Determines the protocol used to communicate with the receiver"
}
}
}
},
"entity": {
"media_player": {
"receiver": {
"state_attributes": {
"source": {
"state": {
"am": "AM",
"aux1": "Aux 1",
"aux2": "Aux 2",
"aux3": "Aux 3",
"aux4": "Aux 4",
"aux5": "Aux 5",
"aux6": "Aux 6",
"aux7": "Aux 7",
"bd": "BD Player",
"bt": "Bluetooth",
"cd": "CD",
"cd_cdr": "CD/CDR",
"cdr": "CDR",
"dss": "DSS",
"dss_vcr2": "DSS/VCR 2",
"dvd": "DVD",
"dvdr": "DVDR",
"favorites": "Favorites",
"flickr": "Flickr",
"fm": "FM",
"game": "Game",
"hdradio": "HD Radio",
"iradio": "Internet Radio",
"lastfm": "Last.fm",
"ld": "LaserDisc",
"lw": "LW",
"m_xport": "M-XPort",
"mplay": "Media Player",
"mw": "MW",
"napster": "Napster",
"net": "Network",
"net_usb": "Network/USB",
"pandora": "Pandora",
"phono": "Phono",
"rhapsody": "Rhapsody",
"sat": "Sat",
"sat_cbl": "Satellite/Cable",
"server": "Server",
"sirius": "Sirius",
"siriusxm": "SiriusXM",
"spotify": "Spotify",
"tape": "Tape",
"tuner": "Tuner",
"tv": "TV Audio",
"usb_ipod": "USB/iPod",
"v_aux": "V. Aux",
"vcr": "VCR",
"vcr1": "VCR 1",
"vcr2_dvdr": "VCR 2/DVDR",
"xm": "XM"
}
}
}
}
}
},
"selector": {
"model": {
"options": {
"modern": "Modern",
"sr7002": "SR7002",
"sr8002": "SR8002",
"sr8300": "SR8300",
"sr9300": "SR9300"
}
}
}
}
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "platinum",
"requirements": ["opower==0.18.5"]
"requirements": ["opower==0.18.4"]
}
+15 -19
View File
@@ -308,17 +308,17 @@ class Events(Base):
def from_event(event: Event) -> Events:
"""Create an event database object from a native event."""
context = event.context
# The unused legacy columns (event_type, event_data, time_fired,
# context_id, context_user_id, context_parent_id) are nullable with no
# default, so they are intentionally left unset here. Assigning them
# None would still insert NULL, but each assignment goes through
# SQLAlchemy's instrumented attribute machinery, which is a measurable
# cost when run for every recorded event.
return Events(
event_type=None,
event_data=None,
origin_idx=event.origin.idx,
time_fired=None,
time_fired_ts=event.time_fired_timestamp,
context_id=None,
context_id_bin=ulid_to_bytes_or_none(context.id),
context_user_id=None,
context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id),
context_parent_id=None,
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
)
@@ -491,18 +491,19 @@ class States(Base):
else:
last_reported_ts = state.last_reported_timestamp
context = event.context
# The unused legacy columns (entity_id, attributes, context_id,
# context_user_id, context_parent_id, last_updated, last_changed) are
# nullable with no default, so they are intentionally left unset here.
# Assigning them None would still insert NULL, but each assignment goes
# through SQLAlchemy's instrumented attribute machinery, which is a
# measurable cost when run for every recorded state change.
return States(
state=state_value,
entity_id=None,
attributes=None,
context_id=None,
context_id_bin=ulid_to_bytes_or_none(context.id),
context_user_id=None,
context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id),
context_parent_id=None,
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
origin_idx=event.origin.idx,
last_updated=None,
last_changed=None,
last_updated_ts=last_updated_ts,
last_changed_ts=last_changed_ts,
last_reported_ts=last_reported_ts,
@@ -559,13 +560,8 @@ class StateAttributes(Base):
# None state means the state was removed from the state machine
if (state := event.data["new_state"]) is None:
return b"{}"
if (state_info := state.state_info) and (
unrecorded_attributes := state_info["unrecorded_attributes"]
):
# The entity has unrecorded attributes, so a combined exclude set
# has to be built. The common case (no unrecorded attributes) falls
# through to the shared constant below without allocating a set per
# recorded state change.
if state_info := state.state_info:
unrecorded_attributes = state_info["unrecorded_attributes"]
exclude_attrs = {
*ALL_DOMAIN_EXCLUDE_ATTRS,
*unrecorded_attributes,
+7 -6
View File
@@ -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}"
)
+2 -3
View File
@@ -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(
+2 -2
View File
@@ -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:
+2 -7
View File
@@ -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(
+1 -9
View File
@@ -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
+1 -10
View File
@@ -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
View File
@@ -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"
)
-1
View File
@@ -440,7 +440,6 @@ FLOWS = {
"madvr",
"mailgun",
"marantz_infrared",
"marantz_rs232",
"mastodon",
"matter",
"mcp",
@@ -4100,12 +4100,6 @@
}
}
},
"marantz_rs232": {
"name": "Marantz RS-232",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
},
"martec": {
"name": "Martec",
"integration_type": "virtual",
+10 -18
View File
@@ -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.
+13 -57
View File
@@ -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:
+51 -126
View File
@@ -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
+1 -3
View File
@@ -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__)
+21 -19
View File
@@ -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,
}
Generated
-10
View File
@@ -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
+2 -5
View File
@@ -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
@@ -1527,9 +1527,6 @@ lw12==0.9.2
# homeassistant.components.scrape
lxml==6.1.1
# homeassistant.components.marantz_rs232
marantz-rs232==2.0.0
# homeassistant.components.matrix
matrix-nio==0.25.2
@@ -1791,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
@@ -639,7 +639,7 @@
}),
}),
'entry_data': dict({
'additional_settings': dict({
'advanced_settings': dict({
'ssl': True,
'verify_ssl': False,
}),
+14 -14
View File
@@ -21,7 +21,7 @@ from homeassistant.components.airos.const import (
HOSTNAME,
IP_ADDRESS,
MAC_ADDRESS,
SECTION_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS,
)
from homeassistant.config_entries import (
SOURCE_DHCP,
@@ -48,7 +48,7 @@ NEW_PASSWORD = "new_password"
REAUTH_STEP = "reauth_confirm"
RECONFIGURE_STEP = "reconfigure"
MOCK_ADDITIONAL_SETTINGS = {
MOCK_ADVANCED_SETTINGS = {
CONF_SSL: True,
CONF_VERIFY_SSL: False,
}
@@ -57,7 +57,7 @@ MOCK_CONFIG = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
}
MOCK_CONFIG_REAUTH = {
CONF_HOST: "1.1.1.1",
@@ -410,7 +410,7 @@ async def test_successful_reconfigure(
user_input = {
CONF_PASSWORD: NEW_PASSWORD,
SECTION_ADDITIONAL_SETTINGS: {
SECTION_ADVANCED_SETTINGS: {
CONF_SSL: True,
CONF_VERIFY_SSL: True,
},
@@ -426,8 +426,8 @@ async def test_successful_reconfigure(
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD
assert updated_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL] is True
assert updated_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL] is True
assert updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True
assert updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is True
assert updated_entry.data[CONF_HOST] == MOCK_CONFIG[CONF_HOST]
assert updated_entry.data[CONF_USERNAME] == MOCK_CONFIG[CONF_USERNAME]
@@ -468,7 +468,7 @@ async def test_reconfigure_flow_failure(
user_input = {
CONF_PASSWORD: NEW_PASSWORD,
SECTION_ADDITIONAL_SETTINGS: {
SECTION_ADVANCED_SETTINGS: {
CONF_SSL: True,
CONF_VERIFY_SSL: True,
},
@@ -525,7 +525,7 @@ async def test_reconfigure_unique_id_mismatch(
user_input = {
CONF_PASSWORD: NEW_PASSWORD,
SECTION_ADDITIONAL_SETTINGS: {
SECTION_ADVANCED_SETTINGS: {
CONF_SSL: True,
CONF_VERIFY_SSL: True,
},
@@ -546,8 +546,8 @@ async def test_reconfigure_unique_id_mismatch(
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert updated_entry.data[CONF_PASSWORD] == MOCK_CONFIG[CONF_PASSWORD]
assert (
updated_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL]
== MOCK_CONFIG[SECTION_ADDITIONAL_SETTINGS][CONF_SSL]
updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
== MOCK_CONFIG[SECTION_ADVANCED_SETTINGS][CONF_SSL]
)
@@ -611,7 +611,7 @@ async def test_discover_flow_one_device_found(
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
@@ -687,7 +687,7 @@ async def test_discover_flow_multiple_devices_found(
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
@@ -785,7 +785,7 @@ async def test_configure_device_flow_exceptions(
{
CONF_USERNAME: "wrong-user",
CONF_PASSWORD: "wrong-password",
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
@@ -801,7 +801,7 @@ async def test_configure_device_flow_exceptions(
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "some-password",
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
+7 -7
View File
@@ -14,7 +14,7 @@ from homeassistant.components.airos.const import (
DEFAULT_SSL,
DEFAULT_VERIFY_SSL,
DOMAIN,
SECTION_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS,
)
from homeassistant.components.airos.coordinator import async_fetch_airos_data
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
@@ -46,7 +46,7 @@ MOCK_CONFIG_PLAIN = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "ubnt",
CONF_PASSWORD: "test-password",
SECTION_ADDITIONAL_SETTINGS: {
SECTION_ADVANCED_SETTINGS: {
CONF_SSL: False,
CONF_VERIFY_SSL: False,
},
@@ -56,7 +56,7 @@ MOCK_CONFIG_V1_2 = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "ubnt",
CONF_PASSWORD: "test-password",
SECTION_ADDITIONAL_SETTINGS: {
SECTION_ADVANCED_SETTINGS: {
CONF_SSL: DEFAULT_SSL,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
},
@@ -86,8 +86,8 @@ async def test_setup_entry_with_default_ssl(
use_ssl=DEFAULT_SSL,
)
assert mock_config_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL] is True
assert mock_config_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL] is False
assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True
assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False
async def test_setup_entry_without_ssl(
@@ -120,8 +120,8 @@ async def test_setup_entry_without_ssl(
use_ssl=False,
)
assert entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL] is False
assert entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL] is False
assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is False
assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False
async def test_ssl_migrate_entry(
@@ -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")
@@ -0,0 +1,251 @@
# serializer version: 1
# name: test_entities[button.edifier_r1700bt_bluetooth-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.edifier_r1700bt_bluetooth',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Bluetooth',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Bluetooth',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'bluetooth',
'unique_id': '01JTEST0000000000000000000_bluetooth',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_bluetooth-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT Bluetooth',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_bluetooth',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.edifier_r1700bt_fx_off-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.edifier_r1700bt_fx_off',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'FX off',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'FX off',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'fx_off',
'unique_id': '01JTEST0000000000000000000_fx_off',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_fx_off-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT FX off',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_fx_off',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.edifier_r1700bt_fx_on-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.edifier_r1700bt_fx_on',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'FX on',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'FX on',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'fx_on',
'unique_id': '01JTEST0000000000000000000_fx_on',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_fx_on-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT FX on',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_fx_on',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.edifier_r1700bt_line_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.edifier_r1700bt_line_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Line 1',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Line 1',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'line_1',
'unique_id': '01JTEST0000000000000000000_line_1',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_line_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT Line 1',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_line_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.edifier_r1700bt_line_2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.edifier_r1700bt_line_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Line 2',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Line 2',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'line_2',
'unique_id': '01JTEST0000000000000000000_line_2',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_line_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT Line 2',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_line_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
@@ -0,0 +1,73 @@
"""Tests for the Edifier Infrared button platform."""
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
from tests.components.common import assert_availability_follows_source_entity
from tests.components.infrared import EMITTER_ENTITY_ID
from tests.components.infrared.common import MockInfraredEmitterEntity
BLUETOOTH_BUTTON_ENTITY_ID = "button.edifier_r1700bt_bluetooth"
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return [Platform.BUTTON]
@pytest.mark.usefixtures("init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the button entities are created with correct attributes."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("entity_id", "expected_code"),
[
("button.edifier_r1700bt_bluetooth", EdifierR1700BTCode.BLUETOOTH),
("button.edifier_r1700bt_line_1", EdifierR1700BTCode.LINE_1),
("button.edifier_r1700bt_line_2", EdifierR1700BTCode.LINE_2),
("button.edifier_r1700bt_fx_on", EdifierR1700BTCode.FX_ON),
("button.edifier_r1700bt_fx_off", EdifierR1700BTCode.FX_OFF),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_button_press_sends_correct_code(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
entity_id: str,
expected_code: EdifierR1700BTCode,
) -> None:
"""Test each button press sends the correct IR code."""
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
assert mock_infrared_emitter_entity.send_command_calls[0] == expected_code
@pytest.mark.usefixtures("init_integration")
async def test_button_availability_follows_ir_entity(
hass: HomeAssistant,
) -> None:
"""Test button becomes unavailable when IR entity is unavailable."""
await assert_availability_follows_source_entity(
hass, BLUETOOTH_BUTTON_ENTITY_ID, EMITTER_ENTITY_ID
)
@@ -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
+1
View File
@@ -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
-42
View File
@@ -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
@@ -1,3 +0,0 @@
"""Tests for the Marantz RS-232 integration."""
MOCK_DEVICE = "/dev/ttyUSB0"
-144
View File
@@ -1,144 +0,0 @@
"""Test fixtures for the Marantz RS-232 integration."""
from unittest.mock import AsyncMock, patch
from marantz_rs232 import (
MarantzV2003Receiver,
MarantzV2007Receiver,
MarantzV2015Receiver,
V2003Source,
V2007Model,
V2007Source,
V2015InputSource,
)
import pytest
from homeassistant.components.marantz_rs232.const import DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, CONF_MODEL
from homeassistant.core import HomeAssistant
from . import MOCK_DEVICE
from tests.common import MockConfigEntry
def push_state(receiver: object) -> None:
"""Notify subscribers using whichever notify API the protocol exposes."""
# The v2003 protocol takes the state explicitly; the others read self._state.
if hasattr(receiver, "_notify_subscribers"):
receiver._notify_subscribers()
else:
receiver._notify(receiver._state if receiver._connected else None)
def _install_async_mocks(receiver: object) -> object:
"""Replace the serial I/O surface with awaitable mocks."""
receiver._connected = True
receiver.connect = AsyncMock()
receiver.query_state = AsyncMock()
receiver._send_command = AsyncMock()
async def _disconnect() -> None:
receiver._connected = False
push_state(receiver)
receiver.disconnect = AsyncMock(side_effect=_disconnect)
return receiver
@pytest.fixture
def mock_v2015_receiver() -> MarantzV2015Receiver:
"""Create a modern (2015+) Marantz receiver test double."""
receiver = MarantzV2015Receiver(MOCK_DEVICE)
main = receiver._state.main_zone
main.power = True
main.volume = -40.0
main.volume_min = -80.0
main.volume_max = 18.0
main.mute = False
main.input_source = V2015InputSource.CD
zone_2 = receiver._state.zone_2
zone_2.power = True
zone_2.volume = -30.0
zone_2.input_source = V2015InputSource.TUNER
zone_3 = receiver._state.zone_3
zone_3.power = False
zone_3.input_source = V2015InputSource.NET
return _install_async_mocks(receiver)
@pytest.fixture
def mock_v2007_receiver() -> MarantzV2007Receiver:
"""Create a 2007-era Marantz receiver test double."""
receiver = MarantzV2007Receiver(MOCK_DEVICE, model=V2007Model.SR7002)
main = receiver._state.main
main.power = True
main.volume = -40.0
main.mute = False
main.source_audio = V2007Source.DVD.value
multi_room = receiver._state.multi_room_a
multi_room.power = True
multi_room.line_volume = -30.0
multi_room.mute = False
multi_room.source_audio = V2007Source.TV.value
return _install_async_mocks(receiver)
@pytest.fixture
def mock_v2003_receiver() -> MarantzV2003Receiver:
"""Create a 2003-era Marantz receiver test double."""
receiver = MarantzV2003Receiver(MOCK_DEVICE)
main = receiver._state.main
main.power = True
main.volume = -40.0
main.mute = False
main.audio_input = V2003Source.CD
multi_room = receiver._state.multi_room
multi_room.enabled = True
multi_room.volume = -30.0
multi_room.audio_input = V2003Source.TUNER
return _install_async_mocks(receiver)
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Create a mock config entry for the modern receiver."""
return MockConfigEntry(
domain=DOMAIN,
data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "modern"},
title="Modern",
entry_id="01KPBBPM6WCQ8148EFR0TCG1WW",
)
@pytest.fixture(autouse=True)
def mock_usb_component(hass: HomeAssistant) -> None:
"""Mock the USB component to prevent setup failures."""
hass.config.components.add("usb")
async def setup_integration(
hass: HomeAssistant,
entry: ConfigEntry,
receiver: object,
receiver_class: str,
) -> None:
"""Set up the integration with a mocked receiver class."""
entry.add_to_hass(hass)
with patch(
f"homeassistant.components.marantz_rs232.{receiver_class}",
return_value=receiver,
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -1,399 +0,0 @@
# serializer version: 1
# name: test_entities_created[media_player.modern-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'source_list': list([
'aux1',
'aux2',
'aux3',
'aux4',
'aux5',
'aux6',
'aux7',
'bd',
'bt',
'cd',
'cdr',
'dvd',
'favorites',
'flickr',
'game',
'hdradio',
'iradio',
'lastfm',
'm_xport',
'mplay',
'napster',
'net',
'net_usb',
'pandora',
'phono',
'rhapsody',
'sat',
'sat_cbl',
'server',
'sirius',
'siriusxm',
'spotify',
'tuner',
'tv',
'usb_ipod',
'v_aux',
'vcr',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.modern',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.RECEIVER: 'receiver'>,
'original_icon': None,
'original_name': None,
'platform': 'marantz_rs232',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 3468>,
'translation_key': 'receiver',
'unique_id': '01KPBBPM6WCQ8148EFR0TCG1WW_main',
'unit_of_measurement': None,
})
# ---
# name: test_entities_created[media_player.modern-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'friendly_name': 'Modern',
'is_volume_muted': False,
'source': 'cd',
'source_list': list([
'aux1',
'aux2',
'aux3',
'aux4',
'aux5',
'aux6',
'aux7',
'bd',
'bt',
'cd',
'cdr',
'dvd',
'favorites',
'flickr',
'game',
'hdradio',
'iradio',
'lastfm',
'm_xport',
'mplay',
'napster',
'net',
'net_usb',
'pandora',
'phono',
'rhapsody',
'sat',
'sat_cbl',
'server',
'sirius',
'siriusxm',
'spotify',
'tuner',
'tv',
'usb_ipod',
'v_aux',
'vcr',
]),
'supported_features': <MediaPlayerEntityFeature: 3468>,
'volume_level': 0.40816326530612246,
}),
'context': <ANY>,
'entity_id': 'media_player.modern',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_entities_created[media_player.modern_zone_2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'source_list': list([
'aux1',
'aux2',
'aux3',
'aux4',
'aux5',
'aux6',
'aux7',
'bd',
'bt',
'cd',
'cdr',
'dvd',
'favorites',
'flickr',
'game',
'hdradio',
'iradio',
'lastfm',
'm_xport',
'mplay',
'napster',
'net',
'net_usb',
'pandora',
'phono',
'rhapsody',
'sat',
'sat_cbl',
'server',
'sirius',
'siriusxm',
'spotify',
'tuner',
'tv',
'usb_ipod',
'v_aux',
'vcr',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.modern_zone_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Zone 2',
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.RECEIVER: 'receiver'>,
'original_icon': None,
'original_name': 'Zone 2',
'platform': 'marantz_rs232',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 3460>,
'translation_key': 'receiver',
'unique_id': '01KPBBPM6WCQ8148EFR0TCG1WW_zone_2',
'unit_of_measurement': None,
})
# ---
# name: test_entities_created[media_player.modern_zone_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'friendly_name': 'Modern Zone 2',
'source': 'tuner',
'source_list': list([
'aux1',
'aux2',
'aux3',
'aux4',
'aux5',
'aux6',
'aux7',
'bd',
'bt',
'cd',
'cdr',
'dvd',
'favorites',
'flickr',
'game',
'hdradio',
'iradio',
'lastfm',
'm_xport',
'mplay',
'napster',
'net',
'net_usb',
'pandora',
'phono',
'rhapsody',
'sat',
'sat_cbl',
'server',
'sirius',
'siriusxm',
'spotify',
'tuner',
'tv',
'usb_ipod',
'v_aux',
'vcr',
]),
'supported_features': <MediaPlayerEntityFeature: 3460>,
'volume_level': 0.5102040816326531,
}),
'context': <ANY>,
'entity_id': 'media_player.modern_zone_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_entities_created[media_player.modern_zone_3-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'source_list': list([
'aux1',
'aux2',
'aux3',
'aux4',
'aux5',
'aux6',
'aux7',
'bd',
'bt',
'cd',
'cdr',
'dvd',
'favorites',
'flickr',
'game',
'hdradio',
'iradio',
'lastfm',
'm_xport',
'mplay',
'napster',
'net',
'net_usb',
'pandora',
'phono',
'rhapsody',
'sat',
'sat_cbl',
'server',
'sirius',
'siriusxm',
'spotify',
'tuner',
'tv',
'usb_ipod',
'v_aux',
'vcr',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.modern_zone_3',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Zone 3',
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.RECEIVER: 'receiver'>,
'original_icon': None,
'original_name': 'Zone 3',
'platform': 'marantz_rs232',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 3460>,
'translation_key': 'receiver',
'unique_id': '01KPBBPM6WCQ8148EFR0TCG1WW_zone_3',
'unit_of_measurement': None,
})
# ---
# name: test_entities_created[media_player.modern_zone_3-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'friendly_name': 'Modern Zone 3',
'source_list': list([
'aux1',
'aux2',
'aux3',
'aux4',
'aux5',
'aux6',
'aux7',
'bd',
'bt',
'cd',
'cdr',
'dvd',
'favorites',
'flickr',
'game',
'hdradio',
'iradio',
'lastfm',
'm_xport',
'mplay',
'napster',
'net',
'net_usb',
'pandora',
'phono',
'rhapsody',
'sat',
'sat_cbl',
'server',
'sirius',
'siriusxm',
'spotify',
'tuner',
'tv',
'usb_ipod',
'v_aux',
'vcr',
]),
'supported_features': <MediaPlayerEntityFeature: 3460>,
}),
'context': <ANY>,
'entity_id': 'media_player.modern_zone_3',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
@@ -1,142 +0,0 @@
"""Tests for the Marantz RS-232 config flow."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.marantz_rs232.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_DEVICE, CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import MOCK_DEVICE
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Prevent config-entry creation tests from setting up the integration."""
with patch(
"homeassistant.components.marantz_rs232.async_setup_entry",
return_value=True,
) as mock_setup:
yield mock_setup
@pytest.mark.parametrize(
("model_key", "title", "receiver_class", "receiver_fixture"),
[
("modern", "Modern", "MarantzV2015Receiver", "mock_v2015_receiver"),
("sr7002", "SR7002", "MarantzV2007Receiver", "mock_v2007_receiver"),
("sr9300", "SR9300", "MarantzV2003Receiver", "mock_v2003_receiver"),
],
)
async def test_user_form_creates_entry(
hass: HomeAssistant,
request: pytest.FixtureRequest,
mock_setup_entry: AsyncMock,
model_key: str,
title: str,
receiver_class: str,
receiver_fixture: str,
) -> None:
"""Test a successful config flow creates an entry for each protocol."""
receiver = request.getfixturevalue(receiver_fixture)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
with patch(
f"homeassistant.components.marantz_rs232.config_flow.{receiver_class}",
return_value=receiver,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: model_key},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == title
assert result["data"] == {CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: model_key}
mock_setup_entry.assert_awaited_once()
receiver.connect.assert_awaited_once()
receiver.disconnect.assert_awaited_once()
@pytest.mark.parametrize(
("exception", "error"),
[
(ValueError("Invalid port"), "cannot_connect"),
(ConnectionError("No response"), "cannot_connect"),
(OSError("No such device"), "cannot_connect"),
(RuntimeError("boom"), "unknown"),
],
)
async def test_user_form_error_recovers(
hass: HomeAssistant,
mock_v2015_receiver: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test the user step reports errors and recovers on retry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_v2015_receiver.connect.side_effect = exception
with patch(
"homeassistant.components.marantz_rs232.config_flow.MarantzV2015Receiver",
return_value=mock_v2015_receiver,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "modern"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": error}
mock_v2015_receiver.connect.side_effect = None
with (
patch(
"homeassistant.components.marantz_rs232.config_flow.MarantzV2015Receiver",
return_value=mock_v2015_receiver,
),
patch(
"homeassistant.components.marantz_rs232.async_setup_entry",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "modern"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_user_duplicate_port_aborts(hass: HomeAssistant) -> None:
"""Test we abort if the same port is already configured."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "modern"},
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "modern"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@@ -1,70 +0,0 @@
"""Tests for the Marantz RS-232 integration setup and teardown."""
from marantz_rs232 import MarantzV2015Receiver
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from .conftest import setup_integration
from tests.common import MockConfigEntry
async def test_setup_and_unload(
hass: HomeAssistant,
mock_v2015_receiver: MarantzV2015Receiver,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test a config entry sets up and unloads cleanly."""
await setup_integration(
hass, mock_config_entry, mock_v2015_receiver, "MarantzV2015Receiver"
)
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_v2015_receiver.connect.assert_awaited_once()
mock_v2015_receiver.query_state.assert_awaited_once()
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
mock_v2015_receiver.disconnect.assert_awaited_once()
async def test_setup_connect_failure_raises_not_ready(
hass: HomeAssistant,
mock_v2015_receiver: MarantzV2015Receiver,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test a connection failure puts the entry in retry state."""
mock_v2015_receiver.connect.side_effect = ConnectionError("No response")
await setup_integration(
hass, mock_config_entry, mock_v2015_receiver, "MarantzV2015Receiver"
)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
mock_v2015_receiver.disconnect.assert_awaited_once()
async def test_remove_entry_while_loaded(
hass: HomeAssistant,
mock_v2015_receiver: MarantzV2015Receiver,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test removing a loaded entry does not schedule a reload.
When removing a loaded entry, disconnect() fires the subscriber callback
with state=None. The callback must not schedule a reload because the entry
is already being removed (state is no longer LOADED).
"""
await setup_integration(
hass, mock_config_entry, mock_v2015_receiver, "MarantzV2015Receiver"
)
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_remove(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
mock_v2015_receiver.disconnect.assert_awaited_once()
@@ -1,376 +0,0 @@
"""Tests for the Marantz RS-232 media player platform."""
from pathlib import Path
from marantz_rs232 import (
MarantzV2003Receiver,
MarantzV2007Receiver,
MarantzV2015Receiver,
V2015InputSource,
)
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.marantz_rs232.const import DOMAIN
from homeassistant.components.marantz_rs232.media_player import (
INPUT_SOURCE_V2003_TO_HA,
INPUT_SOURCE_V2007_TO_HA,
INPUT_SOURCE_V2015_TO_HA,
)
from homeassistant.components.media_player import (
ATTR_INPUT_SOURCE,
ATTR_INPUT_SOURCE_LIST,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
DOMAIN as MP_DOMAIN,
SERVICE_SELECT_SOURCE,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_DEVICE,
CONF_MODEL,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_VOLUME_DOWN,
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.util.json import load_json
from . import MOCK_DEVICE
from .conftest import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
MAIN_ENTITY_ID = "media_player.modern"
ZONE_2_ENTITY_ID = "media_player.modern_zone_2"
ZONE_3_ENTITY_ID = "media_player.modern_zone_3"
STRINGS_PATH = Path("homeassistant/components/marantz_rs232/strings.json")
@pytest.fixture
async def init_v2015(
hass: HomeAssistant,
mock_v2015_receiver: MarantzV2015Receiver,
mock_config_entry: MockConfigEntry,
) -> None:
"""Set up the modern receiver."""
await setup_integration(
hass, mock_config_entry, mock_v2015_receiver, "MarantzV2015Receiver"
)
async def test_entities_created(
hass: HomeAssistant,
mock_v2015_receiver: MarantzV2015Receiver,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
init_v2015: None,
) -> None:
"""Test media player entities are created through config entry setup."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
mock_v2015_receiver.query_state.assert_awaited_once()
async def test_inactive_zone_not_created(
hass: HomeAssistant,
mock_v2015_receiver: MarantzV2015Receiver,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test zones without a queried power state are not created."""
mock_v2015_receiver._state.zone_2.power = None
mock_v2015_receiver._state.zone_3.power = None
await setup_integration(
hass, mock_config_entry, mock_v2015_receiver, "MarantzV2015Receiver"
)
assert hass.states.get(MAIN_ENTITY_ID) is not None
assert hass.states.get(ZONE_2_ENTITY_ID) is None
assert hass.states.get(ZONE_3_ENTITY_ID) is None
async def test_state_update_and_unavailable(
hass: HomeAssistant,
mock_v2015_receiver: MarantzV2015Receiver,
init_v2015: None,
) -> None:
"""Test the entity follows pushed state and goes unavailable on disconnect."""
assert hass.states.get(MAIN_ENTITY_ID).state == STATE_ON
mock_v2015_receiver._state.main_zone.power = False
mock_v2015_receiver._notify_subscribers()
await hass.async_block_till_done()
assert hass.states.get(MAIN_ENTITY_ID).state == STATE_OFF
mock_v2015_receiver._connected = False
mock_v2015_receiver._notify_subscribers()
await hass.async_block_till_done()
assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
("entity_id", "service", "data", "expected"),
[
(MAIN_ENTITY_ID, SERVICE_TURN_ON, {}, ("ZM", "ON")),
(MAIN_ENTITY_ID, SERVICE_TURN_OFF, {}, ("ZM", "OFF")),
(MAIN_ENTITY_ID, SERVICE_VOLUME_UP, {}, ("MV", "UP")),
(MAIN_ENTITY_ID, SERVICE_VOLUME_DOWN, {}, ("MV", "DOWN")),
(
MAIN_ENTITY_ID,
SERVICE_VOLUME_MUTE,
{ATTR_MEDIA_VOLUME_MUTED: True},
("MU", "ON"),
),
(
MAIN_ENTITY_ID,
SERVICE_VOLUME_MUTE,
{ATTR_MEDIA_VOLUME_MUTED: False},
("MU", "OFF"),
),
(
MAIN_ENTITY_ID,
SERVICE_SELECT_SOURCE,
{ATTR_INPUT_SOURCE: "net"},
("SI", V2015InputSource.NET.value),
),
(ZONE_2_ENTITY_ID, SERVICE_TURN_ON, {}, ("Z2", "ON")),
(
ZONE_2_ENTITY_ID,
SERVICE_SELECT_SOURCE,
{ATTR_INPUT_SOURCE: "cd"},
("Z2", V2015InputSource.CD.value),
),
],
)
async def test_v2015_commands(
hass: HomeAssistant,
mock_v2015_receiver: MarantzV2015Receiver,
init_v2015: None,
entity_id: str,
service: str,
data: dict[str, str | bool],
expected: tuple[str, str],
) -> None:
"""Test media player services send the expected serial commands."""
await hass.services.async_call(
MP_DOMAIN,
service,
{ATTR_ENTITY_ID: entity_id, **data},
blocking=True,
)
mock_v2015_receiver._send_command.assert_awaited_with(*expected)
async def test_v2015_volume_set(
hass: HomeAssistant,
mock_v2015_receiver: MarantzV2015Receiver,
init_v2015: None,
) -> None:
"""Test setting the volume level sends a volume command."""
state = hass.states.get(MAIN_ENTITY_ID)
# volume -40 in range [-80, 18] -> (-40 - -80) / 98
assert abs(state.attributes[ATTR_MEDIA_VOLUME_LEVEL] - (40 / 98)) < 0.001
await hass.services.async_call(
MP_DOMAIN,
SERVICE_VOLUME_SET,
{ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
blocking=True,
)
assert mock_v2015_receiver._send_command.await_args.args[0] == "MV"
async def test_invalid_source_raises(
hass: HomeAssistant,
init_v2015: None,
) -> None:
"""Test selecting an unknown source raises an error."""
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
MP_DOMAIN,
SERVICE_SELECT_SOURCE,
{ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_INPUT_SOURCE: "nonexistent"},
blocking=True,
)
async def test_v2007_entities(
hass: HomeAssistant,
mock_v2007_receiver: MarantzV2007Receiver,
) -> None:
"""Test a 2007-era receiver creates main and multi-room entities."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "sr7002"},
title="SR7002",
)
await setup_integration(hass, entry, mock_v2007_receiver, "MarantzV2007Receiver")
main = hass.states.get("media_player.sr7002")
assert main.state == STATE_ON
assert main.attributes[ATTR_INPUT_SOURCE] == "dvd"
assert hass.states.get("media_player.sr7002_multi_room") is not None
await hass.services.async_call(
MP_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "media_player.sr7002"},
blocking=True,
)
assert mock_v2007_receiver._send_command.await_args.args[0] == "PWR"
await hass.services.async_call(
MP_DOMAIN,
SERVICE_SELECT_SOURCE,
{ATTR_ENTITY_ID: "media_player.sr7002", ATTR_INPUT_SOURCE: "tv"},
blocking=True,
)
assert mock_v2007_receiver._send_command.await_args.args[0] == "SRC"
for service, data in (
(SERVICE_TURN_OFF, {}),
(SERVICE_VOLUME_UP, {}),
(SERVICE_VOLUME_DOWN, {}),
(SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 0.5}),
(SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: True}),
(SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: False}),
):
await hass.services.async_call(
MP_DOMAIN,
service,
{ATTR_ENTITY_ID: "media_player.sr7002", **data},
blocking=True,
)
# Exercise the multi-room volume/mute/source paths.
for service, data in (
(SERVICE_TURN_ON, {}),
(SERVICE_VOLUME_UP, {}),
(SERVICE_VOLUME_DOWN, {}),
(SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 0.4}),
(SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: True}),
(SERVICE_SELECT_SOURCE, {ATTR_INPUT_SOURCE: "dvd"}),
):
await hass.services.async_call(
MP_DOMAIN,
service,
{ATTR_ENTITY_ID: "media_player.sr7002_multi_room", **data},
blocking=True,
)
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
MP_DOMAIN,
SERVICE_SELECT_SOURCE,
{ATTR_ENTITY_ID: "media_player.sr7002", ATTR_INPUT_SOURCE: "nope"},
blocking=True,
)
mock_v2007_receiver._state.main.power = False
mock_v2007_receiver._notify_subscribers()
await hass.async_block_till_done()
assert hass.states.get("media_player.sr7002").state == STATE_OFF
mock_v2007_receiver._connected = False
mock_v2007_receiver._notify_subscribers()
await hass.async_block_till_done()
assert hass.states.get("media_player.sr7002").state == STATE_UNAVAILABLE
async def test_v2003_entities(
hass: HomeAssistant,
mock_v2003_receiver: MarantzV2003Receiver,
) -> None:
"""Test a 2003-era receiver creates main and multi-room entities."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "sr9300"},
title="SR9300",
)
await setup_integration(hass, entry, mock_v2003_receiver, "MarantzV2003Receiver")
main = hass.states.get("media_player.sr9300")
assert main.state == STATE_ON
assert main.attributes[ATTR_INPUT_SOURCE] == "cd"
multi_room = hass.states.get("media_player.sr9300_multi_room")
assert multi_room is not None
assert ATTR_INPUT_SOURCE_LIST in multi_room.attributes
for service, data in (
(SERVICE_TURN_ON, {}),
(SERVICE_TURN_OFF, {}),
(SERVICE_VOLUME_UP, {}),
(SERVICE_VOLUME_DOWN, {}),
(SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 0.5}),
(SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: True}),
(SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: False}),
(SERVICE_SELECT_SOURCE, {ATTR_INPUT_SOURCE: "dvd"}),
):
await hass.services.async_call(
MP_DOMAIN,
service,
{ATTR_ENTITY_ID: "media_player.sr9300", **data},
blocking=True,
)
mock_v2003_receiver._send_command.assert_awaited()
# Exercise multi-room power/volume/source paths.
for service, data in (
(SERVICE_TURN_ON, {}),
(SERVICE_TURN_OFF, {}),
(SERVICE_VOLUME_UP, {}),
(SERVICE_VOLUME_DOWN, {}),
(SERVICE_SELECT_SOURCE, {ATTR_INPUT_SOURCE: "tuner"}),
):
await hass.services.async_call(
MP_DOMAIN,
service,
{ATTR_ENTITY_ID: "media_player.sr9300_multi_room", **data},
blocking=True,
)
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
MP_DOMAIN,
SERVICE_SELECT_SOURCE,
{ATTR_ENTITY_ID: "media_player.sr9300", ATTR_INPUT_SOURCE: "nope"},
blocking=True,
)
mock_v2003_receiver._state.main.power = False
mock_v2003_receiver._notify(mock_v2003_receiver._state)
await hass.async_block_till_done()
assert hass.states.get("media_player.sr9300").state == STATE_OFF
mock_v2003_receiver._connected = False
mock_v2003_receiver._notify(None)
await hass.async_block_till_done()
assert hass.states.get("media_player.sr9300").state == STATE_UNAVAILABLE
def test_translation_keys_cover_all_sources() -> None:
"""Test every mapped source has a matching translation key and vice versa."""
mapped = (
set(INPUT_SOURCE_V2015_TO_HA.values())
| set(INPUT_SOURCE_V2007_TO_HA.values())
| set(INPUT_SOURCE_V2003_TO_HA.values())
)
strings = load_json(STRINGS_PATH)
declared = set(
strings["entity"]["media_player"]["receiver"]["state_attributes"]["source"][
"state"
]
)
assert mapped == declared
+2 -2
View File
@@ -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")
+6 -3
View File
@@ -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,
})
# ---
-64
View File
@@ -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 -3
View File
@@ -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)
-15
View File
@@ -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,
})
# ---
-59
View File
@@ -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 -28
View File
@@ -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,
}),
}),
}),
})
# ---
-29
View File
@@ -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"))
+30 -75
View File
@@ -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"
+12 -30
View File
@@ -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
View File
@@ -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 offon 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()
+4 -40
View File
@@ -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",
),
+40 -38
View File
@@ -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: [