This commit is contained in:
Franck Nijhof
2026-08-21 22:44:07 +02:00
committed by GitHub
58 changed files with 2239 additions and 282 deletions
@@ -83,6 +83,7 @@ _LOGGER = logging.getLogger(__name__)
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
_DEVICE_REGISTRY_UPDATE_FIELDS = ["name", "name_by_user"]
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
@@ -288,6 +289,15 @@ class DefaultAgent(ConversationEntity):
field in event_data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS
)
@callback
def _filter_device_registry_changes(
self, event_data: dr.EventDeviceRegistryUpdatedData
) -> bool:
"""Filter device registry changed events."""
return event_data["action"] == "update" and any(
field in event_data["changes"] for field in _DEVICE_REGISTRY_UPDATE_FIELDS
)
@callback
def _filter_state_changes(self, event_data: EventStateChangedData) -> bool:
"""Filter state changed events."""
@@ -312,6 +322,11 @@ class DefaultAgent(ConversationEntity):
self._async_clear_slot_list,
event_filter=self._filter_entity_registry_changes,
),
self.hass.bus.async_listen(
dr.EVENT_DEVICE_REGISTRY_UPDATED,
self._async_clear_slot_list,
event_filter=self._filter_device_registry_changes,
),
self.hass.bus.async_listen(
EVENT_STATE_CHANGED,
self._async_clear_slot_list,
@@ -716,7 +716,7 @@ class ScannerEntity(
config_entry_id=self.platform.config_entry.entry_id,
config_subentry_id=self.registry_entry.config_subentry_id,
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)},
default_name=self.hostname or self.mac_address,
name=self.hostname or self.mac_address,
)
# Link the entity's registry entry to the device
@@ -438,9 +438,9 @@ class DlnaDmrEntity(MediaPlayerEntity):
device_info = dr.DeviceInfo(
connections=connections,
default_manufacturer=self._device.manufacturer,
default_model=self._device.model_name,
default_name=self._device.name,
manufacturer=self._device.manufacturer,
model=self._device.model_name,
name=self._device.name,
)
self._attr_device_info = device_info
@@ -176,17 +176,32 @@
"stats_area": {
"default": "mdi:floor-plan"
},
"stats_area_mower": {
"default": "mdi:floor-plan"
},
"stats_time": {
"default": "mdi:timer-outline"
},
"stats_time_mower": {
"default": "mdi:timer-outline"
},
"total_stats_area": {
"default": "mdi:floor-plan"
},
"total_stats_area_mower": {
"default": "mdi:floor-plan"
},
"total_stats_cleanings": {
"default": "mdi:counter"
},
"total_stats_cleanings_mower": {
"default": "mdi:counter"
},
"total_stats_time": {
"default": "mdi:timer-outline"
},
"total_stats_time_mower": {
"default": "mdi:timer-outline"
}
},
"switch": {
+61 -25
View File
@@ -1,8 +1,8 @@
"""Ecovacs sensor module."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, override
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field, fields, replace
from typing import Any, Self, override
from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType
from deebot_client.device import Device
@@ -33,10 +33,10 @@ from homeassistant.const import (
UnitOfArea,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType
from . import EcovacsConfigEntry
from .const import LEGACY_SUPPORTED_LIFESPANS, SUPPORTED_LIFESPANS
@@ -49,6 +49,14 @@ from .entity import (
from .util import get_name_key, get_options, get_supported_entities
@dataclass(kw_only=True, frozen=True)
class EcovacsSensorDeviceTypeOverride:
"""Description values, which differ for a specific device type."""
native_unit_of_measurement: str | UndefinedType | None = UNDEFINED
translation_key: str | UndefinedType | None = UNDEFINED
@dataclass(kw_only=True, frozen=True)
class EcovacsSensorEntityDescription[EventT: Event](
EcovacsCapabilityEntityDescription,
@@ -57,15 +65,23 @@ class EcovacsSensorEntityDescription[EventT: Event](
"""Ecovacs sensor entity description."""
value_fn: Callable[[EventT], StateType]
native_unit_of_measurement_fn: Callable[[DeviceType], str | None] | None = None
device_type_overrides: Mapping[DeviceType, EcovacsSensorDeviceTypeOverride] = field(
default_factory=dict
)
def get_for(self, device: DeviceType) -> Self:
"""Get entity description for specific device type."""
if (overrides := self.device_type_overrides.get(device)) is None:
return self
@callback
def get_area_native_unit_of_measurement(device_type: DeviceType) -> str | None:
"""Get the area native unit of measurement based on device type."""
if device_type is DeviceType.MOWER:
return UnitOfArea.SQUARE_CENTIMETERS
return UnitOfArea.SQUARE_METERS
return replace(
self,
**{
f.name: value
for f in fields(overrides)
if (value := getattr(overrides, f.name)) is not UNDEFINED
},
)
ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
@@ -76,8 +92,14 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
value_fn=lambda e: e.area,
translation_key="stats_area",
device_class=SensorDeviceClass.AREA,
native_unit_of_measurement_fn=get_area_native_unit_of_measurement,
native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
suggested_unit_of_measurement=UnitOfArea.SQUARE_METERS,
device_type_overrides={
DeviceType.MOWER: EcovacsSensorDeviceTypeOverride(
native_unit_of_measurement=UnitOfArea.SQUARE_CENTIMETERS,
translation_key="stats_area_mower",
)
},
),
EcovacsSensorEntityDescription[StatsEvent](
key="stats_time",
@@ -87,6 +109,11 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
device_type_overrides={
DeviceType.MOWER: EcovacsSensorDeviceTypeOverride(
translation_key="stats_time_mower",
)
},
),
# TotalStats
EcovacsSensorEntityDescription[TotalStatsEvent](
@@ -97,6 +124,11 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.AREA,
native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
state_class=SensorStateClass.TOTAL_INCREASING,
device_type_overrides={
DeviceType.MOWER: EcovacsSensorDeviceTypeOverride(
translation_key="total_stats_area_mower",
)
},
),
EcovacsSensorEntityDescription[TotalStatsEvent](
capability_fn=lambda caps: caps.stats.total,
@@ -107,6 +139,11 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
state_class=SensorStateClass.TOTAL_INCREASING,
device_type_overrides={
DeviceType.MOWER: EcovacsSensorDeviceTypeOverride(
translation_key="total_stats_time_mower",
)
},
),
EcovacsSensorEntityDescription[TotalStatsEvent](
capability_fn=lambda caps: caps.stats.total,
@@ -114,6 +151,11 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
key="total_stats_cleanings",
translation_key="total_stats_cleanings",
state_class=SensorStateClass.TOTAL_INCREASING,
device_type_overrides={
DeviceType.MOWER: EcovacsSensorDeviceTypeOverride(
translation_key="total_stats_cleanings_mower",
)
},
),
EcovacsSensorEntityDescription[BatteryEvent](
capability_fn=lambda caps: caps.battery,
@@ -274,18 +316,12 @@ class EcovacsSensor(
**kwargs: Any,
) -> None:
"""Initialize entity."""
super().__init__(device, capability, entity_description, **kwargs)
if (
entity_description.native_unit_of_measurement_fn
and (
native_unit_of_measurement
:= entity_description.native_unit_of_measurement_fn(
device.capabilities.device_type
)
)
is not None
):
self._attr_native_unit_of_measurement = native_unit_of_measurement
super().__init__(
device,
capability,
entity_description.get_for(device.capabilities.device_type),
**kwargs,
)
@override
async def async_added_to_hass(self) -> None:
@@ -268,17 +268,32 @@
"stats_area": {
"name": "Area cleaned"
},
"stats_area_mower": {
"name": "Area mowed"
},
"stats_time": {
"name": "Cleaning duration"
},
"stats_time_mower": {
"name": "Mowing duration"
},
"total_stats_area": {
"name": "Total area cleaned"
},
"total_stats_area_mower": {
"name": "Total area mowed"
},
"total_stats_cleanings": {
"name": "Total cleanings"
},
"total_stats_cleanings_mower": {
"name": "Total mowings"
},
"total_stats_time": {
"name": "Total cleaning duration"
},
"total_stats_time_mower": {
"name": "Total mowing duration"
}
},
"switch": {
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==3.2.1"],
"requirements": ["pyenphase==4.0.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
@@ -21,7 +21,7 @@ from pyenphase import (
EnvoySystemConsumption,
EnvoySystemProduction,
)
from pyenphase.const import PHASENAMES
from pyenphase.const import PHASENAMES, SupportedFeatures
from pyenphase.models.acb import ACBChargeStatus, ACBSleepState
from pyenphase.models.meters import (
CtMeterStatus,
@@ -383,7 +383,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy CT sensor entity."""
value_fn: Callable[
[EnvoyMeterData],
[EnvoyMeterData | None],
int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None,
]
on_phase: str | None = None
@@ -586,7 +586,9 @@ CT_SENSORS = (
translation_key=(translation_key if translation_key != "" else key),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
value_fn=lambda ct: (
0 if ct is None or ct.status_flags is None else len(ct.status_flags)
),
cttype=cttype,
)
for cttype, key, translation_key in (
@@ -1020,7 +1022,9 @@ async def async_setup_entry(
) -> None:
"""Set up envoy sensor platform."""
coordinator = config_entry.runtime_data
envoy_data = coordinator.envoy.data
envoy = coordinator.envoy
assert envoy is not None
envoy_data = envoy.data
assert envoy_data is not None
_LOGGER.debug("Envoy data: %s", envoy_data)
@@ -1028,39 +1032,57 @@ async def async_setup_entry(
EnvoyProductionEntity(coordinator, description)
for description in PRODUCTION_SENSORS
]
if envoy_data.system_consumption:
# add unconditionally if TOTAL_CONSUMPTION is available to overcome
# None value at startup caused by envoy fw issues
if envoy.supported_features & SupportedFeatures.TOTAL_CONSUMPTION:
entities.extend(
EnvoyConsumptionEntity(coordinator, description)
for description in CONSUMPTION_SENSORS
)
if envoy_data.system_net_consumption:
# add unconditionally if NET_CONSUMPTION is available to overcome
# None value at startup caused by envoy fw issues
if envoy.supported_features & SupportedFeatures.NET_CONSUMPTION:
entities.extend(
EnvoyNetConsumptionEntity(coordinator, description)
for description in NET_CONSUMPTION_SENSORS
)
# For each production phase reported add production entities
if envoy_data.system_production_phases:
# if PRODUCTION is available and phases detected even if None
# to overcome None value at startup caused by envoy fw issues
if envoy.active_phase_count and (
envoy.supported_features & SupportedFeatures.PRODUCTION
):
entities.extend(
EnvoyProductionPhaseEntity(coordinator, description)
for use_phase, phase in envoy_data.system_production_phases.items()
for index, use_phase in enumerate(PHASENAMES)
for description in PRODUCTION_PHASE_SENSORS[use_phase]
if phase is not None
if index < (envoy.phase_count if envoy.phase_count > 1 else 0)
)
# For each consumption phase reported add consumption entities
if envoy_data.system_consumption_phases:
# if TOTAL_CONSUMPTION is available and phases detected even if None
# to overcome None value at startup caused by envoy fw issues
if (
envoy.active_phase_count
and envoy.phase_count > 1
and (envoy.supported_features & SupportedFeatures.TOTAL_CONSUMPTION)
):
entities.extend(
EnvoyConsumptionPhaseEntity(coordinator, description)
for use_phase, phase in envoy_data.system_consumption_phases.items()
for index, use_phase in enumerate(PHASENAMES)
for description in CONSUMPTION_PHASE_SENSORS[use_phase]
if phase is not None
if index < (envoy.phase_count if envoy.phase_count > 1 else 0)
)
# For each net_consumption phase reported add consumption entities
if envoy_data.system_net_consumption_phases:
# if NET_CONSUMPTION is available and phases detected even if None
# to overcome None value at startup caused by envoy fw issues
if envoy.active_phase_count and (
envoy.supported_features & SupportedFeatures.NET_CONSUMPTION
):
entities.extend(
EnvoyNetConsumptionPhaseEntity(coordinator, description)
for use_phase, phase in envoy_data.system_net_consumption_phases.items()
for index, use_phase in enumerate(PHASENAMES)
for description in NET_CONSUMPTION_PHASE_SENSORS[use_phase]
if phase is not None
if index < (envoy.phase_count if envoy.phase_count > 1 else 0)
)
# Add Current Transformer entities
if envoy_data.ctmeters:
@@ -1181,8 +1203,8 @@ class EnvoyProductionEntity(EnvoySystemSensorEntity):
@override
def native_value(self) -> int | None:
"""Return the state of the sensor."""
system_production = self.data.system_production
assert system_production is not None
if (system_production := self.data.system_production) is None:
return None
return self.entity_description.value_fn(system_production)
@@ -1195,8 +1217,8 @@ class EnvoyConsumptionEntity(EnvoySystemSensorEntity):
@override
def native_value(self) -> int | None:
"""Return the state of the sensor."""
system_consumption = self.data.system_consumption
assert system_consumption is not None
if (system_consumption := self.data.system_consumption) is None:
return None
return self.entity_description.value_fn(system_consumption)
@@ -1209,8 +1231,8 @@ class EnvoyNetConsumptionEntity(EnvoySystemSensorEntity):
@override
def native_value(self) -> int | None:
"""Return the state of the sensor."""
system_net_consumption = self.data.system_net_consumption
assert system_net_consumption is not None
if (system_net_consumption := self.data.system_net_consumption) is None:
return None
return self.entity_description.value_fn(system_net_consumption)
@@ -1225,8 +1247,11 @@ class EnvoyProductionPhaseEntity(EnvoySystemSensorEntity):
"""Return the state of the sensor."""
if TYPE_CHECKING:
assert self.entity_description.on_phase
assert self.data.system_production_phases
if self.data.system_production_phases is None:
return None
if self.entity_description.on_phase not in self.data.system_production_phases:
return None
if (
system_production := self.data.system_production_phases[
self.entity_description.on_phase
@@ -1247,8 +1272,11 @@ class EnvoyConsumptionPhaseEntity(EnvoySystemSensorEntity):
"""Return the state of the sensor."""
if TYPE_CHECKING:
assert self.entity_description.on_phase
assert self.data.system_consumption_phases
if self.data.system_consumption_phases is None:
return None
if self.entity_description.on_phase not in self.data.system_consumption_phases:
return None
if (
system_consumption := self.data.system_consumption_phases[
self.entity_description.on_phase
@@ -1269,8 +1297,14 @@ class EnvoyNetConsumptionPhaseEntity(EnvoySystemSensorEntity):
"""Return the state of the sensor."""
if TYPE_CHECKING:
assert self.entity_description.on_phase
assert self.data.system_net_consumption_phases
if self.data.system_net_consumption_phases is None:
return None
if (
self.entity_description.on_phase
not in self.data.system_net_consumption_phases
):
return None
if (
system_net_consumption := self.data.system_net_consumption_phases[
self.entity_description.on_phase
@@ -1293,6 +1327,8 @@ class EnvoyCTEntity(EnvoySystemSensorEntity):
"""Return the state of the CT sensor."""
if (cttype := self.entity_description.cttype) not in self.data.ctmeters:
return None
if self.data.ctmeters[cttype] is None:
return None
return self.entity_description.value_fn(self.data.ctmeters[cttype])
@@ -1315,6 +1351,8 @@ class EnvoyCTPhaseEntity(EnvoySystemSensorEntity):
cttype
]:
return None
if self.data.ctmeters_phases[cttype][phase] is None:
return None
return self.entity_description.value_fn(
self.data.ctmeters_phases[cttype][phase]
)
@@ -546,9 +546,9 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
connections={(CONNECTION_NETWORK_MAC, dev_mac)},
default_manufacturer="FRITZ!",
default_model="FRITZ!Box Tracked device",
default_name=device.hostname,
manufacturer="FRITZ!",
model="FRITZ!Box Tracked device",
name=device.hostname,
via_device_id=dr.async_get_device_id_by_identifier(
self.hass,
(DOMAIN, self.unique_id),
+33 -11
View File
@@ -261,6 +261,14 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
return await hass.async_add_executor_job(shutil.which, "go2rtc")
@dataclass(frozen=True)
class _SessionInfo:
"""Session info."""
ws_client: Go2RtcWsClient
camera: Camera
class WebRTCProvider(CameraWebRTCProvider):
"""WebRTC provider."""
@@ -276,7 +284,7 @@ class WebRTCProvider(CameraWebRTCProvider):
self._url = url
self._session = session
self._rest_client = rest_client
self._sessions: dict[str, Go2RtcWsClient] = {}
self._sessions: dict[str, _SessionInfo] = {}
self._supported_schemes: set[str] = set()
@property
@@ -310,9 +318,13 @@ class WebRTCProvider(CameraWebRTCProvider):
send_message(WebRTCError("go2rtc_webrtc_offer_failed", str(err)))
return
self._sessions[session_id] = ws_client = Go2RtcWsClient(
ws_client = Go2RtcWsClient(
self._session, self._url, source=get_camera_identifier(camera)
)
self._sessions[session_id] = _SessionInfo(
ws_client=ws_client,
camera=camera,
)
@callback
def on_messages(message: ReceiveMessages) -> None:
@@ -338,8 +350,8 @@ class WebRTCProvider(CameraWebRTCProvider):
) -> None:
"""Handle the WebRTC candidate."""
if ws_client := self._sessions.get(session_id):
await ws_client.send(WebRTCCandidate(candidate.candidate))
if session_info := self._sessions.get(session_id):
await session_info.ws_client.send(WebRTCCandidate(candidate.candidate))
else:
_LOGGER.debug("Unknown session %s. Ignoring candidate", session_id)
@@ -347,8 +359,8 @@ class WebRTCProvider(CameraWebRTCProvider):
@override
def async_close_session(self, session_id: str) -> None:
"""Close the session."""
ws_client = self._sessions.pop(session_id)
self._hass.async_create_task(ws_client.close())
if session_info := self._sessions.pop(session_id, None):
self._hass.async_create_task(session_info.ws_client.close())
@override
async def async_get_image(
@@ -366,7 +378,7 @@ class WebRTCProvider(CameraWebRTCProvider):
async def _update_stream_source(self, camera: Camera) -> None:
"""Update the stream source in go2rtc config if needed."""
if not (stream_source := await camera.stream_source()):
await self.teardown()
await self._close_camera_sessions(camera)
raise HomeAssistantError("Camera has no stream source")
if camera.platform.platform_name == "generic":
@@ -376,7 +388,7 @@ class WebRTCProvider(CameraWebRTCProvider):
stream_source = "ffmpeg:" + stream_source
if not self.async_is_supported(stream_source):
await self.teardown()
await self._close_camera_sessions(camera)
raise HomeAssistantError("Stream source is not supported by go2rtc")
camera_prefs = await get_dynamic_camera_stream_settings(
@@ -440,11 +452,20 @@ class WebRTCProvider(CameraWebRTCProvider):
else:
await self._rest_client.preload.disable(identifier)
async def _close_camera_sessions(self, camera: Camera) -> None:
for session_id in list(self._sessions):
session_info = self._sessions.get(session_id)
if session_info is None or session_info.camera != camera:
continue
# Unregister before closing, as closing yields to the event loop
del self._sessions[session_id]
await session_info.ws_client.close()
async def teardown(self) -> None:
"""Tear down the provider."""
for ws_client in self._sessions.values():
await ws_client.close()
self._sessions.clear()
while self._sessions:
_, session_info = self._sessions.popitem()
await session_info.ws_client.close()
@override
async def async_register_camera(
@@ -460,6 +481,7 @@ class WebRTCProvider(CameraWebRTCProvider):
camera: Camera,
) -> None:
"""Will be called when the provider is unregistered for a camera."""
await self._close_camera_sessions(camera)
identifier = get_camera_identifier(camera)
if identifier in await self._rest_client.preload.list():
await self._rest_client.preload.disable(identifier)
@@ -8,5 +8,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==9.1.0", "oauth2client==4.1.3", "ical==14.1.0"]
"requirements": ["gcal-sync==9.1.0", "oauth2client==4.1.3", "ical==14.1.1"]
}
@@ -8,5 +8,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["google-health-api==0.8.0"]
"requirements": ["google-health-api==0.9.0"]
}
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.101", "babel==2.18.0"]
"requirements": ["holidays==0.103", "babel==2.18.0"]
}
@@ -1,16 +1,29 @@
"""Offer event listening automation rules."""
from collections.abc import ItemsView, Mapping
import logging
from typing import Any
import voluptuous as vol
from homeassistant.const import CONF_EVENT_DATA, CONF_PLATFORM, EVENT_STATE_REPORTED
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_EVENT_DATA,
CONF_PLATFORM,
EVENT_STATE_REPORTED,
)
from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
template,
)
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import yaml as yaml_util
_LOGGER = logging.getLogger(__name__)
CONF_EVENT_TYPE = "event_type"
CONF_EVENT_CONTEXT = "context"
@@ -39,6 +52,58 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
)
async def async_validate_trigger_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate trigger config.
Warn if the trigger filters event_data.device_id on a pre-migration composite device
id - a device that was split into one device per config entry.
A templated device id is a Template (not a plain string) and is left alone.
"""
validated_config: ConfigType = TRIGGER_SCHEMA(config)
if (
CONF_EVENT_DATA in validated_config
and isinstance(
device_id := validated_config[CONF_EVENT_DATA].get(CONF_DEVICE_ID), str
)
and (
split_devices := dr.async_get(
hass
).async_get_devices_for_composite_device_id(device_id)
)
):
_log_composite_device_id_warning(hass, config, device_id, split_devices)
return validated_config
@callback
def _log_composite_device_id_warning(
hass: HomeAssistant,
config: ConfigType,
device_id: str,
split_devices: list[dr.DeviceEntry],
) -> None:
"""Warn that an event trigger filters on a split (pre-migration) device id."""
device_summaries: list[str] = []
for device in split_devices:
entry = hass.config_entries.async_get_entry(device.config_entry_id)
domain = entry.domain if entry else "unknown"
name = device.name_by_user or device.name or device.id
device_summaries.append(f"{name} ({device.id}) from the {domain} integration")
_LOGGER.warning(
"Event trigger filters on device '%s', which was split into one device per "
"integration and no longer exists, so the trigger can no longer fire. Update the "
"automation, script or template entity to filter on one of these devices instead: "
"%s.\nThe affected trigger is configured as:\n%s",
device_id,
", ".join(device_summaries),
yaml_util.dump(config),
)
def _schema_value(value: Any) -> Any:
if isinstance(value, list):
return vol.In(value)
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],
"requirements": ["ical==14.1.0"]
"requirements": ["ical==14.1.1"]
}
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling",
"requirements": ["ical==14.1.0"]
"requirements": ["ical==14.1.1"]
}
+2 -2
View File
@@ -34,8 +34,8 @@ class NetgearDeviceEntity(CoordinatorEntity[NetgearTrackerCoordinator]):
self._attr_unique_id = self._mac
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, self._mac)},
default_name=self._device_name,
default_model=device["device_model"],
name=self._device_name,
model=device["device_model"],
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, coordinator.router.unique_id),
@@ -476,18 +476,13 @@ class OverkizConfigFlow(
if discovery_info.type == "_kizboxdev._tcp.local.":
self._host = f"{discovery_info.hostname[:-1]}:{discovery_info.port}"
self._api_type = APIType.LOCAL
return await self._process_discovery(
gateway_id, updates={CONF_HOST: self._host}
)
return await self._process_discovery(gateway_id)
async def _process_discovery(
self, gateway_id: str, *, updates: dict[str, Any] | None = None
) -> ConfigFlowResult:
async def _process_discovery(self, gateway_id: str) -> ConfigFlowResult:
"""Handle discovery of a gateway."""
await self.async_set_unique_id(gateway_id)
self._abort_if_unique_id_configured(updates=updates)
self._abort_if_unique_id_configured()
self.context["title_placeholders"] = {"gateway_id": gateway_id}
return await self.async_step_user()
@@ -41,7 +41,7 @@ rules:
# Gold
docs-examples: todo
discovery-update-info: done
discovery-update-info: todo
entity-device-class: done
entity-translations: todo
docs-data-update: done
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["ical"],
"quality_scale": "silver",
"requirements": ["ical==14.1.0"]
"requirements": ["ical==14.1.1"]
}
@@ -20,5 +20,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.21.8"]
"requirements": ["reolink-aio==0.21.9"]
}
@@ -743,6 +743,7 @@ SMART_AI_NUMBER_ENTITIES = (
ReolinkSmartAINumberEntityDescription(
key="crossline_sensitivity",
smart_type="crossline",
cmd_key="527",
cmd_id=527,
translation_key="crossline_sensitivity",
entity_category=EntityCategory.CONFIG,
@@ -761,6 +762,7 @@ SMART_AI_NUMBER_ENTITIES = (
ReolinkSmartAINumberEntityDescription(
key="intrusion_sensitivity",
smart_type="intrusion",
cmd_key="529",
cmd_id=529,
translation_key="intrusion_sensitivity",
entity_category=EntityCategory.CONFIG,
@@ -779,6 +781,7 @@ SMART_AI_NUMBER_ENTITIES = (
ReolinkSmartAINumberEntityDescription(
key="linger_sensitivity",
smart_type="loitering",
cmd_key="531",
cmd_id=531,
translation_key="linger_sensitivity",
entity_category=EntityCategory.CONFIG,
@@ -797,6 +800,7 @@ SMART_AI_NUMBER_ENTITIES = (
ReolinkSmartAINumberEntityDescription(
key="forgotten_item_sensitivity",
smart_type="legacy",
cmd_key="549",
cmd_id=549,
translation_key="forgotten_item_sensitivity",
entity_registry_enabled_default=False,
@@ -813,6 +817,7 @@ SMART_AI_NUMBER_ENTITIES = (
ReolinkSmartAINumberEntityDescription(
key="taken_item_sensitivity",
smart_type="loss",
cmd_key="551",
cmd_id=551,
translation_key="taken_item_sensitivity",
entity_registry_enabled_default=False,
@@ -829,6 +834,7 @@ SMART_AI_NUMBER_ENTITIES = (
ReolinkSmartAINumberEntityDescription(
key="intrusion_delay",
smart_type="intrusion",
cmd_key="529",
cmd_id=529,
translation_key="intrusion_delay",
entity_registry_enabled_default=False,
@@ -847,6 +853,7 @@ SMART_AI_NUMBER_ENTITIES = (
ReolinkSmartAINumberEntityDescription(
key="linger_delay",
smart_type="loitering",
cmd_key="531",
cmd_id=531,
translation_key="linger_delay",
entity_registry_enabled_default=False,
@@ -864,6 +871,7 @@ SMART_AI_NUMBER_ENTITIES = (
ReolinkSmartAINumberEntityDescription(
key="forgotten_item_delay",
smart_type="legacy",
cmd_key="549",
cmd_id=549,
translation_key="forgotten_item_delay",
entity_registry_enabled_default=False,
@@ -882,6 +890,7 @@ SMART_AI_NUMBER_ENTITIES = (
ReolinkSmartAINumberEntityDescription(
key="taken_item_delay",
smart_type="loss",
cmd_key="551",
cmd_id=551,
translation_key="taken_item_delay",
entity_registry_enabled_default=False,
+5 -3
View File
@@ -63,11 +63,10 @@ THERMOSTAT_TO_HA_MODE = {
"cool": HVACMode.COOL,
"dry": HVACMode.DRY,
"heat": HVACMode.HEAT,
"floor_heating": HVACMode.HEAT,
"ventilation": HVACMode.FAN_ONLY,
}
HA_TO_THERMOSTAT_MODE = {value: key for key, value in THERMOSTAT_TO_HA_MODE.items()}
PRESET_FROST_PROTECTION = "frost_protection"
@@ -138,6 +137,9 @@ class RpcLinkedgoThermostatClimate(ShellyRpcAttributeEntity, ClimateEntity):
self._attr_hvac_modes = [HVACMode.OFF] + [
THERMOSTAT_TO_HA_MODE[mode] for mode in modes
]
self._ha_to_thermostat_mode = {
THERMOSTAT_TO_HA_MODE[mode]: mode for mode in modes
}
@property
def _status(self) -> dict[str, Any]:
@@ -253,7 +255,7 @@ class RpcLinkedgoThermostatClimate(ShellyRpcAttributeEntity, ClimateEntity):
await self.coordinator.device.enum_set(
get_rpc_key_id(self._working_mode_key),
HA_TO_THERMOSTAT_MODE[hvac_mode],
self._ha_to_thermostat_mode[hvac_mode],
)
@override
+2 -2
View File
@@ -1233,7 +1233,7 @@ RPC_SENSORS: Final = {
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
removal_condition=lambda _, status, key: (
DRIVER_MISSING_ERROR in status[key].get("errors", [])
DRIVER_MISSING_ERROR in (status[key].get("errors") or [])
),
),
"rssi": RpcSensorDescription(
@@ -1264,7 +1264,7 @@ RPC_SENSORS: Final = {
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
removal_condition=lambda _, status, key: (
DRIVER_MISSING_ERROR in status[key].get("errors", [])
DRIVER_MISSING_ERROR in (status[key].get("errors") or [])
),
),
"battery": RpcSensorDescription(
@@ -120,6 +120,7 @@ class SignalNotificationService(BaseNotificationService):
self._signal_cli_rest_api.send_message(
message,
recipients,
notify_self=True,
filenames=filenames,
attachments_as_bytes=attachments_as_bytes,
text_mode="normal" if data is None else data.get(ATTR_TEXTMODE),
@@ -288,6 +288,9 @@ def build_item_response(
thumbnail = None
title = None
# Library listings such as Albums and Artists are browsed, not played; only a
# single album resolved below can be played as a whole.
playable = False
# Fetch album info for titles and thumbnails
# Can't be extracted from track info
@@ -303,7 +306,12 @@ def build_item_response(
item = get_media(media_library, idstring, search_type)
title = getattr(item, "title", None)
thumbnail = get_thumbnail_url(search_type, payload["idstring"])
# The browse image proxy round-trips this back to async_get_browse_image,
# which matches on MediaType, not on the Sonos search type.
thumbnail = get_thumbnail_url(
SONOS_TO_MEDIA_TYPES[search_type], payload["idstring"]
)
playable = can_play(search_type)
if not title:
title = _get_title(id_string=payload["idstring"])
@@ -328,7 +336,7 @@ def build_item_response(
media_content_id=payload["idstring"],
media_content_type=payload["search_type"],
children=children,
can_play=can_play(payload["search_type"]),
can_play=playable,
can_expand=can_expand(payload["search_type"]),
)
@@ -28,6 +28,7 @@ class SuplaCoordinator(DataUpdateCoordinator[dict[int, dict]]):
super().__init__(
hass,
_LOGGER,
config_entry=None,
name=f"supla-{server_name}",
update_interval=SCAN_INTERVAL,
)
+2 -2
View File
@@ -91,8 +91,8 @@ def async_client_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo:
client = hub.api.clients[obj_id]
return DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, obj_id)},
default_manufacturer=client.oui,
default_name=client.name or client.hostname,
manufacturer=client.oui,
name=client.name or client.hostname,
)
@@ -202,9 +202,12 @@ class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity):
# Audio settings
if data.audio_settings:
self._attr_volume_level = (
float(data.audio_settings[VIZIO_VOLUME].value) / self._max_volume
)
if VIZIO_VOLUME in data.audio_settings:
self._attr_volume_level = (
float(data.audio_settings[VIZIO_VOLUME].value) / self._max_volume
)
else:
self._attr_volume_level = None
if VIZIO_MUTE in data.audio_settings:
self._attr_is_volume_muted = (
str(data.audio_settings[VIZIO_MUTE].value).lower() == VIZIO_MUTE_ON
+1 -1
View File
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["volvocarsapi"],
"quality_scale": "platinum",
"requirements": ["volvocarsapi==0.4.3"]
"requirements": ["volvocarsapi==0.4.4"]
}
@@ -64,7 +64,7 @@ class WolButton(ButtonEntity):
self._attr_unique_id = dr.format_mac(mac_address)
self._attr_device_info = dr.DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, self._attr_unique_id)},
default_name=name,
name=name,
)
@override
@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["aiowebostv"],
"quality_scale": "platinum",
"requirements": ["aiowebostv==0.9.1"],
"requirements": ["aiowebostv==0.9.2"],
"ssdp": [
{
"st": "urn:lge-com:service:webos-second-screen:1"
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.101"]
"requirements": ["holidays==0.103"]
}
+1 -1
View File
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 8
PATCH_VERSION: Final = "2"
PATCH_VERSION: Final = "3"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.8.2"
version = "2026.8.3"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+7 -7
View File
@@ -471,7 +471,7 @@ aiowatttime==0.1.1
aiowebdav2==0.6.2
# homeassistant.components.webostv
aiowebostv==0.9.1
aiowebostv==0.9.2
# homeassistant.components.withings
aiowithings==3.1.6
@@ -1140,7 +1140,7 @@ google-cloud-texttospeech==2.25.1
google-genai==1.59.0
# homeassistant.components.google_health
google-health-api==0.8.0
google-health-api==0.9.0
# homeassistant.components.google_travel_time
google-maps-routing==0.6.15
@@ -1275,7 +1275,7 @@ hole==0.9.2
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.101
holidays==0.103
# homeassistant.components.frontend
home-assistant-frontend==20260729.7
@@ -1326,7 +1326,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
# homeassistant.components.remote_calendar
ical==14.1.0
ical==14.1.1
# homeassistant.components.caldav
icalendar==6.3.1
@@ -2171,7 +2171,7 @@ pyegps==0.2.5
pyemoncms==0.1.3
# homeassistant.components.enphase_envoy
pyenphase==3.2.1
pyenphase==4.0.0
# homeassistant.components.envertech_evt800
pyenvertechevt800==0.2.4
@@ -2919,7 +2919,7 @@ renault-api==0.5.12
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.21.8
reolink-aio==0.21.9
# homeassistant.components.radio_frequency
rf-protocols==4.3.0
@@ -3339,7 +3339,7 @@ voip-utils==0.4.0
volkszaehler==0.4.0
# homeassistant.components.volvo
volvocarsapi==0.4.3
volvocarsapi==0.4.4
# homeassistant.components.verisure
vsure==2.10.0
@@ -492,6 +492,54 @@ async def test_duplicated_names_resolved_with_device_area(
assert result.response.intent.slots.get("name", {}).get("text") == name
@pytest.mark.usefixtures("init_components")
async def test_device_rename_refreshes_slot_list(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test renaming a device makes the entity matchable by its new computed name."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections=set(),
identifiers={("demo", "device-1")},
name="Kitchen",
)
light = entity_registry.async_get_or_create(
"light",
"demo",
"1234",
device_id=device.id,
has_entity_name=True,
original_name="Light",
)
hass.states.async_set(light.entity_id, "off")
expose_entity(hass, light.entity_id, True)
# Populate the slot list cache: the current computed name matches.
calls = async_mock_service(hass, "light", "turn_on")
result = await conversation.async_converse(
hass, "turn on Kitchen Light", None, Context(), None
)
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
# Renaming the device changes the light's computed name to "Bedroom Light".
device_registry.async_update_device(device.id, name_by_user="Bedroom")
await hass.async_block_till_done()
# The new name is now matchable.
calls = async_mock_service(hass, "light", "turn_on")
result = await conversation.async_converse(
hass, "turn on Bedroom Light", None, Context(), None
)
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
@pytest.mark.usefixtures("init_components")
async def test_trigger_sentences(hass: HomeAssistant) -> None:
"""Test registering/unregistering/matching a few trigger sentences."""
@@ -213,7 +213,7 @@
'sensor.e1234567890000000003_filter_lifespan',
])
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_area_cleaned:entity-registry]
# name: test_sensors[5xu9h3][sensor.goat_g1_area_mowed:entity-registry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -227,7 +227,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.goat_g1_area_cleaned',
'entity_id': 'sensor.goat_g1_area_mowed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -235,7 +235,7 @@
'labels': set({
}),
'name': None,
'object_id_base': 'Area cleaned',
'object_id_base': 'Area mowed',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -246,25 +246,25 @@
}),
'original_device_class': <SensorDeviceClass.AREA: 'area'>,
'original_icon': None,
'original_name': 'Area cleaned',
'original_name': 'Area mowed',
'platform': 'ecovacs',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'stats_area',
'translation_key': 'stats_area_mower',
'unique_id': '8516fbb1-17f1-4194-0000000_stats_area',
'unit_of_measurement': <UnitOfArea.SQUARE_METERS: 'm²'>,
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_area_cleaned:state]
# name: test_sensors[5xu9h3][sensor.goat_g1_area_mowed:state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'area',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Goat G1 Area cleaned',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Goat G1 Area mowed',
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfArea.SQUARE_METERS: 'm²'>,
}),
'context': <ANY>,
'entity_id': 'sensor.goat_g1_area_cleaned',
'entity_id': 'sensor.goat_g1_area_mowed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -374,64 +374,6 @@
'state': 'unknown',
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_cleaning_duration:entity-registry]
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': 'sensor',
'entity_category': None,
'entity_id': 'sensor.goat_g1_cleaning_duration',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Cleaning duration',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Cleaning duration',
'platform': 'ecovacs',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'stats_time',
'unique_id': '8516fbb1-17f1-4194-0000000_stats_time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_cleaning_duration:state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Goat G1 Cleaning duration',
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'sensor.goat_g1_cleaning_duration',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.0',
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_error:entity-registry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -584,7 +526,65 @@
'state': 'unknown',
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_total_area_cleaned:entity-registry]
# name: test_sensors[5xu9h3][sensor.goat_g1_mowing_duration:entity-registry]
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': 'sensor',
'entity_category': None,
'entity_id': 'sensor.goat_g1_mowing_duration',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Mowing duration',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Mowing duration',
'platform': 'ecovacs',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'stats_time_mower',
'unique_id': '8516fbb1-17f1-4194-0000000_stats_time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_mowing_duration:state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Goat G1 Mowing duration',
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'sensor.goat_g1_mowing_duration',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.0',
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_total_area_mowed:entity-registry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -600,7 +600,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.goat_g1_total_area_cleaned',
'entity_id': 'sensor.goat_g1_total_area_mowed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -608,7 +608,7 @@
'labels': set({
}),
'name': None,
'object_id_base': 'Total area cleaned',
'object_id_base': 'Total area mowed',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -616,33 +616,33 @@
}),
'original_device_class': <SensorDeviceClass.AREA: 'area'>,
'original_icon': None,
'original_name': 'Total area cleaned',
'original_name': 'Total area mowed',
'platform': 'ecovacs',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'total_stats_area',
'translation_key': 'total_stats_area_mower',
'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_area',
'unit_of_measurement': <UnitOfArea.SQUARE_METERS: 'm²'>,
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_total_area_cleaned:state]
# name: test_sensors[5xu9h3][sensor.goat_g1_total_area_mowed:state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'area',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Goat G1 Total area cleaned',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Goat G1 Total area mowed',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfArea.SQUARE_METERS: 'm²'>,
}),
'context': <ANY>,
'entity_id': 'sensor.goat_g1_total_area_cleaned',
'entity_id': 'sensor.goat_g1_total_area_mowed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '60',
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_total_cleaning_duration:entity-registry]
# name: test_sensors[5xu9h3][sensor.goat_g1_total_mowing_duration:entity-registry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -658,7 +658,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.goat_g1_total_cleaning_duration',
'entity_id': 'sensor.goat_g1_total_mowing_duration',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -666,7 +666,7 @@
'labels': set({
}),
'name': None,
'object_id_base': 'Total cleaning duration',
'object_id_base': 'Total mowing duration',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
@@ -677,33 +677,33 @@
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Total cleaning duration',
'original_name': 'Total mowing duration',
'platform': 'ecovacs',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'total_stats_time',
'translation_key': 'total_stats_time_mower',
'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_time',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_total_cleaning_duration:state]
# name: test_sensors[5xu9h3][sensor.goat_g1_total_mowing_duration:state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Goat G1 Total cleaning duration',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Goat G1 Total mowing duration',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.HOURS: 'h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.goat_g1_total_cleaning_duration',
'entity_id': 'sensor.goat_g1_total_mowing_duration',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '40.0',
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_total_cleanings:entity-registry]
# name: test_sensors[5xu9h3][sensor.goat_g1_total_mowings:entity-registry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -719,7 +719,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.goat_g1_total_cleanings',
'entity_id': 'sensor.goat_g1_total_mowings',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -727,29 +727,29 @@
'labels': set({
}),
'name': None,
'object_id_base': 'Total cleanings',
'object_id_base': 'Total mowings',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Total cleanings',
'original_name': 'Total mowings',
'platform': 'ecovacs',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'total_stats_cleanings',
'translation_key': 'total_stats_cleanings_mower',
'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_cleanings',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_total_cleanings:state]
# name: test_sensors[5xu9h3][sensor.goat_g1_total_mowings:state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Goat G1 Total cleanings',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Goat G1 Total mowings',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'context': <ANY>,
'entity_id': 'sensor.goat_g1_total_cleanings',
'entity_id': 'sensor.goat_g1_total_mowings',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
+5 -5
View File
@@ -77,11 +77,11 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus):
(
"5xu9h3",
[
"sensor.goat_g1_area_cleaned",
"sensor.goat_g1_cleaning_duration",
"sensor.goat_g1_total_area_cleaned",
"sensor.goat_g1_total_cleaning_duration",
"sensor.goat_g1_total_cleanings",
"sensor.goat_g1_area_mowed",
"sensor.goat_g1_mowing_duration",
"sensor.goat_g1_total_area_mowed",
"sensor.goat_g1_total_mowing_duration",
"sensor.goat_g1_total_mowings",
"sensor.goat_g1_battery",
"sensor.goat_g1_ip_address",
"sensor.goat_g1_wi_fi_rssi",
+11 -7
View File
@@ -206,8 +206,8 @@ def _load_json_2_production_data(
if item := json_fixture["data"].get("system_consumption_phases"):
mocked_data.system_consumption_phases = {}
for sub_item, item_data in item.items():
mocked_data.system_consumption_phases[sub_item] = EnvoySystemConsumption(
**item_data
mocked_data.system_consumption_phases[sub_item] = (
None if not item_data else EnvoySystemConsumption(**item_data)
)
if item := json_fixture["data"].get("system_net_consumption_phases"):
mocked_data.system_net_consumption_phases = {}
@@ -218,8 +218,8 @@ def _load_json_2_production_data(
if item := json_fixture["data"].get("system_production_phases"):
mocked_data.system_production_phases = {}
for sub_item, item_data in item.items():
mocked_data.system_production_phases[sub_item] = EnvoySystemProduction(
**item_data
mocked_data.system_production_phases[sub_item] = (
None if not item_data else EnvoySystemProduction(**item_data)
)
if item := json_fixture["data"].get("acb_power"):
mocked_data.acb_power = EnvoyACBPower(**item)
@@ -232,15 +232,19 @@ def _load_json_2_meter_data(
if meters := json_fixture["data"].get("ctmeters"):
mocked_data.ctmeters = {}
[
mocked_data.ctmeters.update({meter: EnvoyMeterData(**meter_data)})
mocked_data.ctmeters.update(
{meter: None if not meter_data else EnvoyMeterData(**meter_data)}
)
for meter, meter_data in meters.items()
]
if meters := json_fixture["data"].get("ctmeters_phases"):
mocked_data.ctmeters_phases = {}
for meter, meter_data in meters.items():
meter_phase_data: dict[str, EnvoyMeterData] = {}
meter_phase_data: dict[str, EnvoyMeterData | None] = {}
[
meter_phase_data.update({phase: EnvoyMeterData(**phase_data)})
meter_phase_data.update(
{phase: None if not phase_data else EnvoyMeterData(**phase_data)}
)
for phase, phase_data in meter_data.items()
]
mocked_data.ctmeters_phases.update({meter: meter_phase_data})
@@ -3,7 +3,7 @@
"firmware": "7.1.2",
"part_number": "123456789",
"envoy_model": "Envoy, phases: 3, phase mode: split, net-consumption CT, production CT, storage CT",
"supported_features": 1659,
"supported_features": 1663,
"phase_mode": "three",
"phase_count": 3,
"active_phase_count": 3,
@@ -0,0 +1,639 @@
{
"serial_number": "1234",
"firmware": "7.1.2",
"part_number": "123456789",
"envoy_model": "Envoy, phases: 3, phase mode: split, net-consumption CT, production CT, storage CT",
"supported_features": 1663,
"phase_mode": "three",
"phase_count": 3,
"active_phase_count": 3,
"ct_meter_count": 2,
"consumption_meter_type": "net-consumption",
"production_meter_type": "production",
"storage_meter_type": "storage",
"data": {
"encharge_inventory": {
"123456": {
"admin_state": 6,
"admin_state_str": "ENCHG_STATE_READY",
"bmu_firmware_version": "2.1.34",
"comm_level_2_4_ghz": 4,
"comm_level_sub_ghz": 4,
"communicating": true,
"dc_switch_off": false,
"encharge_capacity": 3500,
"encharge_revision": 2,
"firmware_loaded_date": 1695330323,
"firmware_version": "2.6.5973_rel/22.11",
"installed_date": 1695330323,
"last_report_date": 1695769447,
"led_status": 17,
"max_cell_temp": 30,
"operating": true,
"part_number": "830-01760-r37",
"percent_full": 15,
"serial_number": "123456",
"temperature": 29,
"temperature_unit": "C",
"zigbee_dongle_fw_version": "100F"
}
},
"encharge_power": {
"123456": {
"apparent_power_mva": 0,
"real_power_mw": 0,
"soc": 15
}
},
"encharge_aggregate": {
"available_energy": 525,
"backup_reserve": 526,
"state_of_charge": 15,
"reserve_state_of_charge": 15,
"configured_reserve_state_of_charge": 15,
"max_available_capacity": 3500
},
"enpower": {
"grid_mode": "multimode-ongrid",
"admin_state": 24,
"admin_state_str": "ENPWR_STATE_OPER_CLOSED",
"comm_level_2_4_ghz": 5,
"comm_level_sub_ghz": 5,
"communicating": true,
"firmware_loaded_date": 1695330323,
"firmware_version": "1.2.2064_release/20.34",
"installed_date": 1695330323,
"last_report_date": 1695769447,
"mains_admin_state": "closed",
"mains_oper_state": "closed",
"operating": true,
"part_number": "830-01760-r37",
"serial_number": "654321",
"temperature": 79,
"temperature_unit": "F",
"zigbee_dongle_fw_version": "1009"
},
"system_consumption": null,
"system_net_consumption": {
"watt_hours_lifetime": 4321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 2341
},
"system_production": null,
"system_consumption_phases": {
"L1": null,
"L2": null,
"L3": null
},
"system_net_consumption_phases": {
"L1": {
"watt_hours_lifetime": 1321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 12341
},
"L2": {
"watt_hours_lifetime": 2321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 22341
},
"L3": {
"watt_hours_lifetime": 3321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 32341
}
},
"system_production_phases": {
"L1": null,
"L2": null,
"L3": null
},
"ctmeters": {
"production": {
"eid": "100000010",
"timestamp": 1708006110,
"energy_delivered": 11234,
"energy_received": 12345,
"active_power": 100,
"power_factor": 0.11,
"voltage": 111,
"current": 0.2,
"frequency": 50.1,
"state": "enabled",
"measurement_type": "production",
"metering_status": "normal",
"status_flags": ["production-imbalance", "power-on-unused-phase"]
},
"net-consumption": {
"eid": "100000020",
"timestamp": 1708006120,
"energy_delivered": 21234,
"energy_received": 22345,
"active_power": 101,
"power_factor": 0.21,
"voltage": 112,
"current": 0.3,
"frequency": 50.2,
"state": "enabled",
"measurement_type": "net-consumption",
"metering_status": "normal",
"status_flags": []
},
"storage": null,
"backfeed": null,
"load": {
"eid": "100000050",
"timestamp": 1708006120,
"energy_delivered": 51234,
"energy_received": 52345,
"active_power": 105,
"power_factor": 0.25,
"voltage": 115,
"current": 0.6,
"frequency": 50.6,
"state": "enabled",
"measurement_type": "load",
"metering_status": "normal",
"status_flags": []
},
"evse": {
"eid": "100000060",
"timestamp": 1708006120,
"energy_delivered": 61234,
"energy_received": 62345,
"active_power": 106,
"power_factor": 0.26,
"voltage": 116,
"current": 0.7,
"frequency": 50.7,
"state": "enabled",
"measurement_type": "evse",
"metering_status": "normal",
"status_flags": []
},
"pv3p": {
"eid": "100000070",
"timestamp": 1708006120,
"energy_delivered": 71234,
"energy_received": 72345,
"active_power": 107,
"power_factor": 0.27,
"voltage": 117,
"current": 0.8,
"frequency": 50.8,
"state": "enabled",
"measurement_type": "pv3p",
"metering_status": "normal",
"status_flags": []
}
},
"ctmeters_phases": {
"production": {
"L1": {
"eid": "100000011",
"timestamp": 1708006111,
"energy_delivered": 112341,
"energy_received": 123451,
"active_power": 20,
"power_factor": 0.12,
"voltage": 111,
"current": 0.2,
"frequency": 50.1,
"state": "enabled",
"measurement_type": "production",
"metering_status": "normal",
"status_flags": ["production-imbalance"]
},
"L2": {
"eid": "100000012",
"timestamp": 1708006112,
"energy_delivered": 112342,
"energy_received": 123452,
"active_power": 30,
"power_factor": 0.13,
"voltage": 111,
"current": 0.2,
"frequency": 50.1,
"state": "enabled",
"measurement_type": "production",
"metering_status": "normal",
"status_flags": ["power-on-unused-phase"]
},
"L3": {
"eid": "100000013",
"timestamp": 1708006113,
"energy_delivered": 112343,
"energy_received": 123453,
"active_power": 50,
"power_factor": 0.14,
"voltage": 111,
"current": 0.2,
"frequency": 50.1,
"state": "enabled",
"measurement_type": "production",
"metering_status": "normal",
"status_flags": []
}
},
"net-consumption": {
"L1": {
"eid": "100000021",
"timestamp": 1708006121,
"energy_delivered": 212341,
"energy_received": 223451,
"active_power": 21,
"power_factor": 0.22,
"voltage": 112,
"current": 0.3,
"frequency": 50.2,
"state": "enabled",
"measurement_type": "net-consumption",
"metering_status": "normal",
"status_flags": []
},
"L2": {
"eid": "100000022",
"timestamp": 1708006122,
"energy_delivered": 212342,
"energy_received": 223452,
"active_power": 31,
"power_factor": 0.23,
"voltage": 112,
"current": 0.3,
"frequency": 50.2,
"state": "enabled",
"measurement_type": "net-consumption",
"metering_status": "normal",
"status_flags": []
},
"L3": {
"eid": "100000023",
"timestamp": 1708006123,
"energy_delivered": 212343,
"energy_received": 223453,
"active_power": 51,
"power_factor": 0.24,
"voltage": 112,
"current": 0.3,
"frequency": 50.2,
"state": "enabled",
"measurement_type": "net-consumption",
"metering_status": "normal",
"status_flags": []
}
},
"storage": {
"L1": null,
"L2": {
"eid": "100000032",
"timestamp": 1708006122,
"energy_delivered": 312342,
"energy_received": 323452,
"active_power": 33,
"power_factor": 0.23,
"voltage": 112,
"current": 0.3,
"frequency": 50.2,
"state": "enabled",
"measurement_type": "storage",
"metering_status": "normal",
"status_flags": []
},
"L3": {
"eid": "100000033",
"timestamp": 1708006123,
"energy_delivered": 312343,
"energy_received": 323453,
"active_power": 53,
"power_factor": 0.24,
"voltage": 112,
"current": 0.3,
"frequency": 50.2,
"state": "enabled",
"measurement_type": "storage",
"metering_status": "normal",
"status_flags": []
}
},
"backfeed": {
"L1": null,
"L2": null,
"L3": null
},
"load": {
"L1": {
"eid": "100000051",
"timestamp": 1708006121,
"energy_delivered": 512341,
"energy_received": 523451,
"active_power": 115,
"power_factor": 0.25,
"voltage": 115,
"current": 5.1,
"frequency": 50.6,
"state": "enabled",
"measurement_type": "load",
"metering_status": "normal",
"status_flags": []
},
"L2": {
"eid": "100000052",
"timestamp": 1708006122,
"energy_delivered": 512342,
"energy_received": 523452,
"active_power": 125,
"power_factor": 0.25,
"voltage": 115,
"current": 5.2,
"frequency": 50.6,
"state": "enabled",
"measurement_type": "load",
"metering_status": "normal",
"status_flags": []
},
"L3": {
"eid": "100000052",
"timestamp": 1708006123,
"energy_delivered": 512343,
"energy_received": 523453,
"active_power": 135,
"power_factor": 0.25,
"voltage": 115,
"current": 5.3,
"frequency": 50.6,
"state": "enabled",
"measurement_type": "load",
"metering_status": "normal",
"status_flags": []
}
},
"evse": {
"L1": {
"eid": "100000061",
"timestamp": 1708006121,
"energy_delivered": 612341,
"energy_received": 623451,
"active_power": 116,
"power_factor": 0.26,
"voltage": 116,
"current": 6.1,
"frequency": 50.6,
"state": "enabled",
"measurement_type": "evse",
"metering_status": "normal",
"status_flags": []
},
"L2": {
"eid": "100000062",
"timestamp": 1708006122,
"energy_delivered": 612342,
"energy_received": 623452,
"active_power": 126,
"power_factor": 0.26,
"voltage": 116,
"current": 6.2,
"frequency": 50.6,
"state": "enabled",
"measurement_type": "evse",
"metering_status": "normal",
"status_flags": []
},
"L3": {
"eid": "100000063",
"timestamp": 1708006123,
"energy_delivered": 612343,
"energy_received": 623453,
"active_power": 136,
"power_factor": 0.26,
"voltage": 116,
"current": 6.3,
"frequency": 50.6,
"state": "enabled",
"measurement_type": "evse",
"metering_status": "normal",
"status_flags": []
}
},
"pv3p": {
"L1": {
"eid": "100000071",
"timestamp": 1708006127,
"energy_delivered": 712341,
"energy_received": 723451,
"active_power": 117,
"power_factor": 0.27,
"voltage": 117,
"current": 7.1,
"frequency": 50.7,
"state": "enabled",
"measurement_type": "pv3p",
"metering_status": "normal",
"status_flags": []
},
"L2": {
"eid": "100000072",
"timestamp": 1708006122,
"energy_delivered": 712342,
"energy_received": 723452,
"active_power": 127,
"power_factor": 0.27,
"voltage": 117,
"current": 7.2,
"frequency": 50.7,
"state": "enabled",
"measurement_type": "pv3p",
"metering_status": "normal",
"status_flags": []
},
"L3": {
"eid": "100000073",
"timestamp": 1708006123,
"energy_delivered": 712343,
"energy_received": 723453,
"active_power": 137,
"power_factor": 0.27,
"voltage": 117,
"current": 7.3,
"frequency": 50.7,
"state": "enabled",
"measurement_type": "pv3p",
"metering_status": "normal",
"status_flags": []
}
}
},
"dry_contact_status": {
"NC1": {
"id": "NC1",
"status": "open"
},
"NC2": {
"id": "NC2",
"status": "closed"
},
"NC3": {
"id": "NC3",
"status": "open"
}
},
"dry_contact_settings": {
"NC1": {
"id": "NC1",
"black_start": 5.0,
"essential_end_time": 32400.0,
"essential_start_time": 57600.0,
"generator_action": "shed",
"grid_action": "shed",
"load_name": "NC1 Fixture",
"manual_override": true,
"micro_grid_action": "shed",
"mode": "manual",
"override": true,
"priority": 1.0,
"pv_serial_nb": [],
"soc_high": 70.0,
"soc_low": 25.0,
"type": "LOAD"
},
"NC2": {
"id": "NC2",
"black_start": 5.0,
"essential_end_time": 57600.0,
"essential_start_time": 32400.0,
"generator_action": "shed",
"grid_action": "apply",
"load_name": "NC2 Fixture",
"manual_override": true,
"micro_grid_action": "shed",
"mode": "manual",
"override": true,
"priority": 2.0,
"pv_serial_nb": [],
"soc_high": 70.0,
"soc_low": 30.0,
"type": "LOAD"
},
"NC3": {
"id": "NC3",
"black_start": 5.0,
"essential_end_time": 57600.0,
"essential_start_time": 32400.0,
"generator_action": "apply",
"grid_action": "shed",
"load_name": "NC3 Fixture",
"manual_override": true,
"micro_grid_action": "apply",
"mode": "manual",
"override": true,
"priority": 3.0,
"pv_serial_nb": [],
"soc_high": 70.0,
"soc_low": 30.0,
"type": "NONE"
}
},
"collar": {
"admin_state": 88,
"admin_state_str": "ENCMN_MDE_ON_GRID",
"firmware_loaded_date": 1752939759,
"firmware_version": "3.0.6-D0",
"installed_date": 1752939759,
"last_report_date": 1752939759,
"communicating": true,
"mid_state": "close",
"grid_state": "on_grid",
"part_number": "865-00400-r22",
"serial_number": "482520020939",
"temperature": 42,
"temperature_unit": "C",
"control_error": 0,
"collar_state": "Installed"
},
"c6cc": {
"admin_state": 82,
"admin_state_str": "ENCMN_C6_CC_READY",
"firmware_loaded_date": 1752945451,
"firmware_version": "0.1.20-D1",
"installed_date": 1752945451,
"last_report_date": 1752945451,
"communicating": true,
"part_number": "800-02403-r08",
"serial_number": "482523040549",
"dmir_version": "0.1.20-D1"
},
"inverters": {
"1": {
"serial_number": "1",
"last_report_date": 1,
"last_report_watts": 1,
"max_report_watts": 1,
"dc_voltage": null,
"dc_current": null,
"ac_voltage": null,
"ac_current": null,
"ac_frequency": null,
"temperature": null,
"energy_produced": null,
"energy_today": null,
"lifetime_energy": null,
"last_report_duration": null
}
},
"tariff": {
"currency": {
"code": "EUR"
},
"logger": "mylogger",
"date": "1695744220",
"storage_settings": {
"mode": "self-consumption",
"operation_mode_sub_type": "",
"reserved_soc": 15.0,
"very_low_soc": 5,
"charge_from_grid": true,
"date": "1695598084",
"opt_schedules": true
},
"single_rate": {
"rate": 0.0,
"sell": 0.0
},
"seasons": [
{
"id": "season_1",
"start": "1/1",
"days": [
{
"id": "all_days",
"days": "Mon,Tue,Wed,Thu,Fri,Sat,Sun",
"must_charge_start": 444,
"must_charge_duration": 35,
"must_charge_mode": "CG",
"enable_discharge_to_grid": true,
"periods": [
{
"id": "period_1",
"start": 480,
"rate": 0.1898
},
{
"id": "filler",
"start": 1320,
"rate": 0.1034
}
]
}
],
"tiers": []
}
],
"seasons_sell": []
},
"raw": {
"varies_by": "firmware_version"
}
}
}
@@ -3,7 +3,7 @@
"firmware": "7.6.175",
"part_number": "123456789",
"envoy_model": "Envoy, phases: 1, phase mode: three, total-consumption CT, production CT",
"supported_features": 1217,
"supported_features": 1231,
"phase_mode": "three",
"phase_count": 1,
"active_phase_count": 0,
@@ -19923,6 +19923,7 @@
'supported_features': list([
'INVERTERS',
'METERING',
'TOTAL_CONSUMPTION',
'NET_CONSUMPTION',
'ENCHARGE',
'ENPOWER',
@@ -43540,6 +43540,67 @@
'state': '2.341',
})
# ---
# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_current_power_consumption-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.envoy_1234_current_power_consumption',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Current power consumption',
'options': dict({
'sensor': dict({
'suggested_display_precision': 3,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfPower.KILO_WATT: 'kW'>,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Current power consumption',
'platform': 'enphase_envoy',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'current_power_consumption',
'unique_id': '1234_consumption',
'unit_of_measurement': <UnitOfPower.KILO_WATT: 'kW'>,
})
# ---
# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_current_power_consumption-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'power',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Envoy 1234 Current power consumption',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfPower.KILO_WATT: 'kW'>,
}),
'context': <ANY>,
'entity_id': 'sensor.envoy_1234_current_power_consumption',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_current_power_production-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -43601,6 +43662,125 @@
'state': '1.234',
})
# ---
# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_consumption_last_seven_days-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': 'sensor',
'entity_category': None,
'entity_id': 'sensor.envoy_1234_energy_consumption_last_seven_days',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Energy consumption last seven days',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Energy consumption last seven days',
'platform': 'enphase_envoy',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'seven_days_consumption',
'unique_id': '1234_seven_days_consumption',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_consumption_last_seven_days-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'energy',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Envoy 1234 Energy consumption last seven days',
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.envoy_1234_energy_consumption_last_seven_days',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_consumption_today-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.envoy_1234_energy_consumption_today',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Energy consumption today',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Energy consumption today',
'platform': 'enphase_envoy',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'daily_consumption',
'unique_id': '1234_daily_consumption',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_consumption_today-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'energy',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Envoy 1234 Energy consumption today',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.envoy_1234_energy_consumption_today',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_energy_production_last_seven_days-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -43897,6 +44077,67 @@
'state': '4.321',
})
# ---
# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_energy_consumption-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.envoy_1234_lifetime_energy_consumption',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Lifetime energy consumption',
'options': dict({
'sensor': dict({
'suggested_display_precision': 3,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfEnergy.MEGA_WATT_HOUR: 'MWh'>,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Lifetime energy consumption',
'platform': 'enphase_envoy',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'lifetime_consumption',
'unique_id': '1234_lifetime_consumption',
'unit_of_measurement': <UnitOfEnergy.MEGA_WATT_HOUR: 'MWh'>,
})
# ---
# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_energy_consumption-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'energy',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Envoy 1234 Lifetime energy consumption',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfEnergy.MEGA_WATT_HOUR: 'MWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.envoy_1234_lifetime_energy_consumption',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_energy_production-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+267 -2
View File
@@ -6,13 +6,14 @@ from typing import Any
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from pyenphase import EnvoyData
from pyenphase.const import PHASENAMES, PhaseNames
from pyenphase.models.acb import ACBChargeStatus, EnvoyACB
from pyenphase.models.meters import CtType
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.enphase_envoy.const import Platform
from homeassistant.components.enphase_envoy.const import DOMAIN, Platform
from homeassistant.components.enphase_envoy.coordinator import SCAN_INTERVAL
from homeassistant.components.enphase_envoy.sensor import aggregate_acb_sleep_state
from homeassistant.components.sensor import SensorStateClass
@@ -23,8 +24,14 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import TemperatureConverter
from . import setup_integration
from .conftest import _load_json_2_meter_data, _load_json_2_production_data
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
load_json_object_fixture,
snapshot_platform,
)
@pytest.mark.parametrize(
@@ -1413,6 +1420,264 @@ async def test_sensor_missing_data(
assert entity_state.state == STATE_UNKNOWN
def reference_fixture(fixture: str) -> EnvoyData:
"""Load reference fixture in envoy data model."""
reference_data = EnvoyData()
json_fixture: dict[str, Any] = load_json_object_fixture(f"{fixture}.json", DOMAIN)
_load_json_2_production_data(reference_data, json_fixture)
_load_json_2_meter_data(reference_data, json_fixture)
return reference_data
@pytest.mark.parametrize(
("mock_envoy", "ref_fixture"),
[
(
"envoy_metered_batt_relay_none",
"envoy_metered_batt_relay",
)
],
indirect=["mock_envoy"],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_load_none_data(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_envoy: AsyncMock,
ref_fixture: str,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test enphase_envoy sensor platform load None data handling."""
with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, config_entry)
ENTITY_BASE = f"{Platform.SENSOR}.envoy_{mock_envoy.serial_number}"
# these have None data and should show up as unknown
for entity in (
"lifetime_energy_production",
"lifetime_energy_consumption",
"current_battery_discharge",
"backfeed_ct_energy_delivered",
"lifetime_energy_production_l1",
"lifetime_energy_consumption_l1",
"backfeed_ct_energy_delivered_l1",
"current_battery_discharge_l1",
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}"))
assert entity_state.state == STATE_UNKNOWN
# restore None data to operational state
reference_data = reference_fixture(ref_fixture)
mock_envoy.data.system_production = reference_data.system_production
mock_envoy.data.system_consumption = reference_data.system_consumption
mock_envoy.data.ctmeters[CtType.BACKFEED] = reference_data.ctmeters[CtType.BACKFEED]
mock_envoy.data.ctmeters[CtType.STORAGE] = reference_data.ctmeters[CtType.STORAGE]
mock_envoy.data.system_production_phases = reference_data.system_production_phases
mock_envoy.data.system_consumption_phases = reference_data.system_consumption_phases
mock_envoy.data.ctmeters_phases[CtType.BACKFEED] = reference_data.ctmeters_phases[
CtType.BACKFEED
]
mock_envoy.data.ctmeters_phases[CtType.STORAGE][PhaseNames.PHASE_1] = (
reference_data.ctmeters_phases[CtType.STORAGE][PhaseNames.PHASE_1]
)
# force HA to detect changed data by changing raw
mock_envoy.data.raw = {"I": "am changed"}
# Move time to next update
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# all these should now no longer be in unknown state
for entity in (
"lifetime_energy_production",
"lifetime_energy_consumption",
"current_battery_discharge",
"backfeed_ct_energy_delivered",
"lifetime_energy_production_l1",
"lifetime_energy_consumption_l1",
"backfeed_ct_energy_delivered_l1",
"current_battery_discharge_l1",
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}"))
assert entity_state.state != STATE_UNKNOWN
@pytest.mark.parametrize(
("mock_envoy"),
[
"envoy_metered_batt_relay",
],
indirect=["mock_envoy"],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_none_data(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_envoy: AsyncMock,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test enphase_envoy sensor platform None data handling."""
with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, config_entry)
ENTITY_BASE = f"{Platform.SENSOR}.envoy_{mock_envoy.serial_number}"
for entity in (
"lifetime_energy_production",
"lifetime_energy_consumption",
"lifetime_balanced_net_energy_consumption",
"backfeed_ct_energy_delivered",
"lifetime_energy_production_l1",
"lifetime_energy_consumption_l1",
"lifetime_balanced_net_energy_consumption_l1",
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}"))
# force None data to test 'if == none' code sections
mock_envoy.data.system_production = None
mock_envoy.data.system_consumption = None
mock_envoy.data.system_net_consumption = None
mock_envoy.data.ctmeters[CtType.BACKFEED] = None
mock_envoy.data.system_production_phases = None
mock_envoy.data.system_consumption_phases = None
mock_envoy.data.system_net_consumption_phases = None
# force HA to detect changed data by changing raw
mock_envoy.data.raw = {"I": "am changed"}
# Move time to next update
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# all these should now be in unknown state
for entity in (
"lifetime_energy_production",
"lifetime_energy_consumption",
"lifetime_balanced_net_energy_consumption",
"backfeed_ct_energy_delivered",
"lifetime_energy_production_l1",
"lifetime_energy_consumption_l1",
"lifetime_balanced_net_energy_consumption_l1",
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}"))
assert entity_state.state == STATE_UNKNOWN
@pytest.mark.parametrize(
("mock_envoy"),
[
"envoy_metered_batt_relay",
],
indirect=["mock_envoy"],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_phase_values_none_data(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_envoy: AsyncMock,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test enphase_envoy sensor platform phase None data handling."""
with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, config_entry)
ENTITY_BASE = f"{Platform.SENSOR}.envoy_{mock_envoy.serial_number}"
for entity in (
"lifetime_energy_production_l1",
"lifetime_energy_consumption_l1",
"lifetime_balanced_net_energy_consumption_l1",
"backfeed_ct_energy_delivered_l1",
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}"))
# force None data to test 'if == none' code sections
mock_envoy.data.system_production_phases[PhaseNames.PHASE_1] = None
mock_envoy.data.system_consumption_phases[PhaseNames.PHASE_1] = None
mock_envoy.data.system_net_consumption_phases[PhaseNames.PHASE_1] = None
mock_envoy.data.ctmeters_phases[CtType.BACKFEED][PhaseNames.PHASE_1] = None
# force HA to detect changed data by changing raw
mock_envoy.data.raw = {"I": "am changed"}
# Move time to next update
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# all these should now be in unknown state
for entity in (
"lifetime_energy_production_l1",
"lifetime_energy_consumption_l1",
"lifetime_balanced_net_energy_consumption_l1",
"backfeed_ct_energy_delivered_l1",
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}"))
assert entity_state.state == STATE_UNKNOWN
@pytest.mark.parametrize(
("mock_envoy"),
[
"envoy_metered_batt_relay",
],
indirect=["mock_envoy"],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_phase_values_missing_data(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_envoy: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test enphase_envoy sensor platform missing phase data handling."""
with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, config_entry)
ENTITY_BASE = f"{Platform.SENSOR}.envoy_{mock_envoy.serial_number}"
for entity in (
"lifetime_energy_production_l1",
"lifetime_energy_consumption_l1",
"lifetime_balanced_net_energy_consumption_l1",
"backfeed_ct_energy_delivered_l1",
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}"))
# test handling of missing phase data
del mock_envoy.data.system_production_phases[PhaseNames.PHASE_1]
del mock_envoy.data.system_consumption_phases[PhaseNames.PHASE_1]
del mock_envoy.data.system_net_consumption_phases[PhaseNames.PHASE_1]
del mock_envoy.data.ctmeters_phases[CtType.BACKFEED][PhaseNames.PHASE_1]
# force HA to detect changed data by changing raw
mock_envoy.data.raw = {"I": "am changed"}
# Move time to next update
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# all these should now be in unknown state
for entity in (
"lifetime_energy_production_l1",
"lifetime_energy_consumption_l1",
"lifetime_balanced_net_energy_consumption_l1",
"backfeed_ct_energy_delivered_l1",
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}"))
assert entity_state.state == STATE_UNKNOWN
@pytest.mark.parametrize(
("mock_envoy"),
[
+2 -2
View File
@@ -6,14 +6,14 @@ from homeassistant.components.camera import Camera, CameraEntityFeature
class MockCamera(Camera):
"""Mock Camera Entity."""
_attr_name = "Test"
_attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM
def __init__(self, unique_id: str | None) -> None:
def __init__(self, unique_id: str | None, name: str = "Test") -> None:
"""Initialize the mock entity."""
super().__init__()
self._stream_source: str | None = "rtsp://stream"
self._attr_unique_id = unique_id
self._attr_name = name
def set_stream_source(self, stream_source: str | None) -> None:
"""Set the stream source."""
+46 -11
View File
@@ -2,7 +2,8 @@
from collections.abc import Generator
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
from typing import Any
from unittest.mock import AsyncMock, Mock, create_autospec, patch
from awesomeversion import AwesomeVersion
from go2rtc_client.rest import (
@@ -11,6 +12,7 @@ from go2rtc_client.rest import (
_StreamClient,
_WebRTCClient,
)
from go2rtc_client.ws import Go2RtcWsClient
import pytest
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
@@ -82,6 +84,19 @@ def ws_client() -> Generator[Mock]:
yield ws_client_mock.return_value
@pytest.fixture
def ws_clients() -> Generator[list[Mock]]:
"""Mock go2rtc websocket clients with a separate mock per created client."""
clients: list[Mock] = []
def create_client(*args: Any, **kwargs: Any) -> Mock:
clients.append(client := create_autospec(Go2RtcWsClient, instance=True))
return client
with patch(f"{GO2RTC_PATH}.Go2RtcWsClient", side_effect=create_client):
yield clients
@pytest.fixture
def server_stdout() -> list[str]:
"""Server stdout lines."""
@@ -198,13 +213,12 @@ def camera_unique_id() -> str | None:
return "camera_unique_id"
@pytest.fixture
async def init_test_integration(
async def _setup_test_integration(
hass: HomeAssistant,
integration_config_entry: ConfigEntry,
camera_unique_id: str | None,
) -> MockCamera:
"""Initialize components."""
cameras: list[MockCamera],
) -> None:
"""Set up the test integration with the given cameras."""
async def async_setup_entry_init(
hass: HomeAssistant, config_entry: ConfigEntry
@@ -232,17 +246,38 @@ async def init_test_integration(
async_unload_entry=async_unload_entry_init,
),
)
test_camera = MockCamera(camera_unique_id)
setup_test_component_platform(
hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True
)
setup_test_component_platform(hass, CAMERA_DOMAIN, cameras, from_config_entry=True)
mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock())
with mock_config_flow(TEST_DOMAIN, ConfigFlow):
assert await hass.config_entries.async_setup(integration_config_entry.entry_id)
await hass.async_block_till_done()
return test_camera
@pytest.fixture
async def init_test_integration(
hass: HomeAssistant,
integration_config_entry: ConfigEntry,
camera_unique_id: str | None,
) -> MockCamera:
"""Initialize components."""
camera = MockCamera(camera_unique_id)
await _setup_test_integration(hass, integration_config_entry, [camera])
return camera
@pytest.fixture
async def init_test_integration_two_cameras(
hass: HomeAssistant,
integration_config_entry: ConfigEntry,
) -> tuple[MockCamera, MockCamera]:
"""Initialize components with two cameras."""
cameras = (
MockCamera("camera_unique_id_1"),
MockCamera("camera_unique_id_2", "Test 2"),
)
await _setup_test_integration(hass, integration_config_entry, list(cameras))
return cameras
@pytest.fixture
+179 -7
View File
@@ -1,5 +1,6 @@
"""The tests for the go2rtc component."""
import asyncio
from collections.abc import Awaitable, Callable
import logging
from pathlib import Path
@@ -194,14 +195,16 @@ async def _test_setup_and_signaling(
receive_message_callback.assert_called_once_with(
WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source")
)
teardown.assert_called_once()
# Only the sessions of the failing camera are closed, the provider stays up
teardown.assert_not_called()
# We use one ws_client mock for all sessions
assert ws_client.close.call_count == len(sessions)
assert not provider._sessions
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
assert teardown.call_count == 2
teardown.assert_called_once()
@pytest.mark.usefixtures(
@@ -466,8 +469,7 @@ async def test_close_session(
session_id = "session_id"
# Session doesn't exist
with pytest.raises(KeyError):
camera.close_webrtc_session(session_id)
camera.close_webrtc_session(session_id)
ws_client.close.assert_not_called()
# Store session
@@ -485,13 +487,183 @@ async def test_close_session(
camera.close_webrtc_session(session_id)
ws_client.close.assert_called_once()
# Close again should raise an error
# Closing an already closed session is a no-op
ws_client.reset_mock()
with pytest.raises(KeyError):
camera.close_webrtc_session(session_id)
camera.close_webrtc_session(session_id)
ws_client.close.assert_not_called()
async def _fail_with_offer(hass: HomeAssistant, camera: MockCamera, error: str) -> None:
"""Update the stream source via a new WebRTC offer, expecting an error."""
send_message = Mock(spec_set=WebRTCSendMessage)
await camera.async_handle_async_webrtc_offer(OFFER_SDP, "new_session", send_message)
send_message.assert_called_once_with(
WebRTCError("go2rtc_webrtc_offer_failed", error)
)
async def _fail_with_image_request(
hass: HomeAssistant, camera: MockCamera, error: str
) -> None:
"""Update the stream source via a snapshot request, expecting an error."""
with pytest.raises(HomeAssistantError, match=error):
await async_get_image(hass, camera.entity_id)
@pytest.mark.parametrize(
("stream_source", "error"),
[
(
None,
"Camera has no stream source",
),
(
"invalid://not_supported",
"Stream source is not supported by go2rtc",
),
],
ids=["no_stream_source", "unsupported_stream_source"],
)
@pytest.mark.parametrize(
"trigger",
[
_fail_with_offer,
_fail_with_image_request,
],
ids=["offer", "image_request"],
)
@pytest.mark.usefixtures("init_integration")
async def test_invalid_stream_source_closes_only_sessions_of_that_camera(
hass: HomeAssistant,
ws_clients: list[Mock],
init_test_integration_two_cameras: tuple[MockCamera, MockCamera],
caplog: pytest.LogCaptureFixture,
trigger: Callable[[HomeAssistant, MockCamera, str], Awaitable[None]],
stream_source: str | None,
error: str,
) -> None:
"""Test an invalid stream source only closes the sessions of that camera."""
camera_1, camera_2 = init_test_integration_two_cameras
await camera_1.async_handle_async_webrtc_offer(OFFER_SDP, "session_1", Mock())
await camera_2.async_handle_async_webrtc_offer(OFFER_SDP, "session_2", Mock())
ws_client_1, ws_client_2 = ws_clients
ws_client_1.reset_mock()
ws_client_2.reset_mock()
caplog.clear()
camera_1.set_stream_source(stream_source)
await trigger(hass, camera_1, error)
ws_client_1.close.assert_called_once()
ws_client_2.close.assert_not_called()
# The session of camera 1 is gone
await camera_1.async_on_webrtc_candidate(
"session_1", RTCIceCandidateInit("candidate")
)
assert (
"homeassistant.components.go2rtc",
logging.DEBUG,
"Unknown session session_1. Ignoring candidate",
) in caplog.record_tuples
ws_client_1.send.assert_not_called()
# Closing the already closed session, e.g. by the frontend, is a no-op
camera_1.close_webrtc_session("session_1")
ws_client_1.close.assert_called_once()
# The session of camera 2 is untouched
await camera_2.async_on_webrtc_candidate(
"session_2", RTCIceCandidateInit("candidate")
)
ws_client_2.send.assert_called_once_with(WebRTCCandidate("candidate"))
camera_2.close_webrtc_session("session_2")
ws_client_2.close.assert_called_once()
@pytest.mark.usefixtures("init_integration")
async def test_unregister_camera_closes_only_sessions_of_that_camera(
ws_clients: list[Mock],
init_test_integration_two_cameras: tuple[MockCamera, MockCamera],
) -> None:
"""Test removing a camera closes only the sessions of that camera."""
camera_1, camera_2 = init_test_integration_two_cameras
await camera_1.async_handle_async_webrtc_offer(OFFER_SDP, "session_1", Mock())
await camera_2.async_handle_async_webrtc_offer(OFFER_SDP, "session_2", Mock())
ws_client_1, ws_client_2 = ws_clients
ws_client_1.reset_mock()
ws_client_2.reset_mock()
await camera_1.async_remove()
ws_client_1.close.assert_called_once()
ws_client_2.close.assert_not_called()
# The session of camera 2 is untouched
await camera_2.async_on_webrtc_candidate(
"session_2", RTCIceCandidateInit("candidate")
)
ws_client_2.send.assert_called_once_with(WebRTCCandidate("candidate"))
@pytest.mark.usefixtures("init_integration")
async def test_teardown_while_a_camera_is_removed(
ws_clients: list[Mock],
init_test_integration_two_cameras: tuple[MockCamera, MockCamera],
) -> None:
"""Test tearing down the provider while a camera is removed."""
camera_1, camera_2 = init_test_integration_two_cameras
await camera_1.async_handle_async_webrtc_offer(OFFER_SDP, "session_1", Mock())
await camera_2.async_handle_async_webrtc_offer(OFFER_SDP, "session_2", Mock())
ws_client_1, ws_client_2 = ws_clients
assert isinstance(camera_1.webrtc_provider, WebRTCProvider)
provider = camera_1.webrtc_provider
async def yield_control() -> None:
"""Let the camera removal run while the teardown is in progress."""
await asyncio.sleep(0)
ws_client_1.close.side_effect = yield_control
ws_client_2.close.side_effect = yield_control
await asyncio.gather(provider.teardown(), camera_2.async_remove())
ws_client_1.close.assert_called_once()
ws_client_2.close.assert_called_once()
assert not provider._sessions
@pytest.mark.usefixtures("init_integration")
async def test_camera_removed_while_a_snapshot_fails(
hass: HomeAssistant,
ws_clients: list[Mock],
init_test_integration: MockCamera,
) -> None:
"""Test a camera being removed while a snapshot closes the same session."""
camera = init_test_integration
await camera.async_handle_async_webrtc_offer(OFFER_SDP, "session_1", Mock())
(ws_client,) = ws_clients
async def yield_control() -> None:
"""Let the camera removal run while the snapshot is still failing."""
await asyncio.sleep(0)
ws_client.close.side_effect = yield_control
camera.set_stream_source(None)
async def failing_snapshot() -> None:
with pytest.raises(HomeAssistantError, match="Camera has no stream source"):
await async_get_image(hass, camera.entity_id)
await asyncio.gather(failing_snapshot(), camera.async_remove())
ws_client.close.assert_called_once()
ERR_BINARY_NOT_FOUND = "Could not find go2rtc docker binary"
ERR_CONNECT = "Could not connect to go2rtc instance"
ERR_CONNECT_RETRY = (
@@ -1,13 +1,17 @@
"""The tests for the Event automation."""
import logging
import attr
import pytest
from homeassistant.components import automation
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF
from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, script, trigger
from homeassistant.setup import async_setup_component
from tests.common import mock_component
from tests.common import MockConfigEntry, mock_component
@pytest.fixture
@@ -629,3 +633,109 @@ async def test_templated_state_reported_event(
"Got error 'Can't listen to state_reported in event trigger' "
"when setting up triggers for automation 0" in caplog.text
)
COMPOSITE_ID = "composite00000000000000000000ab"
@pytest.fixture
def split_devices(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> tuple[dr.DeviceEntry, dr.DeviceEntry]:
"""Create two devices which are splits of a pre-migration composite device."""
entry_1 = MockConfigEntry(domain="itg1")
entry_1.add_to_hass(hass)
entry_2 = MockConfigEntry(domain="itg2")
entry_2.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=entry_1.entry_id,
identifiers={("itg1", "1")},
name="Split device 1",
)
device_2 = device_registry.async_get_or_create(
config_entry_id=entry_2.entry_id,
identifiers={("itg2", "1")},
name="Split device 2",
)
device_registry.devices[device_1.id] = attr.evolve(
device_1, composite_device_id=COMPOSITE_ID
)
device_registry.devices[device_2.id] = attr.evolve(
device_2, composite_device_id=COMPOSITE_ID
)
return device_registry.devices[device_1.id], device_registry.devices[device_2.id]
_EVENT_TRIGGER = {
"platform": "event",
"event_type": "my_event",
"event_data": {"device_id": COMPOSITE_ID},
}
def _expected_composite_warning(
device_1: dr.DeviceEntry, device_2: dr.DeviceEntry
) -> str:
"""Return the exact warning the event validator logs for a composite device id."""
return (
f"Event trigger filters on device '{COMPOSITE_ID}', which was split into one "
"device per integration and no longer exists, so the trigger can no longer fire. "
"Update the automation, script or template entity to filter on one of these "
"devices instead: "
f"Split device 1 ({device_1.id}) from the itg1 integration, "
f"Split device 2 ({device_2.id}) from the itg2 integration.\n"
"The affected trigger is configured as:\n"
"platform: event\n"
"event_type: my_event\n"
"event_data:\n"
f" device_id: {COMPOSITE_ID}\n"
)
async def test_composite_device_id_logs_warning(
hass: HomeAssistant,
split_devices: tuple[dr.DeviceEntry, dr.DeviceEntry],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test a composite event_data.device_id filter logs the full warning."""
with caplog.at_level(logging.WARNING):
await trigger.async_validate_trigger_config(hass, [_EVENT_TRIGGER])
assert caplog.messages == [_expected_composite_warning(*split_devices)]
async def test_live_device_id_no_warning(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test a live event_data.device_id filter does not warn."""
entry = MockConfigEntry(domain="itg")
entry.add_to_hass(hass)
live_device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={("itg", "1")}
)
with caplog.at_level(logging.WARNING):
await trigger.async_validate_trigger_config(
hass,
[
{
"platform": "event",
"event_type": "my_event",
"event_data": {"device_id": live_device.id},
}
],
)
assert caplog.messages == []
async def test_wait_for_trigger_composite_device_id_logs_warning(
hass: HomeAssistant,
split_devices: tuple[dr.DeviceEntry, dr.DeviceEntry],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test a composite device_id in a wait_for_trigger event trigger warns too."""
with caplog.at_level(logging.WARNING):
await script.async_validate_actions_config(
hass, [{"wait_for_trigger": [_EVENT_TRIGGER]}]
)
assert caplog.messages == [_expected_composite_warning(*split_devices)]
@@ -1322,32 +1322,6 @@ async def test_zeroconf_flow_already_configured(hass: HomeAssistant) -> None:
assert result["reason"] == "already_configured"
async def test_local_zeroconf_flow_updates_host(hass: HomeAssistant) -> None:
"""Test that rediscovery of a local gateway refreshes the stored host."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_GATEWAY_ID,
data={
"host": "gateway-1234-5678-9123.local:9999",
"token": TEST_TOKEN,
"verify_ssl": False,
"hub": TEST_SERVER,
"api_type": "local",
},
)
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
data=FAKE_ZERO_CONF_INFO_LOCAL,
context={"source": config_entries.SOURCE_ZEROCONF},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert config_entry.data["host"] == "gateway-1234-5678-9123.local:8443"
@pytest.fixture
async def setup_rexel_credentials(hass: HomeAssistant) -> None:
"""Set up the application credential used by the Rexel OAuth2 flow."""
@@ -72,6 +72,10 @@
0,
]),
'cmd list': dict({
'115': dict({
'0': 1,
'null': 2,
}),
'208': dict({
'0': 1,
'null': 1,
@@ -84,10 +88,38 @@
'0': 1,
'null': 1,
}),
'594': dict({
'439': dict({
'0': 1,
'null': 1,
}),
'483': dict({
'0': 1,
'null': 1,
}),
'527': dict({
'0': 1,
'null': 1,
}),
'529': dict({
'0': 2,
'null': 2,
}),
'531': dict({
'0': 2,
'null': 2,
}),
'549': dict({
'0': 2,
'null': 2,
}),
'551': dict({
'0': 2,
'null': 2,
}),
'594': dict({
'0': 4,
'null': 4,
}),
'609': dict({
'0': 1,
'null': 1,
@@ -101,12 +133,12 @@
'null': 2,
}),
'GetAiAlarm': dict({
'0': 6,
'null': 6,
'0': 12,
'null': 12,
}),
'GetAiCfg': dict({
'0': 2,
'null': 2,
'0': 4,
'null': 4,
}),
'GetAudioAlarm': dict({
'0': 1,
@@ -125,8 +157,8 @@
'null': 2,
}),
'GetBatteryInfo': dict({
'0': 1,
'null': 1,
'0': 3,
'null': 3,
}),
'GetBuzzerAlarmV20': dict({
'0': 1,
@@ -149,20 +181,27 @@
'null': 2,
}),
'GetEnc': dict({
'0': 1,
'null': 1,
'0': 7,
'null': 7,
}),
'GetFtp': dict({
'0': 1,
'null': 2,
}),
'GetHddInfo': dict({
'null': 1,
}),
'GetImage': dict({
'0': 5,
'null': 5,
}),
'GetIrLights': dict({
'0': 1,
'null': 1,
}),
'GetIsp': dict({
'0': 1,
'null': 1,
'0': 6,
'null': 6,
}),
'GetManualRec': dict({
'0': 1,
@@ -176,10 +215,13 @@
'0': 1,
'null': 1,
}),
'GetPirInfo': dict({
'0': 1,
'GetPerformance': dict({
'null': 1,
}),
'GetPirInfo': dict({
'0': 4,
'null': 4,
}),
'GetPowerLed': dict({
'0': 2,
'null': 2,
@@ -192,13 +234,17 @@
'0': 2,
'null': 2,
}),
'GetPtzTraceSection': dict({
'0': 2,
'null': 2,
}),
'GetPush': dict({
'0': 1,
'null': 2,
}),
'GetRec': dict({
'0': 1,
'null': 2,
'0': 2,
'null': 4,
}),
'GetScene': dict({
'null': 1,
@@ -207,8 +253,8 @@
'null': 1,
}),
'GetWhiteLed': dict({
'0': 3,
'null': 3,
'0': 7,
'null': 7,
}),
'GetZoomFocus': dict({
'0': 2,
@@ -2,6 +2,7 @@
from unittest.mock import MagicMock
import pytest
from reolink_aio.api import Chime
from syrupy.assertion import SnapshotAssertion
@@ -12,6 +13,7 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host")
async def test_entry_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
+72
View File
@@ -19,6 +19,7 @@ from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
ATTR_HVAC_MODES,
ATTR_PRESET_MODE,
DOMAIN as CLIMATE_DOMAIN,
FAN_LOW,
@@ -1061,6 +1062,77 @@ async def test_rpc_linkedgo_st802_thermostat(
assert (state := hass.states.get(entity_id))
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.4
# Test HVAC mode heat (not floor_heating)
mock_rpc_device.boolean_set.reset_mock()
mock_rpc_device.enum_set.reset_mock()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT},
blocking=True,
)
monkeypatch.setitem(mock_rpc_device.status["boolean:201"], "value", True)
monkeypatch.setitem(mock_rpc_device.status["enum:201"], "value", "heat")
mock_rpc_device.mock_update()
mock_rpc_device.boolean_set.assert_called_once_with(201, True)
mock_rpc_device.enum_set.assert_called_once_with(201, "heat")
assert (state := hass.states.get(entity_id))
assert state.state == HVACMode.HEAT
async def test_rpc_linkedgo_st802_thermostat_floor_heating(
hass: HomeAssistant,
mock_rpc_device: Mock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test LINKEDGO ST802 thermostat in floor heating mode."""
entity_id = "climate.test_name"
device_fixture = await async_load_json_object_fixture(
hass, "st802_gen3.json", DOMAIN
)
device_info = device_fixture["shelly"]
config = device_fixture["config"]
status = device_fixture["status"]
config["enum:201"]["options"] = [
"cool",
"dry",
"ventilation",
"floor_heating",
]
status["enum:201"]["value"] = "floor_heating"
monkeypatch.setattr(mock_rpc_device, "shelly", device_info)
monkeypatch.setattr(mock_rpc_device, "status", status)
monkeypatch.setattr(mock_rpc_device, "config", config)
await init_integration(hass, 3, model=MODEL_LINKEDGO_ST802_THERMOSTAT)
assert (state := hass.states.get(entity_id))
assert state.state == HVACMode.HEAT
assert state.attributes[ATTR_HVAC_MODES] == [
HVACMode.OFF,
HVACMode.COOL,
HVACMode.DRY,
HVACMode.FAN_ONLY,
HVACMode.HEAT,
]
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT},
blocking=True,
)
monkeypatch.setitem(mock_rpc_device.status["enum:201"], "value", "floor_heating")
mock_rpc_device.mock_update()
mock_rpc_device.boolean_set.assert_called_once_with(201, True)
mock_rpc_device.enum_set.assert_called_once_with(201, "floor_heating")
assert (state := hass.states.get(entity_id))
assert state.state == HVACMode.HEAT
async def test_rpc_linkedgo_st1820_thermostat(
hass: HomeAssistant,
+21
View File
@@ -2305,3 +2305,24 @@ async def test_rpc_sensor_driver_missing_error(
await hass.async_block_till_done()
assert entity_registry.async_get(entity_id) is None
async def test_rpc_sensor_errors_none(
hass: HomeAssistant,
mock_rpc_device: Mock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""RPC sensor with errors set to none."""
status = {
"temperature:0": {
"id": 0,
"tC": 11.1,
"errors": None,
}
}
monkeypatch.setattr(mock_rpc_device, "status", status)
await init_integration(hass, 2)
assert (state := hass.states.get("sensor.test_name_temperature"))
assert state.state == "11.1"
@@ -452,6 +452,7 @@ def assert_sending_requests(
assert body_request["message"] == MESSAGE
assert body_request["number"] == NUMBER_FROM
assert body_request["recipients"] == (recipients or NUMBERS_TO)
assert body_request["notify_self"] is True
assert len(body_request.get("base64_attachments", [])) == attachments_num
for attachment in body_request.get("base64_attachments", []):
@@ -52,6 +52,86 @@ def mock_browse_by_idstring(
return None
@pytest.mark.parametrize(
("idstring", "expected_type"),
[
pytest.param("A:ALBUM/Abbey%20Road", MediaType.ALBUM, id="album"),
pytest.param(
"A:ALBUMARTIST/The%20Beatles", MediaType.ARTIST, id="album_artist"
),
],
)
def test_build_item_response_container_art_uses_media_type(
idstring: str, expected_type: MediaType
) -> None:
"""Test container art is requested with a MediaType, not a Sonos search type.
async_get_browse_image matches on MediaType, so requesting the container art
with the Sonos search type makes the browse image proxy return no image.
"""
music_library = MagicMock()
music_library.browse_by_idstring.return_value = [
MockMusicServiceItem(
"Come Together",
"x-file-cifs://192.168.42.10/music/01%20Come%20Together.mp3",
idstring,
"object.item.audioItem.musicTrack",
)
]
music_library.get_music_library_information.return_value = []
get_thumbnail_url = Mock(return_value="/thumb")
build_item_response(
music_library,
{"search_type": MediaType.ALBUM, "idstring": idstring},
get_thumbnail_url,
)
assert get_thumbnail_url.call_args.args[0] == expected_type
@pytest.mark.parametrize(
("idstring", "child_class", "expected_can_play"),
[
pytest.param(
"A:ALBUM/Abbey%20Road",
"object.item.audioItem.musicTrack",
True,
id="single_album",
),
pytest.param(
"A:ALBUM",
"object.container.album.musicAlbum",
False,
id="album_listing",
),
],
)
def test_build_item_response_playable_only_for_a_single_album(
idstring: str, child_class: str, expected_can_play: bool
) -> None:
"""Test a resolved album is playable while the album listing is not.
can_play is passed a Sonos search type, which for the listing would otherwise
mark every library listing playable.
"""
music_library = MagicMock()
music_library.browse_by_idstring.return_value = [
MockMusicServiceItem(
"Abbey Road", "A:ALBUM/Abbey%20Road", idstring, child_class
)
]
music_library.get_music_library_information.return_value = []
response = build_item_response(
music_library,
{"search_type": MediaType.ALBUM, "idstring": idstring},
Mock(return_value="/thumb"),
)
assert response.can_play is expected_can_play
async def test_build_item_response(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
@@ -284,6 +284,27 @@ async def _test_service(
assert service_call.call_args == call(*args, **kwargs)
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_tv_without_volume_in_audio_settings(
hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry
) -> None:
"""Test a TV whose audio settings omit volume.
Some firmware does not list `volume` (or `mute`) in the `audio`
settings collection even though the individual settings still work.
The entity must still load, with the unavailable attributes reported
as None instead of raising on every coordinator update.
"""
async with _cm_for_test_setup_without_apps({"eq": CURRENT_EQ}, True):
await setup_integration(hass, mock_tv_config_entry)
attr = _get_attr_and_assert_base_attr(hass, MediaPlayerDeviceClass.TV, STATE_ON)
# Unset attributes are omitted from the state entirely.
assert attr.get("volume_level") is None
assert attr.get("is_volume_muted") is None
assert attr[ATTR_SOUND_MODE] == CURRENT_EQ
@pytest.mark.usefixtures("vizio_connect", "vizio_update")
async def test_speaker_on(
hass: HomeAssistant, mock_speaker_config_entry: MockConfigEntry