mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
2026.8.3 (#179766)
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
Generated
+7
-7
@@ -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>,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user