mirror of
https://github.com/home-assistant/core.git
synced 2026-06-17 17:32:51 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2f11d25e1 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,7 +8,7 @@ from renson_endura_delta.field_enum import (
|
||||
)
|
||||
from renson_endura_delta.renson import RensonVentilation
|
||||
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -24,11 +24,10 @@ class RensonEntity(CoordinatorEntity[RensonCoordinator]):
|
||||
"""Initialize the Renson entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
mac = api.get_field_value(coordinator.data, MAC_ADDRESS.name)
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)},
|
||||
identifiers={
|
||||
(DOMAIN, api.get_field_value(coordinator.data, MAC_ADDRESS.name))
|
||||
},
|
||||
manufacturer="Renson",
|
||||
model=api.get_field_value(coordinator.data, DEVICE_NAME_FIELD.name),
|
||||
name="Ventilation",
|
||||
@@ -42,4 +41,6 @@ class RensonEntity(CoordinatorEntity[RensonCoordinator]):
|
||||
|
||||
self.api = api
|
||||
|
||||
self._attr_unique_id = f"{mac}{name}"
|
||||
self._attr_unique_id = (
|
||||
api.get_field_value(coordinator.data, MAC_ADDRESS.name) + f"{name}"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Reolink integration for HomeAssistant."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from random import uniform
|
||||
from time import time
|
||||
@@ -26,7 +26,6 @@ from homeassistant.helpers import (
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
|
||||
@@ -193,7 +192,7 @@ async def async_setup_entry(
|
||||
hass.config_entries.async_update_entry(config_entry, data=data)
|
||||
|
||||
# If camera WAN blocked, firmware check fails and takes long, do not prevent setup
|
||||
now = dt_util.utcnow()
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
check_time = timedelta(seconds=check_time_sec)
|
||||
delta_midnight = now - now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
firmware_check_delay = check_time - delta_midnight
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Sensoterra devices."""
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum, auto
|
||||
|
||||
from sensoterra.probe import Probe, Sensor
|
||||
@@ -22,7 +22,6 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONFIGURATION_URL, DOMAIN, SENSOR_EXPIRATION_DAYS
|
||||
from .coordinator import SensoterraConfigEntry, SensoterraCoordinator
|
||||
@@ -166,5 +165,5 @@ class SensoterraEntity(CoordinatorEntity[SensoterraCoordinator], SensorEntity):
|
||||
return False
|
||||
|
||||
# Expire sensor if no update within the last few days.
|
||||
expiration = dt_util.utcnow() - timedelta(days=SENSOR_EXPIRATION_DAYS)
|
||||
expiration = datetime.now(UTC) - timedelta(days=SENSOR_EXPIRATION_DAYS) # pylint: disable=home-assistant-enforce-utcnow
|
||||
return sensor.timestamp >= expiration
|
||||
|
||||
@@ -247,7 +247,7 @@ def _async_register_base_station(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, str(system.system_id))},
|
||||
manufacturer="SimpliSafe",
|
||||
model=str(system.version),
|
||||
model=system.version,
|
||||
name=system.address,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from sonos_websocket.exception import SonosWebsocketError
|
||||
|
||||
from homeassistant.components import media_source, spotify
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_INPUT_SOURCE,
|
||||
ATTR_MEDIA_ALBUM_NAME,
|
||||
ATTR_MEDIA_ANNOUNCE,
|
||||
ATTR_MEDIA_ARTIST,
|
||||
@@ -778,6 +779,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
||||
if self.media.queue_size:
|
||||
attributes["queue_size"] = self.media.queue_size
|
||||
|
||||
if self.source:
|
||||
attributes[ATTR_INPUT_SOURCE] = self.source
|
||||
|
||||
return attributes
|
||||
|
||||
async def async_get_browse_image(
|
||||
|
||||
@@ -9,7 +9,6 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator
|
||||
from .entity import StarlinkEntity
|
||||
@@ -64,7 +63,8 @@ def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time:
|
||||
hour -= 24
|
||||
minute = utc_minutes % 60
|
||||
try:
|
||||
utc = dt_util.utcnow().replace(
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
utc = datetime.now(UTC).replace(
|
||||
hour=hour, minute=minute, second=0, microsecond=0
|
||||
)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Provides triggers for timers."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta
|
||||
from typing import cast, override
|
||||
|
||||
@@ -129,17 +128,13 @@ class TimeRemainingTrigger(Trigger):
|
||||
schedule_for_state(entity_id, to_state, event.context)
|
||||
|
||||
@callback
|
||||
def on_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
"""Handle changes to the tracked entity set."""
|
||||
for entity_id in removed:
|
||||
if entity_id in scheduled:
|
||||
scheduled.pop(entity_id)()
|
||||
for entity_id in added:
|
||||
state = entity_states[entity_id]
|
||||
state = self._hass.states.get(entity_id)
|
||||
schedule_for_state(entity_id, state, state.context if state else None)
|
||||
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import VilfoConfigEntry
|
||||
@@ -72,20 +72,12 @@ class VilfoRouterSensor(SensorEntity):
|
||||
self.entity_description = description
|
||||
self.api = api
|
||||
self._attr_device_info = DeviceInfo(
|
||||
# This identifier is a non-standard 3-tuple kept as-is to avoid
|
||||
# migrating existing devices; only the connection is added here.
|
||||
identifiers={(DOMAIN, api.host, api.mac_address)}, # type: ignore[arg-type]
|
||||
name=ROUTER_DEFAULT_NAME,
|
||||
manufacturer=ROUTER_MANUFACTURER,
|
||||
model=ROUTER_DEFAULT_MODEL,
|
||||
sw_version=api.firmware_version,
|
||||
)
|
||||
# The router does not always report a MAC address (e.g. when set up by
|
||||
# host), so only attach the connection when one is available.
|
||||
if api.mac_address:
|
||||
self._attr_device_info["connections"] = {
|
||||
(CONNECTION_NETWORK_MAC, api.mac_address)
|
||||
}
|
||||
self._attr_unique_id = f"{api.unique_id}_{description.key}"
|
||||
|
||||
@property
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
import logging
|
||||
|
||||
from aiowebdav2.client import Client
|
||||
from aiowebdav2.exceptions import (
|
||||
ConnectionExceptionError,
|
||||
NoConnectionError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from aiowebdav2.exceptions import UnauthorizedError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
|
||||
@@ -39,11 +35,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bo
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_username_password",
|
||||
) from err
|
||||
except (ConnectionExceptionError, NoConnectionError, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
|
||||
# Check if we can connect to the WebDAV server
|
||||
# and access the root directory
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -16,7 +16,6 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_DEVICE_STATE, ATTR_LORA_INFO, DOMAIN, YOLINK_OFFLINE_TIME
|
||||
|
||||
@@ -73,7 +72,8 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]):
|
||||
device_reporttime = device_state_resp.data.get("reportAt")
|
||||
if device_reporttime is not None:
|
||||
rpt_time_delta = (
|
||||
dt_util.utcnow().replace(tzinfo=None)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
datetime.now(tz=UTC).replace(tzinfo=None)
|
||||
- datetime.strptime(device_reporttime, "%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
).total_seconds()
|
||||
self.dev_online = rpt_time_delta < YOLINK_OFFLINE_TIME
|
||||
|
||||
@@ -97,6 +97,10 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
|
||||
|
||||
async def _async_update_data(self) -> dict[str, YotoPlayer]:
|
||||
"""Fetch fresh data from the Yoto cloud."""
|
||||
# _async_setup already populated the client; skip the duplicate first fetch.
|
||||
if self.data is None:
|
||||
return self.client.players
|
||||
|
||||
try:
|
||||
await self._session.async_ensure_token_valid()
|
||||
except OAuth2TokenRequestReauthError as err:
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Diagnostics support for the Yoto integration."""
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import YotoConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"mac",
|
||||
"network_ssid",
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: YotoConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
return {
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"players": async_redact_data(
|
||||
{
|
||||
player_id: asdict(player)
|
||||
for player_id, player in coordinator.data.items()
|
||||
},
|
||||
TO_REDACT,
|
||||
),
|
||||
}
|
||||
@@ -45,7 +45,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: The integration supports local DHCP discovery (via hostname pattern), but does not implement a separate discovery update handling flow.
|
||||
@@ -76,4 +76,4 @@ rules:
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
strict-typing: todo
|
||||
|
||||
+4
-22
@@ -760,29 +760,11 @@ class UnitOfPrecipitationDepth(StrEnum):
|
||||
"""Derived from cm³/cm²"""
|
||||
|
||||
|
||||
class UnitOfDensity(StrEnum):
|
||||
"""Density units.
|
||||
|
||||
Ratio of a substance's mass to its volume.
|
||||
"""
|
||||
|
||||
GRAMS_PER_CUBIC_METER = "g/m³"
|
||||
MILLIGRAMS_PER_CUBIC_METER = "mg/m³"
|
||||
MICROGRAMS_PER_CUBIC_METER = "μg/m³"
|
||||
MICROGRAMS_PER_CUBIC_FOOT = "μg/ft³"
|
||||
|
||||
|
||||
# Concentration units
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = UnitOfDensity.GRAMS_PER_CUBIC_METER.value
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = (
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER.value
|
||||
)
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER.value
|
||||
)
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_FOOT.value
|
||||
)
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³"
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "μg/m³"
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = "μg/ft³"
|
||||
_DEPRECATED_CONCENTRATION_PARTS_PER_CUBIC_METER = DeprecatedConstant(
|
||||
"p/m³", "p/m³", "2027.7"
|
||||
)
|
||||
|
||||
Generated
-1
@@ -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",
|
||||
|
||||
@@ -491,10 +491,7 @@ class _HistoryPrimingManager:
|
||||
tracking its entities, or the read could miss a change still queued in the
|
||||
recorder and compute too generous an anchor. A condition therefore never
|
||||
rides a flush that was already running when it arrived (the lobby); it waits
|
||||
that one out and joins the next, and re-attempts if the flush it rode was
|
||||
cancelled before completing. This mirrors `ReloadServiceHelper` minus its
|
||||
target de-duplication, which does not apply because each condition reads its
|
||||
own entities.
|
||||
that one out and joins the next.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
@@ -502,7 +499,6 @@ class _HistoryPrimingManager:
|
||||
self._hass = hass
|
||||
self._flush_condition = asyncio.Condition()
|
||||
self._flushing = False
|
||||
self._flush_ok = False
|
||||
self._query_lock = asyncio.Lock()
|
||||
|
||||
async def async_prime[_T](
|
||||
@@ -524,30 +520,29 @@ class _HistoryPrimingManager:
|
||||
if self._flushing:
|
||||
await self._flush_condition.wait()
|
||||
|
||||
do_flush = False
|
||||
while True:
|
||||
async with self._flush_condition:
|
||||
if not self._flushing:
|
||||
# First past the lobby this generation: we run the flush.
|
||||
self._flushing = True
|
||||
do_flush = True
|
||||
break
|
||||
# A peer began a fresh flush after we cleared the lobby; ride it.
|
||||
# A peer began a fresh flush after we cleared the lobby; it
|
||||
# covers us too, so wait for it and ride it.
|
||||
await self._flush_condition.wait()
|
||||
if self._flush_ok:
|
||||
return
|
||||
# The flush we waited for was cancelled before completing (its owner
|
||||
# timed out): loop and start or wait for a fresh one rather than read
|
||||
# against a queue that was never flushed.
|
||||
break
|
||||
|
||||
if not do_flush:
|
||||
return
|
||||
|
||||
instance = get_instance(self._hass)
|
||||
flushed = False
|
||||
try:
|
||||
if (commit_future := instance.async_get_commit_future()) is not None:
|
||||
await commit_future
|
||||
flushed = True
|
||||
finally:
|
||||
async with self._flush_condition:
|
||||
self._flushing = False
|
||||
self._flush_ok = flushed
|
||||
self._flush_condition.notify_all()
|
||||
|
||||
|
||||
@@ -675,10 +670,7 @@ class EntityConditionBase(Condition):
|
||||
self._on_unload.append(unsub)
|
||||
|
||||
async def _async_on_entities_update(
|
||||
self,
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
_entity_states: Mapping[str, State | None],
|
||||
self, added: set[str], removed: set[str]
|
||||
) -> None:
|
||||
"""Handle changes to the tracked entity set.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine, Mapping
|
||||
from collections.abc import Callable, Coroutine
|
||||
import dataclasses
|
||||
import logging
|
||||
from logging import Logger
|
||||
@@ -21,7 +21,6 @@ from homeassistant.core import (
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -44,19 +43,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@dataclasses.dataclass(slots=True, frozen=True)
|
||||
class TargetStateChangedData:
|
||||
"""Data for state change events related to targets.
|
||||
|
||||
`targeted_entity_states` holds the states of all targeted entities as of
|
||||
the state change event. State change events are dispatched one event loop
|
||||
iteration after the state machine is updated, so the live state machine
|
||||
may already contain later changes; this mapping does not. It is only
|
||||
valid during the synchronous callback: it is updated in place as
|
||||
subsequent events are dispatched.
|
||||
"""
|
||||
"""Data for state change events related to targets."""
|
||||
|
||||
state_change_event: Event[EventStateChangedData]
|
||||
targeted_entity_ids: set[str]
|
||||
targeted_entity_states: Mapping[str, State | None]
|
||||
|
||||
|
||||
def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]:
|
||||
@@ -370,8 +360,7 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
action: Callable[[TargetStateChangedData], Any],
|
||||
entity_filter: Callable[[set[str]], set[str]],
|
||||
on_entities_update: Callable[
|
||||
[set[str], set[str], Mapping[str, State | None]],
|
||||
Coroutine[Any, Any, None] | None,
|
||||
[set[str], set[str]], Coroutine[Any, Any, None] | None
|
||||
]
|
||||
| None = None,
|
||||
*,
|
||||
@@ -382,10 +371,7 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
`on_entities_update` may be a plain callback or a coroutine function.
|
||||
A coroutine is awaited for the initial entity set (so setup is
|
||||
deterministic) and scheduled as a background task for later
|
||||
registry-driven changes. It is called with the added and removed
|
||||
entity ids and the states of all currently targeted entities; the
|
||||
states mapping is only valid during the synchronous call, so a
|
||||
coroutine must copy what it needs before awaiting.
|
||||
registry-driven changes.
|
||||
"""
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -397,7 +383,6 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
self._on_entities_update = on_entities_update
|
||||
self._state_change_unsub: CALLBACK_TYPE | None = None
|
||||
self._tracked_entities: set[str] = set()
|
||||
self._tracked_entity_states: dict[str, State | None] = {}
|
||||
self._update_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
async def async_setup(self) -> Callable[[], None]:
|
||||
@@ -433,49 +418,25 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
previous_entities = self._tracked_entities
|
||||
self._tracked_entities = tracked_entities
|
||||
|
||||
# Carry over the tracked states of still-tracked entities: they are
|
||||
# consistent with the already-dispatched event stream, while the live
|
||||
# state machine may be ahead of it. Only entities new to the view are
|
||||
# read from the live state machine.
|
||||
previous_states = self._tracked_entity_states
|
||||
tracked_entity_states = {
|
||||
entity_id: (
|
||||
previous_states[entity_id]
|
||||
if entity_id in previous_states
|
||||
else self._hass.states.get(entity_id)
|
||||
)
|
||||
for entity_id in tracked_entities
|
||||
}
|
||||
self._tracked_entity_states = tracked_entity_states
|
||||
|
||||
result: Coroutine[Any, Any, None] | None = None
|
||||
if self._on_entities_update is not None:
|
||||
added = tracked_entities - previous_entities
|
||||
removed = previous_entities - tracked_entities
|
||||
if added or removed:
|
||||
result = self._on_entities_update(added, removed, tracked_entity_states)
|
||||
result = self._on_entities_update(added, removed)
|
||||
|
||||
@callback
|
||||
def state_change_listener(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle state change events."""
|
||||
if (entity_id := event.data["entity_id"]) not in tracked_entities:
|
||||
return
|
||||
tracked_entity_states[entity_id] = event.data["new_state"]
|
||||
self._action(
|
||||
TargetStateChangedData(event, tracked_entities, tracked_entity_states)
|
||||
)
|
||||
if event.data["entity_id"] in tracked_entities:
|
||||
self._action(TargetStateChangedData(event, tracked_entities))
|
||||
|
||||
_LOGGER.debug("Tracking state changes for entities: %s", tracked_entities)
|
||||
# Subscribe before unsubscribing the previous listener: if this
|
||||
# tracker is the only subscriber, unsubscribing first tears down the
|
||||
# shared state change tracker, dropping events which have been fired
|
||||
# but not yet dispatched.
|
||||
previous_unsub = self._state_change_unsub
|
||||
if self._state_change_unsub:
|
||||
self._state_change_unsub()
|
||||
self._state_change_unsub = async_track_state_change_event(
|
||||
self._hass, tracked_entities, state_change_listener
|
||||
)
|
||||
if previous_unsub:
|
||||
previous_unsub()
|
||||
return result
|
||||
|
||||
def _unsubscribe(self) -> None:
|
||||
@@ -494,10 +455,7 @@ async def async_track_target_selector_state_change_event(
|
||||
target_selector_config: ConfigType,
|
||||
action: Callable[[TargetStateChangedData], Any],
|
||||
entity_filter: Callable[[set[str]], set[str]] = lambda x: x,
|
||||
on_entities_update: Callable[
|
||||
[set[str], set[str], Mapping[str, State | None]],
|
||||
Coroutine[Any, Any, None] | None,
|
||||
]
|
||||
on_entities_update: Callable[[set[str], set[str]], Coroutine[Any, Any, None] | None]
|
||||
| None = None,
|
||||
*,
|
||||
primary_entities_only: bool = True,
|
||||
@@ -509,11 +467,9 @@ async def async_track_target_selector_state_change_event(
|
||||
expansion (via device, area, and floor) skips entities
|
||||
with an `entity_category` (config or diagnostic entities).
|
||||
|
||||
`on_entities_update` is called with the added and removed entity ids and
|
||||
the states of all currently targeted entities. It may be a coroutine
|
||||
function; it is awaited for the initial entity set and scheduled as a
|
||||
task for later registry-driven changes, so this function must itself be
|
||||
awaited. The states mapping is only valid during the synchronous call.
|
||||
`on_entities_update` may be a coroutine function; it is awaited for the
|
||||
initial entity set and scheduled as a task for later registry-driven
|
||||
changes, so this function must itself be awaited.
|
||||
"""
|
||||
target_selection = TargetSelection(target_selector_config)
|
||||
if not target_selection.has_any_target:
|
||||
|
||||
@@ -5,7 +5,7 @@ import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Coroutine, Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
@@ -75,7 +75,7 @@ from .automation import (
|
||||
get_relative_description_key,
|
||||
move_options_fields_to_top_level,
|
||||
)
|
||||
from .event import async_call_later
|
||||
from .event import async_track_same_state
|
||||
from .integration_platform import async_process_integration_platforms
|
||||
from .selector import (
|
||||
NumericThresholdMode,
|
||||
@@ -438,11 +438,7 @@ class EntityTriggerBase(Trigger):
|
||||
"""
|
||||
return state.state not in self._excluded_states
|
||||
|
||||
def count_matches(
|
||||
self,
|
||||
entity_ids: Iterable[str],
|
||||
states: Mapping[str, State | None] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
def count_matches(self, entity_ids: set[str]) -> tuple[int, int]:
|
||||
"""Return (matches, included) for the entity set.
|
||||
|
||||
`matches` is the number of entities that pass `_should_include` AND
|
||||
@@ -451,19 +447,11 @@ class EntityTriggerBase(Trigger):
|
||||
Callers can use the pair to distinguish vacuous truth
|
||||
(`included == 0`) from a genuine all-match
|
||||
(`matches == included > 0`).
|
||||
|
||||
Entity states are read from `states` when provided, otherwise from
|
||||
the live state machine. Pass the targeted entity states received
|
||||
with a state change event to evaluate the event against the states
|
||||
as they were when the event fired.
|
||||
"""
|
||||
matches = 0
|
||||
included = 0
|
||||
for entity_id in entity_ids:
|
||||
if states is not None:
|
||||
state = states[entity_id]
|
||||
else:
|
||||
state = self._hass.states.get(entity_id)
|
||||
state = self._hass.states.get(entity_id)
|
||||
if state is None or not self._should_include(state):
|
||||
continue
|
||||
included += 1
|
||||
@@ -471,60 +459,6 @@ class EntityTriggerBase(Trigger):
|
||||
matches += 1
|
||||
return matches, included
|
||||
|
||||
@callback
|
||||
def _cancel_invalidated_timers(
|
||||
self,
|
||||
behavior: str,
|
||||
pending_timers: dict[str, CALLBACK_TYPE],
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Cancel pending duration timers invalidated by a state change.
|
||||
|
||||
Runs on every delivered state change, before the trigger's own
|
||||
validity checks: an event which cannot fire the trigger, e.g. an
|
||||
entity becoming unavailable, may still invalidate a pending timer.
|
||||
The targeted entity states have already been updated with this
|
||||
event, so the first/all check can simply recount.
|
||||
"""
|
||||
event = target_state_change_data.state_change_event
|
||||
if behavior == BEHAVIOR_EACH:
|
||||
entity_id = event.data["entity_id"]
|
||||
if entity_id not in pending_timers:
|
||||
return
|
||||
to_state = event.data["new_state"]
|
||||
if (
|
||||
to_state is None
|
||||
or to_state.state in self._excluded_states
|
||||
or not self.is_valid_state(to_state)
|
||||
):
|
||||
pending_timers.pop(entity_id)()
|
||||
return
|
||||
if behavior not in pending_timers:
|
||||
return
|
||||
if not self._combined_state_still_valid(
|
||||
behavior,
|
||||
target_state_change_data.targeted_entity_ids,
|
||||
target_state_change_data.targeted_entity_states,
|
||||
):
|
||||
pending_timers.pop(behavior)()
|
||||
|
||||
def _combined_state_still_valid(
|
||||
self,
|
||||
behavior: str,
|
||||
entity_ids: Iterable[str],
|
||||
states: Mapping[str, State | None],
|
||||
) -> bool:
|
||||
"""Check the combined first/all state for a pending duration timer."""
|
||||
matches, included = self.count_matches(entity_ids, states)
|
||||
if behavior == BEHAVIOR_FIRST:
|
||||
return matches >= 1
|
||||
# Require at least one included entity to avoid keeping the timer
|
||||
# alive when every targeted entity has been filtered out since it
|
||||
# started — a vacuous all-match (`included == 0`) would otherwise
|
||||
# let the action fire after `for:` even though no entity still
|
||||
# matches.
|
||||
return included > 0 and matches == included
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
@@ -532,32 +466,7 @@ class EntityTriggerBase(Trigger):
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
behavior: str = self._options.get(ATTR_BEHAVIOR, BEHAVIOR_EACH)
|
||||
# Pending `for:` duration timers, keyed by entity_id for behavior
|
||||
# each and by the behavior for first/all.
|
||||
pending_timers: dict[str, CALLBACK_TYPE] = {}
|
||||
|
||||
@callback
|
||||
def handle_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
"""Re-validate pending duration timers on target changes.
|
||||
|
||||
Timers of entities no longer targeted are cancelled, and the
|
||||
combined first/all condition is recounted over the updated
|
||||
target: e.g. a non-matching entity added to the target breaks a
|
||||
pending all-match.
|
||||
"""
|
||||
for entity_id in removed:
|
||||
if (cancel := pending_timers.pop(entity_id, None)) is not None:
|
||||
cancel()
|
||||
if behavior not in pending_timers:
|
||||
return
|
||||
if not self._combined_state_still_valid(
|
||||
behavior, entity_states.keys(), entity_states
|
||||
):
|
||||
pending_timers.pop(behavior)()
|
||||
unsub_track_same: dict[str, Callable[[], None]] = {}
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
@@ -569,10 +478,35 @@ class EntityTriggerBase(Trigger):
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
if pending_timers:
|
||||
self._cancel_invalidated_timers(
|
||||
behavior, pending_timers, target_state_change_data
|
||||
)
|
||||
def state_still_valid(
|
||||
_: str, from_state: State | None, to_state: State | None
|
||||
) -> bool:
|
||||
"""Check if the state is still valid during the duration wait.
|
||||
|
||||
Called by async_track_same_state on each state change to
|
||||
determine whether to cancel the timer.
|
||||
For behavior each, checks the individual entity's state.
|
||||
For behavior first/all, checks the combined state.
|
||||
"""
|
||||
if behavior == BEHAVIOR_ALL:
|
||||
matches, included = self.count_matches(
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
# Require at least one included entity to avoid keeping
|
||||
# the timer alive when every targeted entity has been
|
||||
# filtered out since it started — a vacuous all-match
|
||||
# (`included == 0`) would otherwise let the action fire
|
||||
# after `for:` even though no entity still matches.
|
||||
return included > 0 and matches == included
|
||||
if behavior == BEHAVIOR_FIRST:
|
||||
matches, _included = self.count_matches(
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
return matches >= 1
|
||||
# Behavior each: check the individual entity's state
|
||||
if not to_state or to_state.state in self._excluded_states:
|
||||
return False
|
||||
return self.is_valid_state(to_state)
|
||||
|
||||
if not from_state or not to_state:
|
||||
return
|
||||
@@ -592,15 +526,9 @@ class EntityTriggerBase(Trigger):
|
||||
):
|
||||
return
|
||||
|
||||
# Count against the targeted entity states as of this event, not
|
||||
# the live state machine: state change events are dispatched one
|
||||
# event loop iteration after the state machine is updated, so the
|
||||
# state machine may already contain later changes to other
|
||||
# targeted entities.
|
||||
if behavior == BEHAVIOR_ALL:
|
||||
matches, included = self.count_matches(
|
||||
target_state_change_data.targeted_entity_ids,
|
||||
target_state_change_data.targeted_entity_states,
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
if matches != included:
|
||||
return
|
||||
@@ -609,8 +537,7 @@ class EntityTriggerBase(Trigger):
|
||||
# were previously 2 matches the transition would not be valid and we
|
||||
# would have returned already.
|
||||
matches, _ = self.count_matches(
|
||||
target_state_change_data.targeted_entity_ids,
|
||||
target_state_change_data.targeted_entity_states,
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
if matches != 1:
|
||||
return
|
||||
@@ -638,19 +565,18 @@ class EntityTriggerBase(Trigger):
|
||||
return
|
||||
|
||||
subscription_key = entity_id if behavior == BEHAVIOR_EACH else behavior
|
||||
if (
|
||||
previous_timer := pending_timers.pop(subscription_key, None)
|
||||
) is not None:
|
||||
previous_timer()
|
||||
|
||||
@callback
|
||||
def fire_after_duration(_now: datetime) -> None:
|
||||
"""Fire the action once the state has held for the duration."""
|
||||
del pending_timers[subscription_key]
|
||||
call_action()
|
||||
|
||||
pending_timers[subscription_key] = async_call_later(
|
||||
self._hass, self._duration, fire_after_duration
|
||||
if subscription_key in unsub_track_same:
|
||||
unsub_track_same.pop(subscription_key)()
|
||||
unsub_track_same[subscription_key] = async_track_same_state(
|
||||
self._hass,
|
||||
self._duration,
|
||||
call_action,
|
||||
state_still_valid,
|
||||
entity_ids=(
|
||||
entity_id
|
||||
if behavior == BEHAVIOR_EACH
|
||||
else target_state_change_data.targeted_entity_ids
|
||||
),
|
||||
)
|
||||
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
@@ -658,7 +584,6 @@ class EntityTriggerBase(Trigger):
|
||||
self._target,
|
||||
state_change_listener,
|
||||
self.entity_filter,
|
||||
handle_entities_update if self._duration else None,
|
||||
primary_entities_only=self._primary_entities_only,
|
||||
)
|
||||
|
||||
@@ -666,9 +591,9 @@ class EntityTriggerBase(Trigger):
|
||||
def async_remove() -> None:
|
||||
"""Remove state listeners async."""
|
||||
unsub()
|
||||
for cancel_timer in pending_timers.values():
|
||||
cancel_timer()
|
||||
pending_timers.clear()
|
||||
for async_remove in unsub_track_same.values():
|
||||
async_remove()
|
||||
unsub_track_same.clear()
|
||||
|
||||
return async_remove
|
||||
|
||||
|
||||
@@ -31,9 +31,7 @@ DISCOVERY_INTEGRATIONS: dict[str, Iterable[str]] = {
|
||||
}
|
||||
DEPRECATED_PACKAGES: dict[str, tuple[str, str]] = {
|
||||
# old_package_name: (reason, breaks_in_ha_version)
|
||||
"pyserial": ("should be replaced by serialx", "2027.1"),
|
||||
"pyserial-asyncio": ("should be replaced by serialx", "2027.1"),
|
||||
"pyserial-asyncio-fast": ("should be replaced by serialx", "2027.1"),
|
||||
"pyserial-asyncio": ("should be replaced by pyserial-asyncio-fast", "2026.7"),
|
||||
}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ from functools import lru_cache
|
||||
from math import floor, log10
|
||||
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
@@ -14,7 +17,6 @@ from homeassistant.const import (
|
||||
UnitOfBloodGlucoseConcentration,
|
||||
UnitOfConductivity,
|
||||
UnitOfDataRate,
|
||||
UnitOfDensity,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -246,18 +248,18 @@ class CarbonMonoxideConcentrationConverter(BaseUnitConverter):
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1e6,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: (
|
||||
_CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e3
|
||||
),
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -492,14 +494,14 @@ class MassVolumeConcentrationConverter(BaseUnitConverter):
|
||||
|
||||
UNIT_CLASS = "concentration"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: 1_000_000.0, # 1000 µg/m³ = 1 mg/m³
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³
|
||||
UnitOfDensity.GRAMS_PER_CUBIC_METER: 1.0,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1_000_000.0, # 1000 µg/m³ = 1 mg/m³
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER: 1.0,
|
||||
}
|
||||
VALID_UNITS = {
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -510,14 +512,14 @@ class NitrogenDioxideConcentrationConverter(BaseUnitConverter):
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1e6,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_NITROGEN_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -527,13 +529,13 @@ class NitrogenMonoxideConcentrationConverter(BaseUnitConverter):
|
||||
UNIT_CLASS = "nitrogen_monoxide"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_NITROGEN_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -544,14 +546,14 @@ class OzoneConcentrationConverter(BaseUnitConverter):
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1e6,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_OZONE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -749,13 +751,13 @@ class SulphurDioxideConcentrationConverter(BaseUnitConverter):
|
||||
UNIT_CLASS = "sulphur_dioxide"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_SULPHUR_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6180,16 +6180,6 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.yoto.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.youtube.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
Generated
+2
-5
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -39,6 +39,7 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID,
|
||||
},
|
||||
unique_id=f"lg_ir_tv_{MOCK_INFRARED_EMITTER_ENTITY_ID}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ from tests.components.infrared import (
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "expected_title"),
|
||||
("config", "expected_title", "unique_id_entity_id"),
|
||||
[
|
||||
(
|
||||
{CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id},
|
||||
"LG TV via Test IR emitter",
|
||||
mock_infrared_emitter_entity_id,
|
||||
),
|
||||
(
|
||||
{
|
||||
@@ -34,10 +35,12 @@ from tests.components.infrared import (
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id,
|
||||
},
|
||||
"LG TV via Test IR emitter",
|
||||
mock_infrared_emitter_entity_id,
|
||||
),
|
||||
(
|
||||
{CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id},
|
||||
"LG TV via Test IR receiver",
|
||||
mock_infrared_receiver_entity_id,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -48,6 +51,7 @@ async def test_user_flow_success(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, str],
|
||||
expected_title: str,
|
||||
unique_id_entity_id: str,
|
||||
) -> None:
|
||||
"""Test successful user config flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -65,7 +69,7 @@ async def test_user_flow_success(
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == expected_title
|
||||
assert result["data"] == {CONF_DEVICE_TYPE: LGDeviceType.TV, **config}
|
||||
assert result["result"].unique_id is None
|
||||
assert result["result"].unique_id == f"lg_ir_tv_{unique_id_entity_id}"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
|
||||
@@ -86,33 +90,9 @@ async def test_user_flow_requires_emitter_or_receiver(
|
||||
assert result["errors"] == {"base": "missing_infrared_entity"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_infrared_emitter_entity", "mock_infrared_receiver_entity"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"user_input",
|
||||
[
|
||||
pytest.param(
|
||||
{CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id},
|
||||
id="emitter_conflict",
|
||||
),
|
||||
pytest.param(
|
||||
{CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id},
|
||||
id="receiver_conflict",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id,
|
||||
},
|
||||
id="both_conflict",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
user_input: dict[str, str],
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test user flow aborts when entry is already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
@@ -125,7 +105,10 @@ async def test_user_flow_already_configured(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_DEVICE_TYPE: LGDeviceType.TV, **user_input},
|
||||
user_input={
|
||||
CONF_DEVICE_TYPE: LGDeviceType.TV,
|
||||
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -172,5 +155,6 @@ async def test_user_flow_title_from_entity_name(
|
||||
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == expected_title
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
"""Tests for the LG Infrared integration setup."""
|
||||
|
||||
from homeassistant.components.lg_infrared.const import (
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_INFRARED_ENTITY_ID,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID,
|
||||
DOMAIN,
|
||||
LGDeviceType,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.infrared import (
|
||||
EMITTER_ENTITY_ID as MOCK_INFRARED_EMITTER_ENTITY_ID,
|
||||
RECEIVER_ENTITY_ID as MOCK_INFRARED_RECEIVER_ENTITY_ID,
|
||||
)
|
||||
from tests.components.infrared.common import (
|
||||
MockInfraredEmitterEntity,
|
||||
MockInfraredReceiverEntity,
|
||||
)
|
||||
|
||||
|
||||
async def test_setup_and_unload_entry(
|
||||
@@ -32,30 +17,3 @@ async def test_setup_and_unload_entry(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_migrate_v1_to_v2(
|
||||
hass: HomeAssistant,
|
||||
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
|
||||
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
|
||||
mock_lg_tv_code_to_command: None,
|
||||
) -> None:
|
||||
"""Test migration from v1 (legacy unique_id) to v2 (no unique_id)."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
version=1,
|
||||
unique_id=f"lg_ir_tv_{MOCK_INFRARED_EMITTER_ENTITY_ID}",
|
||||
data={
|
||||
CONF_DEVICE_TYPE: LGDeviceType.TV,
|
||||
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID,
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.version == 2
|
||||
assert entry.unique_id is None
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Tests for the Marantz RS-232 integration."""
|
||||
|
||||
MOCK_DEVICE = "/dev/ttyUSB0"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -1622,7 +1622,8 @@ async def test_remove_stale_media(
|
||||
event_media = media_files[0]
|
||||
assert event_media.name.endswith(".mp4")
|
||||
|
||||
event_time1 = dt_util.utcnow() - datetime.timedelta(days=8)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
event_time1 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=8)
|
||||
extra_media1 = (
|
||||
device_path / f"{int(event_time1.timestamp())}-camera_motion-test.mp4"
|
||||
)
|
||||
@@ -1633,7 +1634,8 @@ async def test_remove_stale_media(
|
||||
)
|
||||
extra_media2.write_bytes(mp4.getvalue())
|
||||
# This event will not be garbage collected because it is too recent
|
||||
event_time3 = dt_util.utcnow() - datetime.timedelta(days=3)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
event_time3 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=3)
|
||||
extra_media3 = (
|
||||
device_path / f"{int(event_time3.timestamp())}-camera_motion-test.mp4"
|
||||
)
|
||||
@@ -1643,7 +1645,8 @@ async def test_remove_stale_media(
|
||||
|
||||
# Advance the clock to invoke the garbage collector. This will remove extra
|
||||
# files that are not valid events that are old enough.
|
||||
point_in_time = dt_util.utcnow() + datetime.timedelta(days=1)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
point_in_time = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
|
||||
with freeze_time(point_in_time):
|
||||
async_fire_time_changed(hass, point_in_time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'80:7d:3a:bd:1e:32',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': '8.0',
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'renson',
|
||||
'80:7d:3a:bd:1e:32',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Renson',
|
||||
'model': 'Endura Delta',
|
||||
'model_id': None,
|
||||
'name': 'Ventilation',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': 'Firmware version 4.9.1',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Tests for the Renson integration setup."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.renson.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_device_registry(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the device registry entry, including the network MAC connection."""
|
||||
all_data = {
|
||||
"ModifiedItems": [
|
||||
{"Name": "MAC", "Value": "80:7d:3a:bd:1e:32"},
|
||||
{"Name": "Device name", "Value": "Endura Delta"},
|
||||
{"Name": "Firmware version", "Value": "Firmware version 4.9.1"},
|
||||
{"Name": "Hardware version", "Value": "8.0"},
|
||||
]
|
||||
}
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "1.1.1.1"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_api.connect.return_value = True
|
||||
mock_api.get_all_data.return_value = all_data
|
||||
|
||||
def _get_field_value(data: dict, fieldname: str) -> str:
|
||||
for item in data["ModifiedItems"]:
|
||||
if item["Name"] == fieldname:
|
||||
return item["Value"]
|
||||
return ""
|
||||
|
||||
mock_api.get_field_value.side_effect = _get_field_value
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.renson.RensonVentilation",
|
||||
return_value=mock_api,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.renson.PLATFORMS",
|
||||
[Platform.SENSOR],
|
||||
),
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "80:7d:3a:bd:1e:32")}
|
||||
)
|
||||
assert device_entry == snapshot
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
@@ -47,7 +47,6 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .conftest import (
|
||||
CONF_BC_CONNECT,
|
||||
@@ -1206,7 +1205,7 @@ async def test_firmware_update_delay(
|
||||
call_count: int,
|
||||
) -> None:
|
||||
"""Test delay of firmware update check."""
|
||||
now = dt_util.utcnow()
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
check_delay = (
|
||||
now
|
||||
+ timedelta(seconds=seconds)
|
||||
|
||||
@@ -49,21 +49,6 @@ async def test_base_station_migration(
|
||||
assert device_registry.async_get_device(identifiers=new_identifiers) is not None
|
||||
|
||||
|
||||
async def test_base_station_model_is_string(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
config_entry: MockConfigEntry,
|
||||
patch_simplisafe_api,
|
||||
) -> None:
|
||||
"""Test that the base station model is stored as a string in the device registry."""
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, "12345")})
|
||||
assert device is not None
|
||||
assert isinstance(device.model, str)
|
||||
|
||||
|
||||
async def test_coordinator_update_triggers_reauth_on_invalid_credentials(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry[with_mac]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'ff:00:00:00:00:00',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vilfo',
|
||||
'testadmin.vilfo.com',
|
||||
'FF-00-00-00-00-00',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Vilfo AB',
|
||||
'model': 'Vilfo Router',
|
||||
'model_id': None,
|
||||
'name': 'Vilfo Router',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '1.1.0',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_device_registry[without_mac]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vilfo',
|
||||
'testadmin.vilfo.com',
|
||||
None,
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Vilfo AB',
|
||||
'model': 'Vilfo Router',
|
||||
'model_id': None,
|
||||
'name': 'Vilfo Router',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '1.1.0',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Tests for the Vilfo Router integration setup."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.vilfo.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mac", "identifiers"),
|
||||
[
|
||||
pytest.param(
|
||||
"FF-00-00-00-00-00",
|
||||
{(DOMAIN, "testadmin.vilfo.com", "FF-00-00-00-00-00")},
|
||||
id="with_mac",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
{(DOMAIN, "testadmin.vilfo.com", None)},
|
||||
id="without_mac",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_device_registry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
mac: str | None,
|
||||
identifiers: set[tuple[str, str | None]],
|
||||
) -> None:
|
||||
"""Test the device registry entry.
|
||||
|
||||
The network MAC connection is only attached when the router reports a MAC;
|
||||
a router set up by host may not report one.
|
||||
"""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.vilfo.VilfoClient", autospec=True
|
||||
) as mock_client:
|
||||
client = mock_client.return_value
|
||||
client.mac = mac
|
||||
client.get_board_information.return_value = {
|
||||
"version": "1.1.0",
|
||||
"bootTime": "2024-01-01T00:00:00+00:00",
|
||||
}
|
||||
client.get_load.return_value = 30
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers=identifiers)
|
||||
assert device_entry == snapshot
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiowebdav2.exceptions import (
|
||||
AccessDeniedError,
|
||||
ConnectionExceptionError,
|
||||
NoConnectionError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from aiowebdav2.exceptions import AccessDeniedError, UnauthorizedError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN
|
||||
@@ -33,29 +28,8 @@ from tests.common import MockConfigEntry
|
||||
"Access denied to /access_denied",
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
),
|
||||
(
|
||||
ConnectionExceptionError(ConnectionError("Connection refused")),
|
||||
"Connection refused",
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
),
|
||||
(
|
||||
NoConnectionError("webdav.demo"),
|
||||
"No connection with webdav.demo",
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
),
|
||||
(
|
||||
TimeoutError(),
|
||||
"",
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"UnauthorizedError",
|
||||
"AccessDeniedError",
|
||||
"ConnectionExceptionError",
|
||||
"NoConnectionError",
|
||||
"TimeoutError",
|
||||
],
|
||||
ids=["UnauthorizedError", "AccessDeniedError"],
|
||||
)
|
||||
async def test_error_during_setup(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_entry_diagnostics
|
||||
dict({
|
||||
'entry': dict({
|
||||
'data': dict({
|
||||
'auth_implementation': 'yoto',
|
||||
'token': dict({
|
||||
'access_token': '**REDACTED**',
|
||||
'expires_in': 3600,
|
||||
'refresh_token': '**REDACTED**',
|
||||
'scope': 'offline_access family:view family:devices:view family:devices:control family:devices:manage family:library:view user:content:view user:icons:manage',
|
||||
'token_type': 'Bearer',
|
||||
}),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'discovery_keys': dict({
|
||||
}),
|
||||
'domain': 'yoto',
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'user',
|
||||
'subentries': list([
|
||||
]),
|
||||
'title': 'Yoto',
|
||||
'unique_id': 'auth0|user-test',
|
||||
'version': 1,
|
||||
}),
|
||||
'players': dict({
|
||||
'player-test': dict({
|
||||
'device': dict({
|
||||
'description': None,
|
||||
'device_family': 'v3',
|
||||
'device_group': None,
|
||||
'device_id': 'player-test',
|
||||
'device_type': 'v3',
|
||||
'form_factor': None,
|
||||
'generation': 'gen3',
|
||||
'has_user_given_name': False,
|
||||
'name': 'Nursery Yoto',
|
||||
'release_channel': None,
|
||||
}),
|
||||
'devices_refreshed_at': '2026-05-08T12:00:00+00:00',
|
||||
'extended_status': dict({
|
||||
'active_card': None,
|
||||
'ambient_light_sensor_reading': None,
|
||||
'average_download_speed_bytes_second': None,
|
||||
'battery_level_percentage': None,
|
||||
'battery_level_raw': None,
|
||||
'battery_profile': None,
|
||||
'battery_temperature': None,
|
||||
'battery_voltage_mv': None,
|
||||
'card_insertion_state': None,
|
||||
'current_display_brightness': None,
|
||||
'day_mode': None,
|
||||
'free_disk_space_bytes': None,
|
||||
'is_audio_device_connected': None,
|
||||
'is_background_download_active': None,
|
||||
'is_bluetooth_audio_connected': None,
|
||||
'is_charging': None,
|
||||
'network_ssid': None,
|
||||
'nightlight_mode': None,
|
||||
'power_source': None,
|
||||
'system_volume_percentage': None,
|
||||
'temperature_celcius': None,
|
||||
'total_disk_space_bytes': None,
|
||||
'updated_at': None,
|
||||
'uptime': None,
|
||||
'user_volume_percentage': None,
|
||||
'utc_offset_seconds': None,
|
||||
'utc_time': None,
|
||||
'wifi_strength': None,
|
||||
}),
|
||||
'info': dict({
|
||||
'activation_pop_code': None,
|
||||
'config': dict({
|
||||
'alarms': list([
|
||||
]),
|
||||
'bluetooth_enabled': None,
|
||||
'bt_headphones_enabled': None,
|
||||
'clock_face': None,
|
||||
'day_ambient_colour': None,
|
||||
'day_display_brightness': None,
|
||||
'day_display_brightness_auto': None,
|
||||
'day_max_volume_limit': None,
|
||||
'day_sounds_off': None,
|
||||
'day_time': dict({
|
||||
'__type': "<class 'datetime.time'>",
|
||||
'isoformat': '07:00:00',
|
||||
}),
|
||||
'day_yoto_daily': None,
|
||||
'day_yoto_radio': None,
|
||||
'display_dim_brightness': None,
|
||||
'display_dim_timeout': None,
|
||||
'headphones_volume_limited': None,
|
||||
'hour_format': None,
|
||||
'locale': None,
|
||||
'log_level': None,
|
||||
'night_ambient_colour': None,
|
||||
'night_display_brightness': None,
|
||||
'night_display_brightness_auto': None,
|
||||
'night_max_volume_limit': None,
|
||||
'night_sounds_off': None,
|
||||
'night_time': dict({
|
||||
'__type': "<class 'datetime.time'>",
|
||||
'isoformat': '19:00:00',
|
||||
}),
|
||||
'night_yoto_daily': None,
|
||||
'night_yoto_radio': None,
|
||||
'pause_power_button': None,
|
||||
'pause_volume_down': None,
|
||||
'repeat_all': None,
|
||||
'show_diagnostics': None,
|
||||
'shutdown_timeout': None,
|
||||
'system_volume': None,
|
||||
'timezone': None,
|
||||
'volume_level': None,
|
||||
}),
|
||||
'device_family': None,
|
||||
'device_group': None,
|
||||
'device_type': None,
|
||||
'error_code': None,
|
||||
'firmware_version': 'v2.17.5',
|
||||
'geo_timezone': None,
|
||||
'mac': '**REDACTED**',
|
||||
'name': None,
|
||||
'pop_code': None,
|
||||
'release_channel_id': None,
|
||||
}),
|
||||
'info_refreshed_at': '2026-05-08T12:00:00+00:00',
|
||||
'is_online': True,
|
||||
'last_event': dict({
|
||||
'card_id': 'card-test',
|
||||
'chapter_key': '01',
|
||||
'chapter_title': 'Chapter 1',
|
||||
'event_utc': None,
|
||||
'playback_status': 'playing',
|
||||
'playback_wait': None,
|
||||
'player_id': 'player-test',
|
||||
'position': 120,
|
||||
'repeat_all': None,
|
||||
'request_id': None,
|
||||
'sleep_timer_active': None,
|
||||
'sleep_timer_seconds': None,
|
||||
'source': None,
|
||||
'streaming': None,
|
||||
'track_key': '01-INT',
|
||||
'track_length': 300,
|
||||
'track_title': 'Introduction',
|
||||
'volume': 8,
|
||||
'volume_max': 16,
|
||||
}),
|
||||
'last_event_received_at': '2026-05-08T12:00:00+00:00',
|
||||
'online_refreshed_at': None,
|
||||
'status': dict({
|
||||
'active_card': None,
|
||||
'ambient_light_sensor_reading': None,
|
||||
'battery_level_percentage': 75,
|
||||
'card_insertion_state': 1,
|
||||
'current_display_brightness': None,
|
||||
'day_mode': 1,
|
||||
'free_disk_space_bytes': None,
|
||||
'is_audio_device_connected': False,
|
||||
'is_bluetooth_audio_connected': False,
|
||||
'is_charging': True,
|
||||
'nightlight_mode': None,
|
||||
'system_volume_percentage': None,
|
||||
'updated_at': None,
|
||||
'user_volume_percentage': None,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Tests for the diagnostics data provided by the Yoto integration."""
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from syrupy.filters import props
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.diagnostics import get_diagnostics_for_config_entry
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("setup_credentials", "mock_yoto_client")
|
||||
|
||||
|
||||
async def test_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test config entry diagnostics."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert await get_diagnostics_for_config_entry(
|
||||
hass, hass_client, mock_config_entry
|
||||
) == snapshot(exclude=props("entry_id", "created_at", "modified_at", "expires_at"))
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Test the condition helper."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Mapping
|
||||
from contextlib import AbstractContextManager, nullcontext as does_not_raise
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
@@ -5908,19 +5908,6 @@ async def test_history_priming_manager_serializes_queries(
|
||||
assert max_running == 1
|
||||
|
||||
|
||||
async def _advance_until(predicate: Callable[[], bool]) -> None:
|
||||
"""Pump the event loop until predicate holds, failing if it never does.
|
||||
|
||||
Avoids coupling tests to an exact number of internal await hops while still
|
||||
failing cleanly rather than hanging on a regression.
|
||||
"""
|
||||
for _ in range(1000):
|
||||
if predicate():
|
||||
return
|
||||
await asyncio.sleep(0)
|
||||
pytest.fail("condition was not reached")
|
||||
|
||||
|
||||
async def test_history_priming_manager_does_not_ride_in_flight_flush(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
@@ -5930,8 +5917,8 @@ async def test_history_priming_manager_does_not_ride_in_flight_flush(
|
||||
sees them. A condition that started tracking after an in-flight flush began
|
||||
could miss its own just-queued change if it rode that flush, so it waits the
|
||||
flush out and a fresh one is performed for it. Without the lobby step this
|
||||
test fails: the late arrivals would ride the first flush (it would stay at
|
||||
one flush total) instead of sharing a second, fresh one.
|
||||
test fails: the late arrivals would ride the first flush (one flush total)
|
||||
instead of sharing a second, fresh one.
|
||||
"""
|
||||
manager = _HistoryPrimingManager(hass)
|
||||
instance = get_instance(hass)
|
||||
@@ -5949,69 +5936,32 @@ async def test_history_priming_manager_does_not_ride_in_flight_flush(
|
||||
with patch.object(instance, "async_get_commit_future", _spy_commit_future):
|
||||
# C0 claims the flush and is mid-flush (its commit future is pending).
|
||||
c0 = asyncio.create_task(manager.async_prime(_job))
|
||||
await _advance_until(lambda: len(flush_futures) == 1)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if flush_futures:
|
||||
break
|
||||
assert len(flush_futures) == 1
|
||||
|
||||
# Two conditions arrive while C0's flush runs; they must not ride it.
|
||||
c1 = asyncio.create_task(manager.async_prime(_job))
|
||||
c2 = asyncio.create_task(manager.async_prime(_job))
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
# Parked in the lobby: no new flush yet, none finished.
|
||||
assert len(flush_futures) == 1
|
||||
assert not c1.done()
|
||||
assert not c2.done()
|
||||
|
||||
# C0's flush completes; C1 then performs a fresh flush and C2 rides it.
|
||||
# C0's flush completes; C1 now performs a fresh flush and C2 rides it.
|
||||
flush_futures[0].set_result(None)
|
||||
assert await c0 == "done"
|
||||
await _advance_until(lambda: len(flush_futures) == 2)
|
||||
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
# Exactly one fresh flush is shared by C1 and C2, not one each: this is
|
||||
# the assertion that fails without the lobby (it would stay 1).
|
||||
assert len(flush_futures) == 2
|
||||
flush_futures[1].set_result(None)
|
||||
assert await asyncio.gather(c1, c2) == ["done", "done"]
|
||||
# One fresh flush shared by C1 and C2, not one each (and not C0's stale
|
||||
# one): C1 flushed, C2 rode it.
|
||||
assert len(flush_futures) == 2
|
||||
|
||||
|
||||
async def test_history_priming_manager_retries_after_cancelled_flush(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""A rider re-flushes when the flush it rode was cancelled before completing.
|
||||
|
||||
If the condition performing a generation's shared flush is cancelled by its
|
||||
timeout while awaiting the commit, the riders must not read against the
|
||||
unflushed queue — they perform a fresh flush instead. Without that retry this
|
||||
test fails: the rider would proceed on the cancelled flush and never make a
|
||||
second one.
|
||||
"""
|
||||
manager = _HistoryPrimingManager(hass)
|
||||
instance = get_instance(hass)
|
||||
|
||||
flush_futures: list[asyncio.Future[None]] = []
|
||||
|
||||
def _spy_commit_future() -> asyncio.Future[None]:
|
||||
fut = hass.loop.create_future()
|
||||
flush_futures.append(fut)
|
||||
return fut
|
||||
|
||||
async def _job(_recorder: Recorder) -> str:
|
||||
return "done"
|
||||
|
||||
with patch.object(instance, "async_get_commit_future", _spy_commit_future):
|
||||
# C0 takes the lobby so c1 and c2 form one generation behind it.
|
||||
c0 = asyncio.create_task(manager.async_prime(_job))
|
||||
await _advance_until(lambda: len(flush_futures) == 1)
|
||||
c1 = asyncio.create_task(manager.async_prime(_job))
|
||||
c2 = asyncio.create_task(manager.async_prime(_job))
|
||||
flush_futures[0].set_result(None)
|
||||
assert await c0 == "done"
|
||||
|
||||
# c1 performs the generation's flush (the second one) and c2 rides it.
|
||||
await _advance_until(lambda: len(flush_futures) == 2)
|
||||
|
||||
# c1 is cancelled mid-flush, as its timeout would do. c2 must then run
|
||||
# its own fresh flush rather than ride c1's cancelled one.
|
||||
c1.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await c1
|
||||
await _advance_until(lambda: len(flush_futures) == 3)
|
||||
|
||||
flush_futures[2].set_result(None)
|
||||
assert await c2 == "done"
|
||||
|
||||
|
||||
async def test_history_priming_manager_cancelled_lobby_waiter(
|
||||
@@ -6037,11 +5987,14 @@ async def test_history_priming_manager_cancelled_lobby_waiter(
|
||||
|
||||
with patch.object(instance, "async_get_commit_future", _spy_commit_future):
|
||||
c0 = asyncio.create_task(manager.async_prime(_job))
|
||||
await _advance_until(lambda: len(flush_futures) == 1)
|
||||
# A second priming parks in the lobby (reached in one step, as its lock
|
||||
# acquire is uncontended), then its timeout cancels it.
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if flush_futures:
|
||||
break
|
||||
# A second priming parks in the lobby, then its timeout cancels it.
|
||||
waiter = asyncio.create_task(manager.async_prime(_job))
|
||||
await asyncio.sleep(0)
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
waiter.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await waiter
|
||||
@@ -6050,7 +6003,9 @@ async def test_history_priming_manager_cancelled_lobby_waiter(
|
||||
flush_futures[0].set_result(None)
|
||||
assert await c0 == "done"
|
||||
later = asyncio.create_task(manager.async_prime(_job))
|
||||
await _advance_until(lambda: len(flush_futures) == 2)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
assert len(flush_futures) == 2
|
||||
flush_futures[1].set_result(None)
|
||||
assert await later == "done"
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Test service helpers."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -17,7 +16,7 @@ from homeassistant.const import (
|
||||
STATE_ON,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
@@ -806,20 +805,16 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test on_entities_update callback reports added and removed entities."""
|
||||
entity_updates: list[tuple[set[str], set[str], set[str]]] = []
|
||||
entity_updates: list[tuple[set[str], set[str]]] = []
|
||||
|
||||
@callback
|
||||
def state_change_callback(event: target.TargetStateChangedData) -> None:
|
||||
"""Handle state change events."""
|
||||
|
||||
@callback
|
||||
def on_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
"""Track entity set changes."""
|
||||
entity_updates.append((added, removed, set(entity_states)))
|
||||
entity_updates.append((added, removed))
|
||||
|
||||
config_entry = MockConfigEntry(domain="test")
|
||||
config_entry.add_to_hass(hass)
|
||||
@@ -849,10 +844,9 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
on_entities_update=on_entities_update,
|
||||
)
|
||||
|
||||
# Initial setup fires on_entities_update with all entities as "added".
|
||||
# The states mapping covers the currently targeted entities.
|
||||
# Initial setup fires on_entities_update with all entities as "added"
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == ({entity_a.entity_id}, set(), {entity_a.entity_id})
|
||||
assert entity_updates[-1] == ({entity_a.entity_id}, set())
|
||||
entity_updates.clear()
|
||||
|
||||
# Add label to entity_b → added
|
||||
@@ -860,11 +854,7 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == (
|
||||
{entity_b.entity_id},
|
||||
set(),
|
||||
{entity_a.entity_id, entity_b.entity_id},
|
||||
)
|
||||
assert entity_updates[-1] == ({entity_b.entity_id}, set())
|
||||
entity_updates.clear()
|
||||
|
||||
# Remove label from entity_a → removed
|
||||
@@ -872,7 +862,7 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == (set(), {entity_a.entity_id}, {entity_b.entity_id})
|
||||
assert entity_updates[-1] == (set(), {entity_a.entity_id})
|
||||
entity_updates.clear()
|
||||
|
||||
# Remove label from entity_b → removed
|
||||
@@ -880,7 +870,7 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == (set(), {entity_b.entity_id}, set())
|
||||
assert entity_updates[-1] == (set(), {entity_b.entity_id})
|
||||
entity_updates.clear()
|
||||
|
||||
# Re-add both labels at once — entity_a first, then entity_b
|
||||
@@ -890,12 +880,8 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(entity_updates) == 2
|
||||
assert entity_updates[0] == ({entity_a.entity_id}, set(), {entity_a.entity_id})
|
||||
assert entity_updates[1] == (
|
||||
{entity_b.entity_id},
|
||||
set(),
|
||||
{entity_a.entity_id, entity_b.entity_id},
|
||||
)
|
||||
assert entity_updates[0] == ({entity_a.entity_id}, set())
|
||||
assert entity_updates[1] == ({entity_b.entity_id}, set())
|
||||
entity_updates.clear()
|
||||
|
||||
# After unsubscribing, no more callbacks
|
||||
@@ -917,11 +903,7 @@ async def test_async_track_target_selector_cancels_update_task_on_unsubscribe(
|
||||
def state_change_callback(event: target.TargetStateChangedData) -> None:
|
||||
"""Handle state change events."""
|
||||
|
||||
async def on_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
async def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
nonlocal cancelled
|
||||
started.set()
|
||||
try:
|
||||
|
||||
+47
-249
@@ -4735,11 +4735,15 @@ async def test_entity_trigger_duration_each_cancelled_when_entity_leaves_target(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test an each duration timer is cancelled when its entity is untargeted.
|
||||
"""Test an each duration timer when its entity is untargeted mid-wait.
|
||||
|
||||
A pending `for:` wait does not outlive the entity's membership of the
|
||||
A pending `for:` wait should not outlive the entity's membership of the
|
||||
target: when a registry change removes the entity from the target, the
|
||||
timer is cancelled and the trigger does not fire.
|
||||
timer should be cancelled.
|
||||
|
||||
This test documents existing unwanted behavior: the duration timer
|
||||
keeps running and the trigger fires for an entity which is no longer
|
||||
targeted.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test Each Removal")
|
||||
@@ -4768,66 +4772,13 @@ async def test_entity_trigger_duration_each_cancelled_when_entity_leaves_target(
|
||||
entity_registry.async_update_entity(entry.entity_id, labels=set())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original duration — should NOT fire
|
||||
# Advance past the original duration. Unwanted: the trigger fires for
|
||||
# the no-longer-targeted entity.
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_each_cancelled_on_entity_rename(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test an each duration timer is cancelled when its entity is renamed.
|
||||
|
||||
A rename is delivered as the old entity id leaving the target and the
|
||||
new entity id joining it, so the pending per-entity timer is cancelled
|
||||
rather than transferred to the new id. The entity stays on and targeted
|
||||
under the new id, but never had an off→on transition as the new id, so
|
||||
no timer is armed for it and the trigger does not fire.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test Each Rename")
|
||||
entry = entity_registry.async_get_or_create("test", "test", "labeled")
|
||||
entity_registry.async_update_entity(entry.entity_id, labels={label.label_id})
|
||||
hass.states.async_set(entry.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass,
|
||||
[],
|
||||
BEHAVIOR_EACH,
|
||||
calls,
|
||||
duration={"seconds": 5},
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
hass.states.async_set(entry.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Rename the entity mid-wait. It keeps its label, so it stays targeted
|
||||
# under the new id; the state follows the rename like the entity
|
||||
# component does.
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
new_entity_id = "test.renamed"
|
||||
entity_registry.async_update_entity(entry.entity_id, new_entity_id=new_entity_id)
|
||||
hass.states.async_remove(entry.entity_id)
|
||||
hass.states.async_set(new_entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original duration — should NOT fire: the timer for
|
||||
# the old id was cancelled, and the new id never transitioned off→on.
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entry.entity_id
|
||||
|
||||
unsub()
|
||||
|
||||
@@ -4837,12 +4788,17 @@ async def test_entity_trigger_duration_all_survives_entity_leaving_target(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test a pending all timer ignores an entity removed from the target.
|
||||
"""Test a pending all timer when an entity is removed from the target.
|
||||
|
||||
Once an entity is removed from the target, it no longer gates the
|
||||
all-match: the timer keeps running and fires if the remaining targeted
|
||||
entities stay matching, even if the removed entity changes to a
|
||||
non-matching state.
|
||||
Once an entity is removed from the target it should no longer gate the
|
||||
all-match: the timer should keep running and fire if the remaining
|
||||
targeted entities stay matching, even if the removed entity changes to
|
||||
a non-matching state.
|
||||
|
||||
This test documents existing unwanted behavior: the duration cancel
|
||||
check still tracks the entity set frozen when the timer was armed, so
|
||||
the removed entity turning off cancels the timer and the trigger does
|
||||
not fire.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test All Removal")
|
||||
@@ -4870,8 +4826,8 @@ async def test_entity_trigger_duration_all_survives_entity_leaving_target(
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# B leaves the target mid-wait and turns off: it no longer gates the
|
||||
# all-match, so the timer keeps running.
|
||||
# B leaves the target mid-wait and turns off: it should no longer gate
|
||||
# the all-match.
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels=set())
|
||||
@@ -4879,177 +4835,14 @@ async def test_entity_trigger_duration_all_survives_entity_leaving_target(
|
||||
hass.states.async_set(entry_b.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The remaining targeted entity stayed on for the duration — fires
|
||||
# Unwanted: the remaining targeted entity stayed on for the duration,
|
||||
# so the trigger should fire — but the no-longer-targeted entity
|
||||
# cancelled the timer.
|
||||
freezer.tick(datetime.timedelta(seconds=4))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entry_b.entity_id
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("added_entity_state", "expected_calls"),
|
||||
[
|
||||
pytest.param(STATE_OFF, 0, id="added_entity_breaks_all_match"),
|
||||
pytest.param(STATE_ON, 1, id="added_entity_keeps_all_match"),
|
||||
],
|
||||
)
|
||||
async def test_entity_trigger_duration_all_revalidated_when_entity_joins_target(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
added_entity_state: str,
|
||||
expected_calls: int,
|
||||
) -> None:
|
||||
"""Test a pending all timer is re-validated when an entity is added.
|
||||
|
||||
An entity added to the target mid-wait participates in the all-match:
|
||||
a non-matching entity cancels the pending timer, while a matching one
|
||||
leaves it running and the trigger fires after the duration.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test All Addition")
|
||||
entry_a = entity_registry.async_get_or_create("test", "test", "labeled_a")
|
||||
entry_b = entity_registry.async_get_or_create("test", "test", "labeled_b")
|
||||
entity_registry.async_update_entity(entry_a.entity_id, labels={label.label_id})
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels={label.label_id})
|
||||
hass.states.async_set(entry_a.entity_id, STATE_OFF)
|
||||
hass.states.async_set(entry_b.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass,
|
||||
[],
|
||||
BEHAVIOR_ALL,
|
||||
calls,
|
||||
duration={"seconds": 5},
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
hass.states.async_set(entry_a.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entry_b.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# A third entity joins the target mid-wait
|
||||
entry_c = entity_registry.async_get_or_create("test", "test", "labeled_c")
|
||||
hass.states.async_set(entry_c.entity_id, added_entity_state)
|
||||
await hass.async_block_till_done()
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_c.entity_id, labels={label.label_id})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the duration
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == expected_calls
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_first_cancelled_when_match_leaves_target(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test a pending first timer when the matching entity is untargeted.
|
||||
|
||||
Removing the only matching entity from the target mid-wait leaves the
|
||||
target without a matching entity, so the pending timer is cancelled and
|
||||
the trigger does not fire.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test First Removal")
|
||||
entry_a = entity_registry.async_get_or_create("test", "test", "labeled_a")
|
||||
entry_b = entity_registry.async_get_or_create("test", "test", "labeled_b")
|
||||
entity_registry.async_update_entity(entry_a.entity_id, labels={label.label_id})
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels={label.label_id})
|
||||
hass.states.async_set(entry_a.entity_id, STATE_OFF)
|
||||
hass.states.async_set(entry_b.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass,
|
||||
[],
|
||||
BEHAVIOR_FIRST,
|
||||
calls,
|
||||
duration={"seconds": 5},
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
hass.states.async_set(entry_a.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# The only matching entity leaves the target mid-wait
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_a.entity_id, labels=set())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original duration — should NOT fire
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_first_survives_entity_joining_target(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test a pending first timer when an entity is added to the target.
|
||||
|
||||
The added entity does not match, but at least one targeted entity
|
||||
still does, so the timer keeps running and the trigger fires.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test First Addition")
|
||||
entry_a = entity_registry.async_get_or_create("test", "test", "labeled_a")
|
||||
entity_registry.async_update_entity(entry_a.entity_id, labels={label.label_id})
|
||||
hass.states.async_set(entry_a.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass,
|
||||
[],
|
||||
BEHAVIOR_FIRST,
|
||||
calls,
|
||||
duration={"seconds": 5},
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
hass.states.async_set(entry_a.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# A non-matching entity joins the target mid-wait
|
||||
entry_b = entity_registry.async_get_or_create("test", "test", "labeled_b")
|
||||
hass.states.async_set(entry_b.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels={label.label_id})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The matching entity stayed on for the duration — fires
|
||||
freezer.tick(datetime.timedelta(seconds=4))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entry_a.entity_id
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@@ -5058,15 +4851,19 @@ async def test_entity_trigger_first_nested_state_revert(
|
||||
) -> None:
|
||||
"""Test a synchronous bus listener reverting a state change.
|
||||
|
||||
A synchronous bus listener turns entity_a off again from within the
|
||||
dispatch of its turn-on event. The event bus queues the nested off-event
|
||||
and dispatches it after the on-event, so the target tracker observes the
|
||||
two events in fire order and its tracked states view stays consistent.
|
||||
Writing states from a synchronous bus listener during state change
|
||||
dispatch is not supported: the nested state write is dispatched to the
|
||||
target tracker before the event that caused it, inverting per-entity
|
||||
delivery order. Supported state change tracking via
|
||||
async_track_state_change_event or async_track_state_change_filtered is
|
||||
deferred precisely so callbacks cannot run inside the dispatch loop and
|
||||
cause this.
|
||||
|
||||
The trigger fires for entity_a — it was the first entity to match, even
|
||||
though it was immediately reverted — consistent with behavior each and
|
||||
with a same-iteration blip. Because the view is not left stale, entity_b
|
||||
turning on later is correctly recognized as a first match and fires too.
|
||||
This test documents the resulting behavior rather than guaranteeing it:
|
||||
both of entity_a's events are evaluated against the live state machine,
|
||||
which already shows the entity off again, so the trigger does not fire
|
||||
for the blip; entity_b turning on later counts as the only match and
|
||||
fires.
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
@@ -5084,6 +4881,9 @@ async def test_entity_trigger_first_nested_state_revert(
|
||||
):
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
|
||||
# Registered before the trigger is armed, so it runs before the state
|
||||
# change tracker's bus listener and its nested write is dispatched to
|
||||
# the tracker first.
|
||||
unsub_revert = hass.bus.async_listen(EVENT_STATE_CHANGED, revert_entity_a)
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
@@ -5092,19 +4892,17 @@ async def test_entity_trigger_first_nested_state_revert(
|
||||
)
|
||||
|
||||
# entity_a turns on and is synchronously reverted to off. The trigger
|
||||
# receives (off→on) then (on→off) in fire order and fires for the
|
||||
# on-event: entity_a was the first matching entity.
|
||||
# receives (on→off) then (off→on); the on-event counts no matches in
|
||||
# the live state machine and the trigger does not fire.
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_a
|
||||
assert len(calls) == 0
|
||||
|
||||
# entity_a is off again, so entity_b is now the first matching entity
|
||||
# and the trigger fires for it.
|
||||
# entity_a is off, so entity_b is the first matching entity and fires.
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 2
|
||||
assert calls[1]["entity_id"] == entity_b
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_b
|
||||
|
||||
unsub()
|
||||
unsub_revert()
|
||||
|
||||
@@ -679,30 +679,12 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None:
|
||||
" 2020.12, please create a bug report at https://github.com/home-assistant/"
|
||||
"core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial",
|
||||
False,
|
||||
"Detected that custom integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial>=3.5",
|
||||
True,
|
||||
"Detected that integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial-asyncio",
|
||||
False,
|
||||
"Detected that custom integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"which should be replaced by pyserial-asyncio-fast. This will stop"
|
||||
" working in Home Assistant 2026.7, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
@@ -710,26 +692,8 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None:
|
||||
"pyserial-asyncio>=0.6",
|
||||
True,
|
||||
"Detected that integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial-asyncio-fast",
|
||||
False,
|
||||
"Detected that custom integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial-asyncio-fast>=0.6",
|
||||
True,
|
||||
"Detected that integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"which should be replaced by pyserial-asyncio-fast. This will stop"
|
||||
" working in Home Assistant 2026.7, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
|
||||
@@ -6,6 +6,9 @@ from itertools import chain
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
@@ -14,7 +17,6 @@ from homeassistant.const import (
|
||||
UnitOfBloodGlucoseConcentration,
|
||||
UnitOfConductivity,
|
||||
UnitOfDataRate,
|
||||
UnitOfDensity,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -126,7 +128,7 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo
|
||||
18.016,
|
||||
),
|
||||
CarbonMonoxideConcentrationConverter: (
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1.16441,
|
||||
),
|
||||
@@ -162,22 +164,22 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo
|
||||
InformationConverter: (UnitOfInformation.BITS, UnitOfInformation.BYTES, 8),
|
||||
MassConverter: (UnitOfMass.STONES, UnitOfMass.KILOGRAMS, 0.157473),
|
||||
MassVolumeConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
1000,
|
||||
),
|
||||
NitrogenDioxideConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.912503,
|
||||
),
|
||||
NitrogenMonoxideConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.247389,
|
||||
),
|
||||
OzoneConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.995417,
|
||||
),
|
||||
@@ -199,7 +201,7 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo
|
||||
1.609343,
|
||||
),
|
||||
SulphurDioxideConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
2.6633,
|
||||
),
|
||||
@@ -334,13 +336,13 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.16441,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
0.00116441,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# PPM to other units
|
||||
(
|
||||
@@ -353,51 +355,51 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1.16441,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1164.41,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# MICROGRAMS_PER_CUBIC_METER to other units
|
||||
(
|
||||
120000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
103056.5,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
(
|
||||
120000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
103.0565,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
(
|
||||
120000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
120,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# MILLIGRAMS_PER_CUBIC_METER to other units
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
103056.5,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
103.0565,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
120000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
],
|
||||
NitrogenDioxideConcentrationConverter: [
|
||||
@@ -405,11 +407,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.912503,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
62.744976,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
@@ -417,11 +419,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1912.503,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
0.062744976,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
@@ -443,11 +445,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.247389,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
96.200906,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
@@ -801,11 +803,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.995417,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
60.1378,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
@@ -813,11 +815,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1995.417,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
0.0601378,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
@@ -1003,11 +1005,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
2.6633,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
45.056879,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
@@ -1058,23 +1060,23 @@ _CONVERTED_VALUE: dict[
|
||||
# 1000 µg/m³ = 1 mg/m³
|
||||
(
|
||||
1000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
1,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# 2 mg/m³ = 2000 µg/m³
|
||||
(
|
||||
2,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
2000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# 3 g/m³ = 3000 mg/m³
|
||||
(
|
||||
3,
|
||||
UnitOfDensity.GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
3000,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
],
|
||||
VolumeConverter: [
|
||||
|
||||
Reference in New Issue
Block a user