mirror of
https://github.com/home-assistant/core.git
synced 2026-03-18 17:02:25 +01:00
Compare commits
46 Commits
gha-builde
...
add_event_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fc3703551 | ||
|
|
8745eb38e1 | ||
|
|
6d1a63919d | ||
|
|
1527883c67 | ||
|
|
e307ceccb5 | ||
|
|
ea7558c0ad | ||
|
|
c4399b5547 | ||
|
|
d989a83d7b | ||
|
|
d04f3530df | ||
|
|
647d957ffe | ||
|
|
a3f3c87b39 | ||
|
|
447b17a2a4 | ||
|
|
eb2b92687c | ||
|
|
6424e3658e | ||
|
|
f92a70845a | ||
|
|
d1d8754853 | ||
|
|
c4ff7fa676 | ||
|
|
f1fe1d3956 | ||
|
|
fd0d60b787 | ||
|
|
9ddefaaacd | ||
|
|
5c8df048b1 | ||
|
|
d86d85ec56 | ||
|
|
660f12b683 | ||
|
|
b8238c86e6 | ||
|
|
754828188e | ||
|
|
6992a3c72b | ||
|
|
738d4f662a | ||
|
|
7f33ac72ab | ||
|
|
0891d814fa | ||
|
|
ddab50edcc | ||
|
|
c8ce4eb32d | ||
|
|
22aca8b7af | ||
|
|
770864082f | ||
|
|
14545660e2 | ||
|
|
836353015b | ||
|
|
c57ffd4d78 | ||
|
|
cbebfdf149 | ||
|
|
d8ed9ca66f | ||
|
|
5caf8a5b83 | ||
|
|
c05210683e | ||
|
|
aa8dd4bb66 | ||
|
|
ee7d6157d9 | ||
|
|
adec1d128c | ||
|
|
0a2fc97696 | ||
|
|
503fe5ef7f | ||
|
|
7865f7084f |
@@ -620,12 +620,14 @@ rules:
|
||||
|
||||
### Config Flow Testing
|
||||
- **100% Coverage Required**: All config flow paths must be tested
|
||||
- **Patch Boundaries**: Only patch library or client methods when testing config flows. Do not patch methods defined in `config_flow.py`; exercise the flow logic end-to-end.
|
||||
- **Test Scenarios**:
|
||||
- All flow initiation methods (user, discovery, import)
|
||||
- Successful configuration paths
|
||||
- Error recovery scenarios
|
||||
- Prevention of duplicate entries
|
||||
- Flow completion after errors
|
||||
- Reauthentication/reconfigure flows
|
||||
|
||||
### Testing
|
||||
- **Integration-specific tests** (recommended):
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -1616,8 +1616,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/srp_energy/ @briglx
|
||||
/homeassistant/components/starline/ @anonym-tsk
|
||||
/tests/components/starline/ @anonym-tsk
|
||||
/homeassistant/components/starlink/ @boswelja
|
||||
/tests/components/starlink/ @boswelja
|
||||
/homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST
|
||||
/tests/components/statistics/ @ThomDietrich @gjohansson-ST
|
||||
/homeassistant/components/steam_online/ @tkdrob
|
||||
|
||||
@@ -46,19 +46,10 @@ async def async_setup_entry(
|
||||
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
|
||||
)
|
||||
|
||||
try:
|
||||
doors = await client.get_doors()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
coordinator = AladdinConnectCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = {
|
||||
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
|
||||
for door in doors
|
||||
}
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -100,7 +91,7 @@ def remove_stale_devices(
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry.entry_id
|
||||
)
|
||||
all_device_ids = set(config_entry.runtime_data)
|
||||
all_device_ids = set(config_entry.runtime_data.data)
|
||||
|
||||
for device_entry in device_entries:
|
||||
device_id: str | None = None
|
||||
|
||||
@@ -11,22 +11,24 @@ from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]]
|
||||
type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator]
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
|
||||
class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
|
||||
class AladdinConnectCoordinator(DataUpdateCoordinator[dict[str, GarageDoor]]):
|
||||
"""Coordinator for Aladdin Connect integration."""
|
||||
|
||||
config_entry: AladdinConnectConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: AladdinConnectConfigEntry,
|
||||
client: AladdinConnectClient,
|
||||
garage_door: GarageDoor,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
@@ -37,18 +39,16 @@ class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
self.data = garage_door
|
||||
|
||||
async def _async_update_data(self) -> GarageDoor:
|
||||
async def _async_update_data(self) -> dict[str, GarageDoor]:
|
||||
"""Fetch data from the Aladdin Connect API."""
|
||||
try:
|
||||
await self.client.update_door(self.data.device_id, self.data.door_number)
|
||||
doors = await self.client.get_doors()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
self.data.status = self.client.get_door_status(
|
||||
self.data.device_id, self.data.door_number
|
||||
)
|
||||
self.data.battery_level = self.client.get_battery_status(
|
||||
self.data.device_id, self.data.door_number
|
||||
)
|
||||
return self.data
|
||||
|
||||
return {door.unique_id: door for door in doors}
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -24,11 +24,22 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the cover platform."""
|
||||
coordinators = entry.runtime_data
|
||||
coordinator = entry.runtime_data
|
||||
known_devices: set[str] = set()
|
||||
|
||||
async_add_entities(
|
||||
AladdinCoverEntity(coordinator) for coordinator in coordinators.values()
|
||||
)
|
||||
@callback
|
||||
def _async_add_new_devices() -> None:
|
||||
"""Detect and add entities for new doors."""
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AladdinCoverEntity(coordinator, door_id) for door_id in new_devices
|
||||
)
|
||||
|
||||
_async_add_new_devices()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
|
||||
|
||||
|
||||
class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||
@@ -38,10 +49,10 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||
_attr_supported_features = SUPPORTED_FEATURES
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None:
|
||||
"""Initialize the Aladdin Connect cover."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = coordinator.data.unique_id
|
||||
super().__init__(coordinator, door_id)
|
||||
self._attr_unique_id = door_id
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Issue open command to cover."""
|
||||
@@ -66,16 +77,16 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Update is closed attribute."""
|
||||
if (status := self.coordinator.data.status) is None:
|
||||
if (status := self.door.status) is None:
|
||||
return None
|
||||
return status == "closed"
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""Update is closing attribute."""
|
||||
return self.coordinator.data.status == "closing"
|
||||
return self.door.status == "closing"
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""Update is opening attribute."""
|
||||
return self.coordinator.data.status == "opening"
|
||||
return self.door.status == "opening"
|
||||
|
||||
@@ -20,13 +20,13 @@ async def async_get_config_entry_diagnostics(
|
||||
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
|
||||
"doors": {
|
||||
uid: {
|
||||
"device_id": coordinator.data.device_id,
|
||||
"door_number": coordinator.data.door_number,
|
||||
"name": coordinator.data.name,
|
||||
"status": coordinator.data.status,
|
||||
"link_status": coordinator.data.link_status,
|
||||
"battery_level": coordinator.data.battery_level,
|
||||
"device_id": door.device_id,
|
||||
"door_number": door.door_number,
|
||||
"name": door.name,
|
||||
"status": door.status,
|
||||
"link_status": door.link_status,
|
||||
"battery_level": door.battery_level,
|
||||
}
|
||||
for uid, coordinator in config_entry.runtime_data.items()
|
||||
for uid, door in config_entry.runtime_data.data.items()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Base class for Aladdin Connect entities."""
|
||||
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -14,17 +15,28 @@ class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None:
|
||||
"""Initialize Aladdin Connect entity."""
|
||||
super().__init__(coordinator)
|
||||
device = coordinator.data
|
||||
self._door_id = door_id
|
||||
door = self.door
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.unique_id)},
|
||||
identifiers={(DOMAIN, door.unique_id)},
|
||||
manufacturer="Aladdin Connect",
|
||||
name=device.name,
|
||||
name=door.name,
|
||||
)
|
||||
self._device_id = device.device_id
|
||||
self._number = device.door_number
|
||||
self._device_id = door.device_id
|
||||
self._number = door.door_number
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self._door_id in self.coordinator.data
|
||||
|
||||
@property
|
||||
def door(self) -> GarageDoor:
|
||||
"""Return the garage door data."""
|
||||
return self.coordinator.data[self._door_id]
|
||||
|
||||
@property
|
||||
def client(self) -> AladdinConnectClient:
|
||||
|
||||
@@ -57,7 +57,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||
@@ -49,13 +49,24 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Aladdin Connect sensor devices."""
|
||||
coordinators = entry.runtime_data
|
||||
coordinator = entry.runtime_data
|
||||
known_devices: set[str] = set()
|
||||
|
||||
async_add_entities(
|
||||
AladdinConnectSensor(coordinator, description)
|
||||
for coordinator in coordinators.values()
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
@callback
|
||||
def _async_add_new_devices() -> None:
|
||||
"""Detect and add entities for new doors."""
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AladdinConnectSensor(coordinator, door_id, description)
|
||||
for door_id in new_devices
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
|
||||
_async_add_new_devices()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
|
||||
|
||||
|
||||
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
|
||||
@@ -66,14 +77,15 @@ class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AladdinConnectCoordinator,
|
||||
door_id: str,
|
||||
entity_description: AladdinConnectSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Aladdin Connect sensor."""
|
||||
super().__init__(coordinator)
|
||||
super().__init__(coordinator, door_id)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}"
|
||||
self._attr_unique_id = f"{door_id}-{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
return self.entity_description.value_fn(self.door)
|
||||
|
||||
@@ -143,6 +143,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"door",
|
||||
"event",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
|
||||
@@ -9,9 +9,12 @@ from typing import Any
|
||||
from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_utc_time_change
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend
|
||||
|
||||
|
||||
@@ -23,10 +26,10 @@ async def async_setup_entry(
|
||||
"""Set up the Demo config entry."""
|
||||
async_add_entities(
|
||||
[
|
||||
DemoValve("Front Garden", ValveState.OPEN),
|
||||
DemoValve("Orchard", ValveState.CLOSED),
|
||||
DemoValve("Back Garden", ValveState.CLOSED, position=70),
|
||||
DemoValve("Trees", ValveState.CLOSED, position=30),
|
||||
DemoValve("valve_1", "Front Garden", ValveState.OPEN),
|
||||
DemoValve("valve_2", "Orchard", ValveState.CLOSED),
|
||||
DemoValve("valve_3", "Back Garden", ValveState.CLOSED, position=70),
|
||||
DemoValve("valve_4", "Trees", ValveState.CLOSED, position=30),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -34,17 +37,24 @@ async def async_setup_entry(
|
||||
class DemoValve(ValveEntity):
|
||||
"""Representation of a Demo valve."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
name: str,
|
||||
state: str,
|
||||
moveable: bool = True,
|
||||
position: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the valve."""
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=name,
|
||||
)
|
||||
if moveable:
|
||||
self._attr_supported_features = (
|
||||
ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
|
||||
|
||||
@@ -12,5 +12,10 @@
|
||||
"motion": {
|
||||
"default": "mdi:motion-sensor"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"received": {
|
||||
"trigger": "mdi:eye-check"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,17 @@
|
||||
"name": "Motion"
|
||||
}
|
||||
},
|
||||
"title": "Event"
|
||||
"title": "Event",
|
||||
"triggers": {
|
||||
"received": {
|
||||
"description": "Triggers after one or more event entities receive a matching event.",
|
||||
"fields": {
|
||||
"event_type": {
|
||||
"description": "The event types to trigger on.",
|
||||
"name": "Event type"
|
||||
}
|
||||
},
|
||||
"name": "Event received"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
67
homeassistant/components/event/trigger.py
Normal file
67
homeassistant/components/event/trigger.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Provides triggers for events."""
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_OPTIONS, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA,
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
TriggerConfig,
|
||||
)
|
||||
|
||||
from .const import ATTR_EVENT_TYPE, DOMAIN
|
||||
|
||||
CONF_EVENT_TYPE = "event_type"
|
||||
|
||||
EVENT_RECEIVED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_EVENT_TYPE): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [cv.string]
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EventReceivedTrigger(EntityTriggerBase):
|
||||
"""Trigger for event entity when it receives a matching event."""
|
||||
|
||||
_domain_specs = {DOMAIN: DomainSpec()}
|
||||
_schema = EVENT_RECEIVED_TRIGGER_SCHEMA
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the event received trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._event_types = set(self._options[CONF_EVENT_TYPE])
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the origin state is valid and different from the current state."""
|
||||
|
||||
# UNKNOWN is a valid from_state, otherwise the first time the event is received
|
||||
# would not trigger
|
||||
if from_state.state == STATE_UNAVAILABLE:
|
||||
return False
|
||||
|
||||
return from_state.state != to_state.state
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the event type is valid and matches one of the configured types."""
|
||||
return (
|
||||
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||
and state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
|
||||
)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"received": EventReceivedTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for events."""
|
||||
return TRIGGERS
|
||||
16
homeassistant/components/event/triggers.yaml
Normal file
16
homeassistant/components/event/triggers.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
received:
|
||||
target:
|
||||
entity:
|
||||
domain: event
|
||||
fields:
|
||||
event_type:
|
||||
context:
|
||||
filter_target: target
|
||||
required: true
|
||||
selector:
|
||||
state:
|
||||
attribute: event_type
|
||||
hide_states:
|
||||
- unavailable
|
||||
- unknown
|
||||
multiple: true
|
||||
@@ -130,6 +130,7 @@ class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
|
||||
identifiers={(DOMAIN, coordinator.device_id)},
|
||||
manufacturer="Growatt",
|
||||
name=coordinator.device_id,
|
||||
serial_number=coordinator.device_id,
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -32,9 +32,7 @@ rules:
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: todo
|
||||
comment: Add serial_number field to DeviceInfo in sensor, number, and switch platforms using device_id/serial_id.
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
@@ -46,13 +44,11 @@ rules:
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category:
|
||||
status: todo
|
||||
comment: Add EntityCategory.DIAGNOSTIC to temperature and other diagnostic sensors. Merge GrowattRequiredKeysMixin into GrowattSensorEntityDescription using kw_only=True.
|
||||
entity-category: done
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: Replace custom precision field with suggested_display_precision to preserve full data granularity.
|
||||
entity-disabled-by-default: todo
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
|
||||
@@ -105,6 +105,7 @@ class GrowattSensor(CoordinatorEntity[GrowattCoordinator], SensorEntity):
|
||||
identifiers={(DOMAIN, serial_id)},
|
||||
manufacturer="Growatt",
|
||||
name=name,
|
||||
serial_number=serial_id,
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -131,6 +132,8 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_inverter_reactive_amperage",
|
||||
@@ -140,6 +143,8 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_frequency",
|
||||
@@ -149,6 +154,8 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_current_wattage",
|
||||
@@ -167,6 +174,8 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_ipm_temperature",
|
||||
@@ -176,6 +185,8 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_temperature",
|
||||
@@ -185,5 +196,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfFrequency,
|
||||
@@ -90,6 +91,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_1",
|
||||
@@ -98,6 +101,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_2",
|
||||
@@ -106,6 +111,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_3",
|
||||
@@ -114,6 +121,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_4",
|
||||
@@ -122,6 +131,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_5",
|
||||
@@ -130,6 +141,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# Values from 'sph_energy' API call
|
||||
GrowattSensorEntityDescription(
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -217,6 +218,8 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_output_voltage",
|
||||
@@ -235,6 +238,8 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_current_PV",
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -248,6 +249,8 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_frequency",
|
||||
@@ -256,6 +259,8 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_current_wattage",
|
||||
@@ -273,6 +278,8 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_temperature_2",
|
||||
@@ -281,6 +288,8 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_temperature_3",
|
||||
@@ -289,6 +298,8 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_temperature_4",
|
||||
@@ -297,6 +308,8 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_temperature_5",
|
||||
@@ -305,6 +318,8 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_all_batteries_discharge_today",
|
||||
|
||||
@@ -87,6 +87,7 @@ class GrowattSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity):
|
||||
identifiers={(DOMAIN, coordinator.device_id)},
|
||||
manufacturer="Growatt",
|
||||
name=coordinator.device_id,
|
||||
serial_number=coordinator.device_id,
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohasupervisor==0.4.1"],
|
||||
"requirements": ["aiohasupervisor==0.4.2"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -72,13 +72,6 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
|
||||
return HVACMode.HEAT
|
||||
return HVACMode.OFF
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Return nice icon for heater."""
|
||||
if self.hvac_mode == HVACMode.HEAT:
|
||||
return "mdi:radiator"
|
||||
return "mdi:radiator-off"
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> int | None:
|
||||
"""Return the current temperature."""
|
||||
|
||||
@@ -45,8 +45,6 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
await huum.status()
|
||||
except Forbidden, NotAuthenticated:
|
||||
# Most likely Forbidden as that is what is returned from `.status()` with bad creds
|
||||
_LOGGER.error("Could not log in to Huum with given credentials")
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown error")
|
||||
|
||||
@@ -54,7 +54,6 @@ class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]):
|
||||
try:
|
||||
return await self.huum.status()
|
||||
except (Forbidden, NotAuthenticated) as err:
|
||||
_LOGGER.error("Could not log in to Huum with given credentials")
|
||||
raise UpdateFailed(
|
||||
"Could not log in to Huum with given credentials"
|
||||
) from err
|
||||
|
||||
@@ -7,11 +7,7 @@ rules:
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow:
|
||||
status: done
|
||||
comment: |
|
||||
PLANNED: Remove _LOGGER.error call from config_flow.py — the error
|
||||
message is redundant with the errors dict entry.
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
@@ -40,11 +36,7 @@ rules:
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: |
|
||||
PLANNED: Remove _LOGGER.error from coordinator.py — the message is already
|
||||
passed to UpdateFailed, so logging it separately is redundant.
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage:
|
||||
@@ -74,11 +66,7 @@ rules:
|
||||
comment: All entities are core functionality.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: done
|
||||
comment: |
|
||||
PLANNED: Remove the icon property from climate.py — entities should not set
|
||||
custom icons. Use HA defaults or icon translations instead.
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
|
||||
@@ -18,6 +18,8 @@ ABBREVIATIONS = {
|
||||
"bri_stat_t": "brightness_state_topic",
|
||||
"bri_tpl": "brightness_template",
|
||||
"bri_val_tpl": "brightness_value_template",
|
||||
"cln_segmnts_cmd_t": "clean_segments_command_topic",
|
||||
"cln_segmnts_cmd_tpl": "clean_segments_command_template",
|
||||
"clr_temp_cmd_tpl": "color_temp_command_template",
|
||||
"clrm_stat_t": "color_mode_state_topic",
|
||||
"clrm_val_tpl": "color_mode_value_template",
|
||||
@@ -185,6 +187,7 @@ ABBREVIATIONS = {
|
||||
"rgbww_cmd_t": "rgbww_command_topic",
|
||||
"rgbww_stat_t": "rgbww_state_topic",
|
||||
"rgbww_val_tpl": "rgbww_value_template",
|
||||
"segmnts": "segments",
|
||||
"send_cmd_t": "send_command_topic",
|
||||
"send_if_off": "send_if_off",
|
||||
"set_fan_spd_t": "set_fan_speed_topic",
|
||||
|
||||
@@ -1484,6 +1484,7 @@ class MqttEntity(
|
||||
self._config = config
|
||||
self._setup_from_config(self._config)
|
||||
self._setup_common_attributes_from_config(self._config)
|
||||
self._process_entity_update()
|
||||
|
||||
# Prepare MQTT subscriptions
|
||||
self.attributes_prepare_discovery_update(config)
|
||||
@@ -1586,6 +1587,10 @@ class MqttEntity(
|
||||
def _setup_from_config(self, config: ConfigType) -> None:
|
||||
"""(Re)Setup the entity."""
|
||||
|
||||
@callback
|
||||
def _process_entity_update(self) -> None:
|
||||
"""Process an entity discovery update."""
|
||||
|
||||
@abstractmethod
|
||||
@callback
|
||||
def _prepare_subscribe_topics(self) -> None:
|
||||
|
||||
@@ -10,12 +10,13 @@ import voluptuous as vol
|
||||
from homeassistant.components import vacuum
|
||||
from homeassistant.components.vacuum import (
|
||||
ENTITY_ID_FORMAT,
|
||||
Segment,
|
||||
StateVacuumEntity,
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -27,7 +28,7 @@ from . import subscription
|
||||
from .config import MQTT_BASE_SCHEMA
|
||||
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC
|
||||
from .entity import MqttEntity, async_setup_entity_entry_helper
|
||||
from .models import ReceiveMessage
|
||||
from .models import MqttCommandTemplate, ReceiveMessage
|
||||
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
|
||||
from .util import valid_publish_topic
|
||||
|
||||
@@ -52,6 +53,9 @@ POSSIBLE_STATES: dict[str, VacuumActivity] = {
|
||||
STATE_CLEANING: VacuumActivity.CLEANING,
|
||||
}
|
||||
|
||||
CONF_SEGMENTS = "segments"
|
||||
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC = "clean_segments_command_topic"
|
||||
CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE = "clean_segments_command_template"
|
||||
CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES
|
||||
CONF_PAYLOAD_TURN_ON = "payload_turn_on"
|
||||
CONF_PAYLOAD_TURN_OFF = "payload_turn_off"
|
||||
@@ -137,8 +141,39 @@ MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset(
|
||||
MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/"
|
||||
|
||||
|
||||
PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
|
||||
def validate_clean_area_config(config: ConfigType) -> ConfigType:
|
||||
"""Check for a valid configuration and check segments."""
|
||||
if (config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC not in config) or (
|
||||
not config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config
|
||||
):
|
||||
raise vol.Invalid(
|
||||
f"Options `{CONF_SEGMENTS}` and "
|
||||
f"`{CONF_CLEAN_SEGMENTS_COMMAND_TOPIC}` must be defined together"
|
||||
)
|
||||
segments: list[str]
|
||||
if segments := config[CONF_SEGMENTS]:
|
||||
if not config.get(CONF_UNIQUE_ID):
|
||||
raise vol.Invalid(
|
||||
f"Option `{CONF_SEGMENTS}` requires `{CONF_UNIQUE_ID}` to be configured"
|
||||
)
|
||||
unique_segments: set[str] = set()
|
||||
for segment in segments:
|
||||
segment_id, _, _ = segment.partition(".")
|
||||
if not segment_id or segment_id in unique_segments:
|
||||
raise vol.Invalid(
|
||||
f"The `{CONF_SEGMENTS}` option contains an invalid or non-"
|
||||
f"unique segment ID '{segment_id}'. Got {segments}"
|
||||
)
|
||||
unique_segments.add(segment_id)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_SEGMENTS, default=[]): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TOPIC): valid_publish_topic,
|
||||
vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
@@ -164,7 +199,10 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
|
||||
}
|
||||
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
|
||||
|
||||
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA)
|
||||
PLATFORM_SCHEMA_MODERN = vol.All(_BASE_SCHEMA, validate_clean_area_config)
|
||||
DISCOVERY_SCHEMA = vol.All(
|
||||
_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_clean_area_config
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -191,9 +229,11 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
_entity_id_format = ENTITY_ID_FORMAT
|
||||
_attributes_extra_blocked = MQTT_VACUUM_ATTRIBUTES_BLOCKED
|
||||
|
||||
_segments: list[Segment]
|
||||
_command_topic: str | None
|
||||
_set_fan_speed_topic: str | None
|
||||
_send_command_topic: str | None
|
||||
_clean_segments_command_topic: str
|
||||
_payloads: dict[str, str | None]
|
||||
|
||||
def __init__(
|
||||
@@ -229,6 +269,23 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
self._attr_supported_features = _strings_to_services(
|
||||
supported_feature_strings, STRING_TO_SERVICE
|
||||
)
|
||||
if config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config:
|
||||
self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA
|
||||
segments: list[str] = config[CONF_SEGMENTS]
|
||||
self._segments = [
|
||||
Segment(id=segment_id, name=name or segment_id)
|
||||
for segment_id, _, name in [
|
||||
segment.partition(".") for segment in segments
|
||||
]
|
||||
]
|
||||
self._clean_segments_command_topic = config[
|
||||
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC
|
||||
]
|
||||
self._clean_segments_command_template = MqttCommandTemplate(
|
||||
config.get(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE),
|
||||
entity=self,
|
||||
).async_render
|
||||
|
||||
self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST]
|
||||
self._command_topic = config.get(CONF_COMMAND_TOPIC)
|
||||
self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC)
|
||||
@@ -246,6 +303,20 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
)
|
||||
}
|
||||
|
||||
@callback
|
||||
def _process_entity_update(self) -> None:
|
||||
"""Check vacuum segments with registry entry."""
|
||||
if (
|
||||
self._attr_supported_features & VacuumEntityFeature.CLEAN_AREA
|
||||
and (last_seen := self.last_seen_segments) is not None
|
||||
and {s.id: s for s in last_seen} != {s.id: s for s in self._segments}
|
||||
):
|
||||
self.async_create_segments_issue()
|
||||
|
||||
async def mqtt_async_added_to_hass(self) -> None:
|
||||
"""Check vacuum segments with registry entry."""
|
||||
self._process_entity_update()
|
||||
|
||||
def _update_state_attributes(self, payload: dict[str, Any]) -> None:
|
||||
"""Update the entity state attributes."""
|
||||
self._state_attrs.update(payload)
|
||||
@@ -277,6 +348,19 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
"""(Re)Subscribe to topics."""
|
||||
subscription.async_subscribe_topics_internal(self.hass, self._sub_state)
|
||||
|
||||
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
|
||||
"""Perform an area clean."""
|
||||
await self.async_publish_with_config(
|
||||
self._clean_segments_command_topic,
|
||||
self._clean_segments_command_template(
|
||||
json_dumps(segment_ids), {"value": segment_ids}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_get_segments(self) -> list[Segment]:
|
||||
"""Return the available segments."""
|
||||
return self._segments
|
||||
|
||||
async def _async_publish_command(self, feature: VacuumEntityFeature) -> None:
|
||||
"""Publish a command."""
|
||||
if self._command_topic is None:
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["opower==0.17.0"]
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
@@ -71,7 +71,7 @@ rules:
|
||||
status: exempt
|
||||
comment: The integration has no user-configurable options that are not authentication-related.
|
||||
repair-issues: done
|
||||
stale-devices: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
@@ -15,7 +15,8 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
@@ -207,48 +208,102 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Opower sensor."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[OpowerSensor] = []
|
||||
opower_data_list = coordinator.data.values()
|
||||
for opower_data in opower_data_list:
|
||||
account = opower_data.account
|
||||
forecast = opower_data.forecast
|
||||
device_id = (
|
||||
f"{coordinator.api.utility.subdomain()}_{account.utility_account_id}"
|
||||
)
|
||||
device = DeviceInfo(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
name=f"{account.meter_type.name} account {account.utility_account_id}",
|
||||
manufacturer="Opower",
|
||||
model=coordinator.api.utility.name(),
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
sensors: tuple[OpowerEntityDescription, ...] = COMMON_SENSORS
|
||||
if (
|
||||
account.meter_type == MeterType.ELEC
|
||||
and forecast is not None
|
||||
and forecast.unit_of_measure == UnitOfMeasure.KWH
|
||||
):
|
||||
sensors += ELEC_SENSORS
|
||||
elif (
|
||||
account.meter_type == MeterType.GAS
|
||||
and forecast is not None
|
||||
and forecast.unit_of_measure in [UnitOfMeasure.THERM, UnitOfMeasure.CCF]
|
||||
):
|
||||
sensors += GAS_SENSORS
|
||||
entities.extend(
|
||||
OpowerSensor(
|
||||
coordinator,
|
||||
sensor,
|
||||
account.utility_account_id,
|
||||
device,
|
||||
device_id,
|
||||
)
|
||||
for sensor in sensors
|
||||
)
|
||||
created_sensors: set[tuple[str, str]] = set()
|
||||
|
||||
async_add_entities(entities)
|
||||
@callback
|
||||
def _update_entities() -> None:
|
||||
"""Update entities."""
|
||||
new_entities: list[OpowerSensor] = []
|
||||
current_account_device_ids: set[str] = set()
|
||||
current_account_ids: set[str] = set()
|
||||
|
||||
for opower_data in coordinator.data.values():
|
||||
account = opower_data.account
|
||||
forecast = opower_data.forecast
|
||||
device_id = (
|
||||
f"{coordinator.api.utility.subdomain()}_{account.utility_account_id}"
|
||||
)
|
||||
current_account_device_ids.add(device_id)
|
||||
current_account_ids.add(account.utility_account_id)
|
||||
device = DeviceInfo(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
name=f"{account.meter_type.name} account {account.utility_account_id}",
|
||||
manufacturer="Opower",
|
||||
model=coordinator.api.utility.name(),
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
sensors: tuple[OpowerEntityDescription, ...] = COMMON_SENSORS
|
||||
if (
|
||||
account.meter_type == MeterType.ELEC
|
||||
and forecast is not None
|
||||
and forecast.unit_of_measure == UnitOfMeasure.KWH
|
||||
):
|
||||
sensors += ELEC_SENSORS
|
||||
elif (
|
||||
account.meter_type == MeterType.GAS
|
||||
and forecast is not None
|
||||
and forecast.unit_of_measure in [UnitOfMeasure.THERM, UnitOfMeasure.CCF]
|
||||
):
|
||||
sensors += GAS_SENSORS
|
||||
for sensor in sensors:
|
||||
sensor_key = (account.utility_account_id, sensor.key)
|
||||
if sensor_key in created_sensors:
|
||||
continue
|
||||
created_sensors.add(sensor_key)
|
||||
new_entities.append(
|
||||
OpowerSensor(
|
||||
coordinator,
|
||||
sensor,
|
||||
account.utility_account_id,
|
||||
device,
|
||||
device_id,
|
||||
)
|
||||
)
|
||||
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
# Remove any registered devices not in the current coordinator data
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
for device_entry in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
device_domain_ids = {
|
||||
identifier[1]
|
||||
for identifier in device_entry.identifiers
|
||||
if identifier[0] == DOMAIN
|
||||
}
|
||||
if not device_domain_ids:
|
||||
# This device has no Opower identifiers; it may be a merged/shared
|
||||
# device owned by another integration. Do not alter it here.
|
||||
continue
|
||||
if not device_domain_ids.isdisjoint(current_account_device_ids):
|
||||
continue # device is still active
|
||||
# Device is stale — remove its entities then detach it
|
||||
for entity_entry in er.async_entries_for_device(
|
||||
entity_registry, device_entry.id, include_disabled_entities=True
|
||||
):
|
||||
if entity_entry.config_entry_id != entry.entry_id:
|
||||
continue
|
||||
entity_registry.async_remove(entity_entry.entity_id)
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, remove_config_entry_id=entry.entry_id
|
||||
)
|
||||
|
||||
# Prune sensor tracking for accounts that are no longer present
|
||||
if created_sensors:
|
||||
stale_sensor_keys = {
|
||||
sensor_key
|
||||
for sensor_key in created_sensors
|
||||
if sensor_key[0] not in current_account_ids
|
||||
}
|
||||
if stale_sensor_keys:
|
||||
created_sensors.difference_update(stale_sensor_keys)
|
||||
|
||||
_update_entities()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_update_entities))
|
||||
|
||||
|
||||
class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity):
|
||||
@@ -272,6 +327,11 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity):
|
||||
self._attr_device_info = device
|
||||
self.utility_account_id = utility_account_id
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.utility_account_id in self.coordinator.data
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | date | datetime:
|
||||
"""Return the state."""
|
||||
|
||||
@@ -14,7 +14,7 @@ from .coordinator import PranaConfigEntry, PranaCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.FAN, Platform.SENSOR, Platform.SWITCH]
|
||||
PLATFORMS = [Platform.FAN, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: PranaConfigEntry) -> bool:
|
||||
|
||||
@@ -21,8 +21,8 @@ from homeassistant.util.percentage import (
|
||||
)
|
||||
from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from . import PranaConfigEntry
|
||||
from .entity import PranaBaseEntity, PranaCoordinator
|
||||
from .coordinator import PranaConfigEntry, PranaCoordinator
|
||||
from .entity import PranaBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@@ -8,6 +8,20 @@
|
||||
"default": "mdi:arrow-expand-left"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"display_brightness": {
|
||||
"default": "mdi:brightness-6",
|
||||
"state": {
|
||||
"0": "mdi:brightness-2",
|
||||
"1": "mdi:brightness-4",
|
||||
"2": "mdi:brightness-4",
|
||||
"3": "mdi:brightness-5",
|
||||
"4": "mdi:brightness-5",
|
||||
"5": "mdi:brightness-7",
|
||||
"6": "mdi:brightness-7"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"inside_temperature": {
|
||||
"default": "mdi:home-thermometer"
|
||||
|
||||
80
homeassistant/components/prana/number.py
Normal file
80
homeassistant/components/prana/number.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Number platform for Prana integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import PranaConfigEntry, PranaCoordinator
|
||||
from .entity import PranaBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
class PranaNumberType(StrEnum):
|
||||
"""Enumerates Prana number types exposed by the device API."""
|
||||
|
||||
DISPLAY_BRIGHTNESS = "display_brightness"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PranaNumberEntityDescription(NumberEntityDescription):
|
||||
"""Description of a Prana number entity."""
|
||||
|
||||
key: PranaNumberType
|
||||
value_fn: Callable[[PranaCoordinator], float | None]
|
||||
set_value_fn: Callable[[Any, float], Any]
|
||||
|
||||
|
||||
ENTITIES: tuple[PranaNumberEntityDescription, ...] = (
|
||||
PranaNumberEntityDescription(
|
||||
key=PranaNumberType.DISPLAY_BRIGHTNESS,
|
||||
translation_key="display_brightness",
|
||||
native_min_value=0,
|
||||
native_max_value=6,
|
||||
native_step=1,
|
||||
mode=NumberMode.SLIDER,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda coord: coord.data.brightness,
|
||||
set_value_fn=lambda api, val: api.set_brightness(
|
||||
0 if val == 0 else 2 ** (int(val) - 1)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: PranaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Prana number entities from a config entry."""
|
||||
async_add_entities(
|
||||
PranaNumber(entry.runtime_data, entity_description)
|
||||
for entity_description in ENTITIES
|
||||
)
|
||||
|
||||
|
||||
class PranaNumber(PranaBaseEntity, NumberEntity):
|
||||
"""Representation of a Prana number entity."""
|
||||
|
||||
entity_description: PranaNumberEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the entity value."""
|
||||
return self.entity_description.value_fn(self.coordinator)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set new value."""
|
||||
await self.entity_description.set_value_fn(self.coordinator.api_client, value)
|
||||
await self.coordinator.async_refresh()
|
||||
@@ -21,8 +21,8 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PranaConfigEntry
|
||||
from .entity import PranaBaseEntity, PranaCoordinator
|
||||
from .coordinator import PranaConfigEntry, PranaCoordinator
|
||||
from .entity import PranaBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"display_brightness": {
|
||||
"name": "Display brightness"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"inside_temperature": {
|
||||
"name": "Inside temperature"
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PranaConfigEntry, PranaCoordinator
|
||||
from .coordinator import PranaConfigEntry, PranaCoordinator
|
||||
from .entity import PranaBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -4,10 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
|
||||
from aiodns.error import DNSError
|
||||
import pycountry
|
||||
from radios import FilterBy, Order, RadioBrowser, Station
|
||||
from radios import FilterBy, Order, RadioBrowser, RadioBrowserError, Station
|
||||
|
||||
from homeassistant.components.media_player import MediaClass, MediaType
|
||||
from homeassistant.components.media_player import BrowseError, MediaClass, MediaType
|
||||
from homeassistant.components.media_source import (
|
||||
BrowseMediaSource,
|
||||
MediaSource,
|
||||
@@ -15,6 +16,7 @@ from homeassistant.components.media_source import (
|
||||
PlayMedia,
|
||||
Unresolvable,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util.location import vincenty
|
||||
|
||||
@@ -55,9 +57,20 @@ class RadioMediaSource(MediaSource):
|
||||
|
||||
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||||
"""Resolve selected Radio station to a streaming URL."""
|
||||
radios = self.radios
|
||||
|
||||
station = await radios.station(uuid=item.identifier)
|
||||
if self.entry.state != ConfigEntryState.LOADED:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_ready",
|
||||
)
|
||||
radios = self.radios
|
||||
try:
|
||||
station = await radios.station(uuid=item.identifier)
|
||||
except (DNSError, RadioBrowserError) as e:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="radio_browser_error",
|
||||
) from e
|
||||
if not station:
|
||||
raise Unresolvable("Radio station is no longer available")
|
||||
|
||||
@@ -74,25 +87,37 @@ class RadioMediaSource(MediaSource):
|
||||
item: MediaSourceItem,
|
||||
) -> BrowseMediaSource:
|
||||
"""Return media."""
|
||||
|
||||
if self.entry.state != ConfigEntryState.LOADED:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_ready",
|
||||
)
|
||||
radios = self.radios
|
||||
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=None,
|
||||
media_class=MediaClass.CHANNEL,
|
||||
media_content_type=MediaType.MUSIC,
|
||||
title=self.entry.title,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
children=[
|
||||
*await self._async_build_popular(radios, item),
|
||||
*await self._async_build_by_tag(radios, item),
|
||||
*await self._async_build_by_language(radios, item),
|
||||
*await self._async_build_local(radios, item),
|
||||
*await self._async_build_by_country(radios, item),
|
||||
],
|
||||
)
|
||||
try:
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=None,
|
||||
media_class=MediaClass.CHANNEL,
|
||||
media_content_type=MediaType.MUSIC,
|
||||
title=self.entry.title,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
children=[
|
||||
*await self._async_build_popular(radios, item),
|
||||
*await self._async_build_by_tag(radios, item),
|
||||
*await self._async_build_by_language(radios, item),
|
||||
*await self._async_build_local(radios, item),
|
||||
*await self._async_build_by_country(radios, item),
|
||||
],
|
||||
)
|
||||
except (DNSError, RadioBrowserError) as e:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="radio_browser_error",
|
||||
) from e
|
||||
|
||||
@callback
|
||||
@staticmethod
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"description": "Do you want to add Radio Browser to Home Assistant?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"config_entry_not_ready": {
|
||||
"message": "Radio Browser integration is not ready"
|
||||
},
|
||||
"radio_browser_error": {
|
||||
"message": "Error occurred while communicating with Radio Browser"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==4.20.0",
|
||||
"python-roborock==4.25.0",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import datetime
|
||||
|
||||
import ephem
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.const import CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN, TYPE_ASTRONOMICAL
|
||||
|
||||
@@ -50,7 +50,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
def get_season(
|
||||
current_date: date, hemisphere: str, season_tracking_type: str
|
||||
current_datetime: datetime, hemisphere: str, season_tracking_type: str
|
||||
) -> str | None:
|
||||
"""Calculate the current season."""
|
||||
|
||||
@@ -58,22 +58,36 @@ def get_season(
|
||||
return None
|
||||
|
||||
if season_tracking_type == TYPE_ASTRONOMICAL:
|
||||
spring_start = ephem.next_equinox(str(current_date.year)).datetime()
|
||||
summer_start = ephem.next_solstice(str(current_date.year)).datetime()
|
||||
autumn_start = ephem.next_equinox(spring_start).datetime()
|
||||
winter_start = ephem.next_solstice(summer_start).datetime()
|
||||
spring_start = (
|
||||
ephem.next_equinox(str(current_datetime.year))
|
||||
.datetime()
|
||||
.replace(tzinfo=dt_util.UTC)
|
||||
)
|
||||
summer_start = (
|
||||
ephem.next_solstice(str(current_datetime.year))
|
||||
.datetime()
|
||||
.replace(tzinfo=dt_util.UTC)
|
||||
)
|
||||
autumn_start = (
|
||||
ephem.next_equinox(spring_start).datetime().replace(tzinfo=dt_util.UTC)
|
||||
)
|
||||
winter_start = (
|
||||
ephem.next_solstice(summer_start).datetime().replace(tzinfo=dt_util.UTC)
|
||||
)
|
||||
else:
|
||||
spring_start = datetime(2017, 3, 1).replace(year=current_date.year)
|
||||
spring_start = current_datetime.replace(
|
||||
month=3, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
summer_start = spring_start.replace(month=6)
|
||||
autumn_start = spring_start.replace(month=9)
|
||||
winter_start = spring_start.replace(month=12)
|
||||
|
||||
season = STATE_WINTER
|
||||
if spring_start <= current_date < summer_start:
|
||||
if spring_start <= current_datetime < summer_start:
|
||||
season = STATE_SPRING
|
||||
elif summer_start <= current_date < autumn_start:
|
||||
elif summer_start <= current_datetime < autumn_start:
|
||||
season = STATE_SUMMER
|
||||
elif autumn_start <= current_date < winter_start:
|
||||
elif autumn_start <= current_datetime < winter_start:
|
||||
season = STATE_AUTUMN
|
||||
|
||||
# If user is located in the southern hemisphere swap the season
|
||||
@@ -104,6 +118,4 @@ class SeasonSensorEntity(SensorEntity):
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update season."""
|
||||
self._attr_native_value = get_season(
|
||||
utcnow().replace(tzinfo=None), self.hemisphere, self.type
|
||||
)
|
||||
self._attr_native_value = get_season(dt_util.now(), self.hemisphere, self.type)
|
||||
|
||||
@@ -38,5 +38,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmartthings"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pysmartthings==3.7.0"]
|
||||
"requirements": ["pysmartthings==3.7.2"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "starlink",
|
||||
"name": "Starlink",
|
||||
"codeowners": ["@boswelja"],
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/starlink",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
import copy
|
||||
import dataclasses
|
||||
import datetime
|
||||
import logging
|
||||
@@ -28,7 +29,6 @@ from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.json import JsonValueType
|
||||
|
||||
from .const import (
|
||||
ATTR_DESCRIPTION,
|
||||
@@ -240,7 +240,7 @@ class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""An entity that represents a To-do list."""
|
||||
|
||||
_attr_todo_items: list[TodoItem] | None = None
|
||||
_update_listeners: list[Callable[[list[JsonValueType] | None], None]] | None = None
|
||||
_update_listeners: list[Callable[[list[TodoItem]], None]] | None = None
|
||||
|
||||
@property
|
||||
def state(self) -> int | None:
|
||||
@@ -281,13 +281,9 @@ class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
@final
|
||||
@callback
|
||||
def async_subscribe_updates(
|
||||
self,
|
||||
listener: Callable[[list[JsonValueType] | None], None],
|
||||
self, listener: Callable[[list[TodoItem]], None]
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Subscribe to To-do list item updates.
|
||||
|
||||
Called by websocket API.
|
||||
"""
|
||||
"""Subscribe to To-do list item updates."""
|
||||
if self._update_listeners is None:
|
||||
self._update_listeners = []
|
||||
self._update_listeners.append(listener)
|
||||
@@ -306,9 +302,7 @@ class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
if not self._update_listeners:
|
||||
return
|
||||
|
||||
todo_items: list[JsonValueType] = [
|
||||
dataclasses.asdict(item) for item in self.todo_items or ()
|
||||
]
|
||||
todo_items = [copy.copy(item) for item in self.todo_items or []]
|
||||
for listener in self._update_listeners:
|
||||
listener(todo_items)
|
||||
|
||||
@@ -341,13 +335,13 @@ async def websocket_handle_subscribe_todo_items(
|
||||
return
|
||||
|
||||
@callback
|
||||
def todo_item_listener(todo_items: list[JsonValueType] | None) -> None:
|
||||
def todo_item_listener(todo_items: list[TodoItem]) -> None:
|
||||
"""Push updated To-do list items to websocket."""
|
||||
connection.send_message(
|
||||
websocket_api.event_message(
|
||||
msg["id"],
|
||||
{
|
||||
"items": todo_items,
|
||||
"items": [dataclasses.asdict(item) for item in todo_items],
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -357,7 +351,7 @@ async def websocket_handle_subscribe_todo_items(
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
# Push an initial forecast update
|
||||
# Push an initial list update
|
||||
entity.async_update_listeners()
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,13 @@ ALARM: dict[DeviceCategory, tuple[AlarmControlPanelEntityDescription, ...]] = {
|
||||
key=DPCode.MASTER_MODE,
|
||||
name="Alarm",
|
||||
),
|
||||
)
|
||||
),
|
||||
DeviceCategory.WG2: (
|
||||
AlarmControlPanelEntityDescription(
|
||||
key=DPCode.MASTER_MODE,
|
||||
name="Alarm",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
_TUYA_TO_HA_STATE_MAPPINGS = {
|
||||
|
||||
@@ -317,6 +317,11 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ..
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
on_value="alarm",
|
||||
),
|
||||
TuyaBinarySensorEntityDescription(
|
||||
key=DPCode.CHARGE_STATE,
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
),
|
||||
DeviceCategory.WK: (
|
||||
TuyaBinarySensorEntityDescription(
|
||||
|
||||
@@ -1233,6 +1233,7 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
),
|
||||
*BATTERY_SENSORS,
|
||||
),
|
||||
DeviceCategory.WG2: (*BATTERY_SENSORS,),
|
||||
DeviceCategory.WK: (*BATTERY_SENSORS,),
|
||||
DeviceCategory.WKCZ: (
|
||||
TuyaSensorEntityDescription(
|
||||
|
||||
@@ -6,7 +6,6 @@ from contextlib import suppress
|
||||
import logging
|
||||
import os
|
||||
|
||||
from PyViCare.PyViCare import PyViCare
|
||||
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
|
||||
from PyViCare.PyViCareUtils import (
|
||||
PyViCareInvalidConfigurationError,
|
||||
@@ -68,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ViCareConfigEntry) -> bo
|
||||
return True
|
||||
|
||||
|
||||
def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> PyViCare:
|
||||
def setup_vicare_api(hass: HomeAssistant, entry: ViCareConfigEntry) -> ViCareData:
|
||||
"""Set up PyVicare API."""
|
||||
client = login(hass, entry.data)
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["victron-ble-ha-parser==0.6.1"]
|
||||
"requirements": ["victron-ble-ha-parser==0.6.2"]
|
||||
}
|
||||
|
||||
@@ -148,7 +148,10 @@ def error_to_state(value: float | str | None) -> str | None:
|
||||
"network_c": "network",
|
||||
"network_d": "network",
|
||||
}
|
||||
return value_map.get(value)
|
||||
mapped = value_map.get(value)
|
||||
if mapped is not None:
|
||||
return mapped
|
||||
return value if isinstance(value, str) and value in CHARGER_ERROR_OPTIONS else None
|
||||
|
||||
|
||||
DEVICE_STATE_OPTIONS = [
|
||||
|
||||
@@ -2,20 +2,34 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from pyvizio import VizioAsync
|
||||
|
||||
from homeassistant.components.media_player import MediaPlayerDeviceClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, Platform
|
||||
from homeassistant.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import CONF_APPS, DOMAIN
|
||||
from .coordinator import VizioAppsDataUpdateCoordinator
|
||||
from .const import DEFAULT_TIMEOUT, DEVICE_ID, DOMAIN, VIZIO_DEVICE_CLASSES
|
||||
from .coordinator import (
|
||||
VizioAppsDataUpdateCoordinator,
|
||||
VizioConfigEntry,
|
||||
VizioDeviceCoordinator,
|
||||
VizioRuntimeData,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
DATA_APPS: HassKey[VizioAppsDataUpdateCoordinator] = HassKey(f"{DOMAIN}_apps")
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
@@ -26,38 +40,54 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: VizioConfigEntry) -> bool:
|
||||
"""Load the saved entities."""
|
||||
host = entry.data[CONF_HOST]
|
||||
token = entry.data.get(CONF_ACCESS_TOKEN)
|
||||
device_class = entry.data[CONF_DEVICE_CLASS]
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
if (
|
||||
CONF_APPS not in hass.data[DOMAIN]
|
||||
and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV
|
||||
):
|
||||
store: Store[list[dict[str, Any]]] = Store(hass, 1, DOMAIN)
|
||||
coordinator = VizioAppsDataUpdateCoordinator(hass, store)
|
||||
await coordinator.async_setup()
|
||||
hass.data[DOMAIN][CONF_APPS] = coordinator
|
||||
await coordinator.async_refresh()
|
||||
# Create device
|
||||
device = VizioAsync(
|
||||
DEVICE_ID,
|
||||
host,
|
||||
entry.data[CONF_NAME],
|
||||
auth_token=token,
|
||||
device_type=VIZIO_DEVICE_CLASSES[device_class],
|
||||
session=async_get_clientsession(hass, False),
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
# Create device coordinator
|
||||
device_coordinator = VizioDeviceCoordinator(hass, entry, device)
|
||||
await device_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# Create apps coordinator for TVs (shared across entries)
|
||||
if device_class == MediaPlayerDeviceClass.TV and DATA_APPS not in hass.data:
|
||||
apps_coordinator = VizioAppsDataUpdateCoordinator(hass, Store(hass, 1, DOMAIN))
|
||||
await apps_coordinator.async_setup()
|
||||
hass.data[DATA_APPS] = apps_coordinator
|
||||
await apps_coordinator.async_refresh()
|
||||
|
||||
entry.runtime_data = VizioRuntimeData(
|
||||
device_coordinator=device_coordinator,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: VizioConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
)
|
||||
if not any(
|
||||
entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV
|
||||
for entry in hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
):
|
||||
if coordinator := hass.data[DOMAIN].pop(CONF_APPS, None):
|
||||
await coordinator.async_shutdown()
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if not hass.data[DOMAIN]:
|
||||
hass.data.pop(DOMAIN)
|
||||
# Clean up apps coordinator if no TV entries remain
|
||||
if unload_ok and not any(
|
||||
e.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV
|
||||
for e in hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
if e.entry_id != entry.entry_id
|
||||
):
|
||||
if apps_coordinator := hass.data.pop(DATA_APPS, None):
|
||||
await apps_coordinator.async_shutdown()
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -8,13 +8,12 @@ import socket
|
||||
from typing import Any
|
||||
|
||||
from pyvizio import VizioAsync, async_guess_device_type
|
||||
from pyvizio.const import APP_HOME
|
||||
from pyvizio.const import APP_HOME, APPS
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import MediaPlayerDeviceClass
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_ZEROCONF,
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
@@ -34,6 +33,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
from homeassistant.util.network import is_ip_address
|
||||
|
||||
from . import DATA_APPS
|
||||
from .const import (
|
||||
CONF_APPS,
|
||||
CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
|
||||
@@ -45,6 +45,7 @@ from .const import (
|
||||
DEVICE_ID,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import VizioConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -106,6 +107,14 @@ def _host_is_same(host1: str, host2: str) -> bool:
|
||||
class VizioOptionsConfigFlow(OptionsFlow):
|
||||
"""Handle Vizio options."""
|
||||
|
||||
def _get_app_list(self) -> list[dict[str, Any]]:
|
||||
"""Return the current apps list, falling back to defaults."""
|
||||
if (
|
||||
apps_coordinator := self.hass.data.get(DATA_APPS)
|
||||
) and apps_coordinator.data:
|
||||
return apps_coordinator.data
|
||||
return APPS
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -157,10 +166,7 @@ class VizioOptionsConfigFlow(OptionsFlow):
|
||||
): cv.multi_select(
|
||||
[
|
||||
APP_HOME["name"],
|
||||
*(
|
||||
app["name"]
|
||||
for app in self.hass.data[DOMAIN][CONF_APPS].data
|
||||
),
|
||||
*(app["name"] for app in self._get_app_list()),
|
||||
]
|
||||
),
|
||||
}
|
||||
@@ -176,7 +182,9 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> VizioOptionsConfigFlow:
|
||||
def async_get_options_flow(
|
||||
config_entry: VizioConfigEntry,
|
||||
) -> VizioOptionsConfigFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
return VizioOptionsConfigFlow()
|
||||
|
||||
|
||||
@@ -2,22 +2,150 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyvizio.const import APPS
|
||||
from pyvizio import VizioAsync
|
||||
from pyvizio.api.apps import AppConfig
|
||||
from pyvizio.api.input import InputItem
|
||||
from pyvizio.const import APPS, INPUT_APPS
|
||||
from pyvizio.util import gen_apps_list_from_url
|
||||
|
||||
from homeassistant.components.media_player import MediaPlayerDeviceClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE
|
||||
|
||||
type VizioConfigEntry = ConfigEntry[VizioRuntimeData]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VizioRuntimeData:
|
||||
"""Runtime data for Vizio integration."""
|
||||
|
||||
device_coordinator: VizioDeviceCoordinator
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VizioDeviceData:
|
||||
"""Raw data fetched from Vizio device."""
|
||||
|
||||
# Power state
|
||||
is_on: bool
|
||||
|
||||
# Audio settings from get_all_settings("audio")
|
||||
audio_settings: dict[str, Any] | None = None
|
||||
|
||||
# Sound mode options from get_setting_options("audio", "eq")
|
||||
sound_mode_list: list[str] | None = None
|
||||
|
||||
# Current input from get_current_input()
|
||||
current_input: str | None = None
|
||||
|
||||
# Available inputs from get_inputs_list()
|
||||
input_list: list[InputItem] | None = None
|
||||
|
||||
# Current app config from get_current_app_config() (TVs only)
|
||||
current_app_config: AppConfig | None = None
|
||||
|
||||
|
||||
class VizioDeviceCoordinator(DataUpdateCoordinator[VizioDeviceData]):
|
||||
"""Coordinator for Vizio device data."""
|
||||
|
||||
config_entry: VizioConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: VizioConfigEntry,
|
||||
device: VizioAsync,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.device = device
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Fetch device info and update device registry."""
|
||||
model = await self.device.get_model_name(log_api_exception=False)
|
||||
version = await self.device.get_version(log_api_exception=False)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert self.config_entry.unique_id
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
identifiers={(DOMAIN, self.config_entry.unique_id)},
|
||||
manufacturer="VIZIO",
|
||||
name=self.config_entry.data[CONF_NAME],
|
||||
model=model,
|
||||
sw_version=version,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> VizioDeviceData:
|
||||
"""Fetch all device data."""
|
||||
is_on = await self.device.get_power_state(log_api_exception=False)
|
||||
|
||||
if is_on is None:
|
||||
raise UpdateFailed(
|
||||
f"Unable to connect to {self.config_entry.data[CONF_HOST]}"
|
||||
)
|
||||
|
||||
if not is_on:
|
||||
return VizioDeviceData(is_on=False)
|
||||
|
||||
# Device is on - fetch all data
|
||||
audio_settings = await self.device.get_all_settings(
|
||||
VIZIO_AUDIO_SETTINGS, log_api_exception=False
|
||||
)
|
||||
|
||||
sound_mode_list = None
|
||||
if audio_settings and VIZIO_SOUND_MODE in audio_settings:
|
||||
sound_mode_list = await self.device.get_setting_options(
|
||||
VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, log_api_exception=False
|
||||
)
|
||||
|
||||
current_input = await self.device.get_current_input(log_api_exception=False)
|
||||
input_list = await self.device.get_inputs_list(log_api_exception=False)
|
||||
|
||||
current_app_config = None
|
||||
# Only attempt to fetch app config if the device is a TV and supports apps
|
||||
if (
|
||||
self.config_entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV
|
||||
and input_list
|
||||
and any(input_item.name in INPUT_APPS for input_item in input_list)
|
||||
):
|
||||
current_app_config = await self.device.get_current_app_config(
|
||||
log_api_exception=False
|
||||
)
|
||||
|
||||
return VizioDeviceData(
|
||||
is_on=True,
|
||||
audio_settings=audio_settings,
|
||||
sound_mode_list=sound_mode_list,
|
||||
current_input=current_input,
|
||||
input_list=input_list,
|
||||
current_app_config=current_app_config,
|
||||
)
|
||||
|
||||
|
||||
class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
|
||||
"""Define an object to hold Vizio app config data."""
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyvizio import AppConfig, VizioAsync
|
||||
from pyvizio.api.apps import find_app_name
|
||||
from pyvizio.api.apps import AppConfig, find_app_name
|
||||
from pyvizio.const import APP_HOME, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -15,58 +11,45 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_EXCLUDE,
|
||||
CONF_HOST,
|
||||
CONF_INCLUDE,
|
||||
CONF_NAME,
|
||||
)
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_EXCLUDE, CONF_INCLUDE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import DATA_APPS
|
||||
from .const import (
|
||||
CONF_ADDITIONAL_CONFIGS,
|
||||
CONF_APPS,
|
||||
CONF_VOLUME_STEP,
|
||||
DEFAULT_TIMEOUT,
|
||||
DEFAULT_VOLUME_STEP,
|
||||
DEVICE_ID,
|
||||
DOMAIN,
|
||||
SUPPORTED_COMMANDS,
|
||||
VIZIO_AUDIO_SETTINGS,
|
||||
VIZIO_DEVICE_CLASSES,
|
||||
VIZIO_MUTE,
|
||||
VIZIO_MUTE_ON,
|
||||
VIZIO_SOUND_MODE,
|
||||
VIZIO_VOLUME,
|
||||
)
|
||||
from .coordinator import VizioAppsDataUpdateCoordinator
|
||||
from .coordinator import (
|
||||
VizioAppsDataUpdateCoordinator,
|
||||
VizioConfigEntry,
|
||||
VizioDeviceCoordinator,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: VizioConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a Vizio media player entry."""
|
||||
host = config_entry.data[CONF_HOST]
|
||||
token = config_entry.data.get(CONF_ACCESS_TOKEN)
|
||||
name = config_entry.data[CONF_NAME]
|
||||
device_class = config_entry.data[CONF_DEVICE_CLASS]
|
||||
|
||||
# If config entry options not set up, set them up,
|
||||
@@ -105,59 +88,51 @@ async def async_setup_entry(
|
||||
**params, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
device = VizioAsync(
|
||||
DEVICE_ID,
|
||||
host,
|
||||
name,
|
||||
auth_token=token,
|
||||
device_type=VIZIO_DEVICE_CLASSES[device_class],
|
||||
session=async_get_clientsession(hass, False),
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
entity = VizioDevice(
|
||||
config_entry,
|
||||
device_class,
|
||||
config_entry.runtime_data.device_coordinator,
|
||||
hass.data.get(DATA_APPS) if device_class == MediaPlayerDeviceClass.TV else None,
|
||||
)
|
||||
|
||||
apps_coordinator = hass.data[DOMAIN].get(CONF_APPS)
|
||||
|
||||
entity = VizioDevice(config_entry, device, name, device_class, apps_coordinator)
|
||||
|
||||
async_add_entities([entity], update_before_add=True)
|
||||
async_add_entities([entity])
|
||||
|
||||
|
||||
class VizioDevice(MediaPlayerEntity):
|
||||
class VizioDevice(CoordinatorEntity[VizioDeviceCoordinator], MediaPlayerEntity):
|
||||
"""Media Player implementation which performs REST requests to device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_received_device_info = False
|
||||
_current_input: str | None = None
|
||||
_current_app_config: AppConfig | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: ConfigEntry,
|
||||
device: VizioAsync,
|
||||
name: str,
|
||||
config_entry: VizioConfigEntry,
|
||||
device_class: MediaPlayerDeviceClass,
|
||||
coordinator: VizioDeviceCoordinator,
|
||||
apps_coordinator: VizioAppsDataUpdateCoordinator | None,
|
||||
) -> None:
|
||||
"""Initialize Vizio device."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._config_entry = config_entry
|
||||
self._apps_coordinator = apps_coordinator
|
||||
|
||||
self._volume_step = config_entry.options[CONF_VOLUME_STEP]
|
||||
self._current_input: str | None = None
|
||||
self._current_app_config: AppConfig | None = None
|
||||
self._attr_sound_mode_list = []
|
||||
self._available_inputs: list[str] = []
|
||||
self._available_apps: list[str] = []
|
||||
|
||||
self._volume_step = config_entry.options[CONF_VOLUME_STEP]
|
||||
self._all_apps = apps_coordinator.data if apps_coordinator else None
|
||||
self._conf_apps = config_entry.options.get(CONF_APPS, {})
|
||||
self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get(
|
||||
CONF_ADDITIONAL_CONFIGS, []
|
||||
)
|
||||
self._device = device
|
||||
self._max_volume = float(device.get_max_volume())
|
||||
self._attr_assumed_state = True
|
||||
self._device = coordinator.device
|
||||
self._max_volume = float(coordinator.device.get_max_volume())
|
||||
|
||||
# Entity class attributes that will change with each update (we only include
|
||||
# the ones that are initialized differently from the defaults)
|
||||
self._attr_sound_mode_list = []
|
||||
self._attr_supported_features = SUPPORTED_COMMANDS[device_class]
|
||||
|
||||
# Entity class attributes that will not change
|
||||
@@ -165,11 +140,7 @@ class VizioDevice(MediaPlayerEntity):
|
||||
assert unique_id
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_device_class = device_class
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
manufacturer="VIZIO",
|
||||
name=name,
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)})
|
||||
|
||||
def _apps_list(self, apps: list[str]) -> list[str]:
|
||||
"""Return process apps list based on configured filters."""
|
||||
@@ -181,112 +152,72 @@ class VizioDevice(MediaPlayerEntity):
|
||||
|
||||
return apps
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Retrieve latest state of the device."""
|
||||
if (
|
||||
is_on := await self._device.get_power_state(log_api_exception=False)
|
||||
) is None:
|
||||
if self._attr_available:
|
||||
_LOGGER.warning(
|
||||
"Lost connection to %s", self._config_entry.data[CONF_HOST]
|
||||
)
|
||||
self._attr_available = False
|
||||
return
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
data = self.coordinator.data
|
||||
|
||||
if not self._attr_available:
|
||||
_LOGGER.warning(
|
||||
"Restored connection to %s", self._config_entry.data[CONF_HOST]
|
||||
)
|
||||
self._attr_available = True
|
||||
|
||||
if not self._received_device_info:
|
||||
device_reg = dr.async_get(self.hass)
|
||||
assert self._config_entry.unique_id
|
||||
device = device_reg.async_get_device(
|
||||
identifiers={(DOMAIN, self._config_entry.unique_id)}
|
||||
)
|
||||
if device:
|
||||
device_reg.async_update_device(
|
||||
device.id,
|
||||
model=await self._device.get_model_name(log_api_exception=False),
|
||||
sw_version=await self._device.get_version(log_api_exception=False),
|
||||
)
|
||||
self._received_device_info = True
|
||||
|
||||
if not is_on:
|
||||
# Handle device off
|
||||
if not data.is_on:
|
||||
self._attr_state = MediaPlayerState.OFF
|
||||
self._attr_volume_level = None
|
||||
self._attr_is_volume_muted = None
|
||||
self._current_input = None
|
||||
self._attr_app_name = None
|
||||
self._current_app_config = None
|
||||
self._attr_sound_mode = None
|
||||
self._attr_app_name = None
|
||||
self._current_input = None
|
||||
self._current_app_config = None
|
||||
super()._handle_coordinator_update()
|
||||
return
|
||||
|
||||
# Device is on - apply coordinator data
|
||||
self._attr_state = MediaPlayerState.ON
|
||||
|
||||
if audio_settings := await self._device.get_all_settings(
|
||||
VIZIO_AUDIO_SETTINGS, log_api_exception=False
|
||||
):
|
||||
# Audio settings
|
||||
if data.audio_settings:
|
||||
self._attr_volume_level = (
|
||||
float(audio_settings[VIZIO_VOLUME]) / self._max_volume
|
||||
float(data.audio_settings[VIZIO_VOLUME]) / self._max_volume
|
||||
)
|
||||
if VIZIO_MUTE in audio_settings:
|
||||
if VIZIO_MUTE in data.audio_settings:
|
||||
self._attr_is_volume_muted = (
|
||||
audio_settings[VIZIO_MUTE].lower() == VIZIO_MUTE_ON
|
||||
data.audio_settings[VIZIO_MUTE].lower() == VIZIO_MUTE_ON
|
||||
)
|
||||
else:
|
||||
self._attr_is_volume_muted = None
|
||||
|
||||
if VIZIO_SOUND_MODE in audio_settings:
|
||||
if VIZIO_SOUND_MODE in data.audio_settings:
|
||||
self._attr_supported_features |= (
|
||||
MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
)
|
||||
self._attr_sound_mode = audio_settings[VIZIO_SOUND_MODE]
|
||||
self._attr_sound_mode = data.audio_settings[VIZIO_SOUND_MODE]
|
||||
if not self._attr_sound_mode_list:
|
||||
self._attr_sound_mode_list = await self._device.get_setting_options(
|
||||
VIZIO_AUDIO_SETTINGS,
|
||||
VIZIO_SOUND_MODE,
|
||||
log_api_exception=False,
|
||||
)
|
||||
self._attr_sound_mode_list = data.sound_mode_list or []
|
||||
else:
|
||||
# Explicitly remove MediaPlayerEntityFeature.SELECT_SOUND_MODE from supported features
|
||||
self._attr_supported_features &= (
|
||||
~MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
)
|
||||
|
||||
if input_ := await self._device.get_current_input(log_api_exception=False):
|
||||
self._current_input = input_
|
||||
# Input state
|
||||
if data.current_input:
|
||||
self._current_input = data.current_input
|
||||
if data.input_list:
|
||||
self._available_inputs = [i.name for i in data.input_list]
|
||||
|
||||
# If no inputs returned, end update
|
||||
if not (inputs := await self._device.get_inputs_list(log_api_exception=False)):
|
||||
return
|
||||
|
||||
self._available_inputs = [input_.name for input_ in inputs]
|
||||
|
||||
# Return before setting app variables if INPUT_APPS isn't in available inputs
|
||||
if self._attr_device_class == MediaPlayerDeviceClass.SPEAKER or not any(
|
||||
app for app in INPUT_APPS if app in self._available_inputs
|
||||
# App state (TV only) - check if device supports apps
|
||||
if (
|
||||
self._attr_device_class == MediaPlayerDeviceClass.TV
|
||||
and self._available_inputs
|
||||
and any(app in self._available_inputs for app in INPUT_APPS)
|
||||
):
|
||||
return
|
||||
all_apps = self._all_apps or ()
|
||||
self._available_apps = self._apps_list([app["name"] for app in all_apps])
|
||||
self._current_app_config = data.current_app_config
|
||||
self._attr_app_name = find_app_name(
|
||||
self._current_app_config,
|
||||
[APP_HOME, *all_apps, *self._additional_app_configs],
|
||||
)
|
||||
if self._attr_app_name == NO_APP_RUNNING:
|
||||
self._attr_app_name = None
|
||||
|
||||
# Create list of available known apps from known app list after
|
||||
# filtering by CONF_INCLUDE/CONF_EXCLUDE
|
||||
self._available_apps = self._apps_list(
|
||||
[app["name"] for app in self._all_apps or ()]
|
||||
)
|
||||
|
||||
self._current_app_config = await self._device.get_current_app_config(
|
||||
log_api_exception=False
|
||||
)
|
||||
|
||||
self._attr_app_name = find_app_name(
|
||||
self._current_app_config,
|
||||
[APP_HOME, *(self._all_apps or ()), *self._additional_app_configs],
|
||||
)
|
||||
|
||||
if self._attr_app_name == NO_APP_RUNNING:
|
||||
self._attr_app_name = None
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
def _get_additional_app_names(self) -> list[str]:
|
||||
"""Return list of additional apps that were included in configuration.yaml."""
|
||||
@@ -296,7 +227,7 @@ class VizioDevice(MediaPlayerEntity):
|
||||
|
||||
@staticmethod
|
||||
async def _async_send_update_options_signal(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
hass: HomeAssistant, config_entry: VizioConfigEntry
|
||||
) -> None:
|
||||
"""Send update event when Vizio config entry is updated."""
|
||||
# Move this method to component level if another entity ever gets added for a
|
||||
@@ -304,7 +235,7 @@ class VizioDevice(MediaPlayerEntity):
|
||||
# See here: https://github.com/home-assistant/core/pull/30653#discussion_r366426121
|
||||
async_dispatcher_send(hass, config_entry.entry_id, config_entry)
|
||||
|
||||
async def _async_update_options(self, config_entry: ConfigEntry) -> None:
|
||||
async def _async_update_options(self, config_entry: VizioConfigEntry) -> None:
|
||||
"""Update options if the update signal comes from this entity."""
|
||||
self._volume_step = config_entry.options[CONF_VOLUME_STEP]
|
||||
# Update so that CONF_ADDITIONAL_CONFIGS gets retained for imports
|
||||
@@ -323,6 +254,11 @@ class VizioDevice(MediaPlayerEntity):
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# Process initial coordinator data
|
||||
self._handle_coordinator_update()
|
||||
|
||||
# Register callback for when config entry is updated.
|
||||
self.async_on_remove(
|
||||
self._config_entry.add_update_listener(
|
||||
@@ -337,21 +273,17 @@ class VizioDevice(MediaPlayerEntity):
|
||||
)
|
||||
)
|
||||
|
||||
if not self._apps_coordinator:
|
||||
if not (apps_coordinator := self._apps_coordinator):
|
||||
return
|
||||
|
||||
# Register callback for app list updates if device is a TV
|
||||
@callback
|
||||
def apps_list_update() -> None:
|
||||
"""Update list of all apps."""
|
||||
if not self._apps_coordinator:
|
||||
return
|
||||
self._all_apps = self._apps_coordinator.data
|
||||
self._all_apps = apps_coordinator.data
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
self._apps_coordinator.async_add_listener(apps_list_update)
|
||||
)
|
||||
self.async_on_remove(apps_coordinator.async_add_listener(apps_list_update))
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
|
||||
@@ -186,7 +186,7 @@ class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
||||
async def _async_determine_api_calls(
|
||||
self,
|
||||
) -> list[Callable[[], Coroutine[Any, Any, Any]]]:
|
||||
raise NotImplementedError
|
||||
"""Determine which API calls to make for this coordinator."""
|
||||
|
||||
|
||||
class VolvoVerySlowIntervalCoordinator(VolvoBaseCoordinator):
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyweatherflowudp"],
|
||||
"requirements": ["pyweatherflowudp==1.5.0"]
|
||||
"requirements": ["pyweatherflowudp==1.5.2"]
|
||||
}
|
||||
|
||||
@@ -1508,6 +1508,7 @@ class StateSelectorConfig(BaseSelectorConfig, total=False):
|
||||
|
||||
entity_id: str
|
||||
hide_states: list[str]
|
||||
attribute: str
|
||||
multiple: bool
|
||||
|
||||
|
||||
@@ -1530,11 +1531,7 @@ class StateSelector(Selector[StateSelectorConfig]):
|
||||
{
|
||||
vol.Optional("entity_id"): cv.entity_id,
|
||||
vol.Optional("hide_states"): [str],
|
||||
# The attribute to filter on, is currently deliberately not
|
||||
# configurable/exposed. We are considering separating state
|
||||
# selectors into two types: one for state and one for attribute.
|
||||
# Limiting the public use, prevents breaking changes in the future.
|
||||
# vol.Optional("attribute"): str,
|
||||
vol.Optional("attribute"): str,
|
||||
vol.Optional("multiple", default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1110,7 +1110,10 @@ async def _async_get_trigger_platform(
|
||||
|
||||
platform_and_sub_type = trigger_key.split(".")
|
||||
platform = platform_and_sub_type[0]
|
||||
platform = _PLATFORM_ALIASES.get(platform, platform)
|
||||
# Only apply aliases for old-style triggers (no sub_type).
|
||||
# New-style triggers (e.g. "event.received") use the integration domain directly.
|
||||
if len(platform_and_sub_type) == 1:
|
||||
platform = _PLATFORM_ALIASES.get(platform, platform)
|
||||
|
||||
if automation.is_disabled_experimental_trigger(hass, platform):
|
||||
raise vol.Invalid(
|
||||
|
||||
10
requirements_all.txt
generated
10
requirements_all.txt
generated
@@ -276,7 +276,7 @@ aioguardian==2026.01.1
|
||||
aioharmony==0.5.3
|
||||
|
||||
# homeassistant.components.hassio
|
||||
aiohasupervisor==0.4.1
|
||||
aiohasupervisor==0.4.2
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.32.0
|
||||
@@ -2494,7 +2494,7 @@ pysmappee==0.2.29
|
||||
pysmarlaapi==1.0.2
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.7.0
|
||||
pysmartthings==3.7.2
|
||||
|
||||
# homeassistant.components.smarty
|
||||
pysmarty2==0.10.3
|
||||
@@ -2651,7 +2651,7 @@ python-rabbitair==0.0.8
|
||||
python-ripple-api==0.0.3
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==4.20.0
|
||||
python-roborock==4.25.0
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.47
|
||||
@@ -2739,7 +2739,7 @@ pyvolumio==0.1.5
|
||||
pywaze==1.2.0
|
||||
|
||||
# homeassistant.components.weatherflow
|
||||
pyweatherflowudp==1.5.0
|
||||
pyweatherflowudp==1.5.2
|
||||
|
||||
# homeassistant.components.html5
|
||||
pywebpush==2.3.0
|
||||
@@ -3222,7 +3222,7 @@ venstarcolortouch==0.21
|
||||
viaggiatreno_ha==0.2.4
|
||||
|
||||
# homeassistant.components.victron_ble
|
||||
victron-ble-ha-parser==0.6.1
|
||||
victron-ble-ha-parser==0.6.2
|
||||
|
||||
# homeassistant.components.victron_remote_monitoring
|
||||
victron-vrm==0.1.8
|
||||
|
||||
10
requirements_test_all.txt
generated
10
requirements_test_all.txt
generated
@@ -264,7 +264,7 @@ aioguardian==2026.01.1
|
||||
aioharmony==0.5.3
|
||||
|
||||
# homeassistant.components.hassio
|
||||
aiohasupervisor==0.4.1
|
||||
aiohasupervisor==0.4.2
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.32.0
|
||||
@@ -2126,7 +2126,7 @@ pysmappee==0.2.29
|
||||
pysmarlaapi==1.0.2
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.7.0
|
||||
pysmartthings==3.7.2
|
||||
|
||||
# homeassistant.components.smarty
|
||||
pysmarty2==0.10.3
|
||||
@@ -2247,7 +2247,7 @@ python-pooldose==0.8.6
|
||||
python-rabbitair==0.0.8
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==4.20.0
|
||||
python-roborock==4.25.0
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.47
|
||||
@@ -2323,7 +2323,7 @@ pyvolumio==0.1.5
|
||||
pywaze==1.2.0
|
||||
|
||||
# homeassistant.components.weatherflow
|
||||
pyweatherflowudp==1.5.0
|
||||
pyweatherflowudp==1.5.2
|
||||
|
||||
# homeassistant.components.html5
|
||||
pywebpush==2.3.0
|
||||
@@ -2716,7 +2716,7 @@ velbus-aio==2026.2.0
|
||||
venstarcolortouch==0.21
|
||||
|
||||
# homeassistant.components.victron_ble
|
||||
victron-ble-ha-parser==0.6.1
|
||||
victron-ble-ha-parser==0.6.2
|
||||
|
||||
# homeassistant.components.victron_remote_monitoring
|
||||
victron-vrm==0.1.8
|
||||
|
||||
@@ -125,7 +125,7 @@ async def test_cover_unavailable(
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
mock_aladdin_connect_api.update_door.side_effect = aiohttp.ClientError()
|
||||
mock_aladdin_connect_api.get_doors.side_effect = aiohttp.ClientError()
|
||||
freezer.tick(15)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -5,16 +5,17 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiohttp import ClientConnectionError, RequestInfo
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.aladdin_connect import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import init_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
async def test_setup_entry(
|
||||
@@ -137,3 +138,49 @@ async def test_remove_stale_devices(
|
||||
)
|
||||
assert len(device_entries) == 1
|
||||
assert device_entries[0].identifiers == {(DOMAIN, "test_device_id-1")}
|
||||
|
||||
|
||||
async def test_dynamic_devices(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_aladdin_connect_api: AsyncMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test new devices are automatically discovered on coordinator refresh."""
|
||||
await init_integration(hass, mock_config_entry)
|
||||
|
||||
# Initially one door -> one cover entity + one sensor entity
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
assert len(device_entries) == 1
|
||||
assert hass.states.get("cover.test_door") is not None
|
||||
|
||||
# Simulate a new door appearing on the API
|
||||
mock_door_2 = AsyncMock()
|
||||
mock_door_2.device_id = "test_device_id_2"
|
||||
mock_door_2.door_number = 1
|
||||
mock_door_2.name = "Test Door 2"
|
||||
mock_door_2.status = "open"
|
||||
mock_door_2.link_status = "connected"
|
||||
mock_door_2.battery_level = 80
|
||||
mock_door_2.unique_id = f"{mock_door_2.device_id}-{mock_door_2.door_number}"
|
||||
|
||||
existing_door = mock_aladdin_connect_api.get_doors.return_value[0]
|
||||
mock_aladdin_connect_api.get_doors.return_value = [existing_door, mock_door_2]
|
||||
|
||||
# Trigger coordinator refresh
|
||||
freezer.tick(15)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Now two devices should exist
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
assert len(device_entries) == 2
|
||||
|
||||
# New cover entity should exist
|
||||
assert hass.states.get("cover.test_door_2") is not None
|
||||
|
||||
@@ -49,7 +49,7 @@ async def test_sensor_unavailable(
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
mock_aladdin_connect_api.update_door.side_effect = aiohttp.ClientError()
|
||||
mock_aladdin_connect_api.get_doors.side_effect = aiohttp.ClientError()
|
||||
freezer.tick(15)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -13,13 +13,13 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -78,7 +78,7 @@ async def test_alarm_control_panel_conditions_gated_by_labs_flag(
|
||||
condition="alarm_control_panel.is_armed_away",
|
||||
target_states=[AlarmControlPanelState.ARMED_AWAY],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
},
|
||||
),
|
||||
@@ -86,7 +86,7 @@ async def test_alarm_control_panel_conditions_gated_by_labs_flag(
|
||||
condition="alarm_control_panel.is_armed_home",
|
||||
target_states=[AlarmControlPanelState.ARMED_HOME],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_HOME),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
|
||||
},
|
||||
),
|
||||
@@ -94,7 +94,7 @@ async def test_alarm_control_panel_conditions_gated_by_labs_flag(
|
||||
condition="alarm_control_panel.is_armed_night",
|
||||
target_states=[AlarmControlPanelState.ARMED_NIGHT],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
},
|
||||
),
|
||||
@@ -102,7 +102,7 @@ async def test_alarm_control_panel_conditions_gated_by_labs_flag(
|
||||
condition="alarm_control_panel.is_armed_vacation",
|
||||
target_states=[AlarmControlPanelState.ARMED_VACATION],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
},
|
||||
),
|
||||
@@ -129,32 +129,17 @@ async def test_alarm_control_panel_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the alarm_control_panel state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_alarm_control_panels["included"]) - {entity_id}
|
||||
|
||||
# Set all alarm_control_panels, including the tested alarm_control_panel, to the initial state
|
||||
for eid in target_alarm_control_panels["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_alarm_control_panels,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other alarm_control_panels also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -185,7 +170,7 @@ async def test_alarm_control_panel_state_condition_behavior_any(
|
||||
condition="alarm_control_panel.is_armed_away",
|
||||
target_states=[AlarmControlPanelState.ARMED_AWAY],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
},
|
||||
),
|
||||
@@ -193,7 +178,7 @@ async def test_alarm_control_panel_state_condition_behavior_any(
|
||||
condition="alarm_control_panel.is_armed_home",
|
||||
target_states=[AlarmControlPanelState.ARMED_HOME],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_HOME),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
|
||||
},
|
||||
),
|
||||
@@ -201,7 +186,7 @@ async def test_alarm_control_panel_state_condition_behavior_any(
|
||||
condition="alarm_control_panel.is_armed_night",
|
||||
target_states=[AlarmControlPanelState.ARMED_NIGHT],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
},
|
||||
),
|
||||
@@ -209,7 +194,7 @@ async def test_alarm_control_panel_state_condition_behavior_any(
|
||||
condition="alarm_control_panel.is_armed_vacation",
|
||||
target_states=[AlarmControlPanelState.ARMED_VACATION],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
},
|
||||
),
|
||||
@@ -236,29 +221,13 @@ async def test_alarm_control_panel_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the alarm_control_panel state condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_alarm_control_panels["included"]) - {entity_id}
|
||||
|
||||
# Set all alarm_control_panels, including the tested alarm_control_panel, to the initial state
|
||||
for eid in target_alarm_control_panels["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_alarm_control_panels,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
@@ -78,7 +78,7 @@ async def test_alarm_control_panel_triggers_gated_by_labs_flag(
|
||||
trigger="alarm_control_panel.armed_away",
|
||||
target_states=[AlarmControlPanelState.ARMED_AWAY],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
},
|
||||
trigger_from_none=False,
|
||||
@@ -87,7 +87,7 @@ async def test_alarm_control_panel_triggers_gated_by_labs_flag(
|
||||
trigger="alarm_control_panel.armed_home",
|
||||
target_states=[AlarmControlPanelState.ARMED_HOME],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_HOME),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
|
||||
},
|
||||
trigger_from_none=False,
|
||||
@@ -96,7 +96,7 @@ async def test_alarm_control_panel_triggers_gated_by_labs_flag(
|
||||
trigger="alarm_control_panel.armed_night",
|
||||
target_states=[AlarmControlPanelState.ARMED_NIGHT],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
},
|
||||
trigger_from_none=False,
|
||||
@@ -105,7 +105,7 @@ async def test_alarm_control_panel_triggers_gated_by_labs_flag(
|
||||
trigger="alarm_control_panel.armed_vacation",
|
||||
target_states=[AlarmControlPanelState.ARMED_VACATION],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
},
|
||||
trigger_from_none=False,
|
||||
@@ -176,7 +176,7 @@ async def test_alarm_control_panel_state_trigger_behavior_any(
|
||||
trigger="alarm_control_panel.armed_away",
|
||||
target_states=[AlarmControlPanelState.ARMED_AWAY],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
},
|
||||
trigger_from_none=False,
|
||||
@@ -185,7 +185,7 @@ async def test_alarm_control_panel_state_trigger_behavior_any(
|
||||
trigger="alarm_control_panel.armed_home",
|
||||
target_states=[AlarmControlPanelState.ARMED_HOME],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_HOME),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
|
||||
},
|
||||
trigger_from_none=False,
|
||||
@@ -194,7 +194,7 @@ async def test_alarm_control_panel_state_trigger_behavior_any(
|
||||
trigger="alarm_control_panel.armed_night",
|
||||
target_states=[AlarmControlPanelState.ARMED_NIGHT],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
},
|
||||
trigger_from_none=False,
|
||||
@@ -203,7 +203,7 @@ async def test_alarm_control_panel_state_trigger_behavior_any(
|
||||
trigger="alarm_control_panel.armed_vacation",
|
||||
target_states=[AlarmControlPanelState.ARMED_VACATION],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
},
|
||||
trigger_from_none=False,
|
||||
@@ -274,7 +274,7 @@ async def test_alarm_control_panel_state_trigger_behavior_first(
|
||||
trigger="alarm_control_panel.armed_away",
|
||||
target_states=[AlarmControlPanelState.ARMED_AWAY],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
},
|
||||
trigger_from_none=False,
|
||||
@@ -283,7 +283,7 @@ async def test_alarm_control_panel_state_trigger_behavior_first(
|
||||
trigger="alarm_control_panel.armed_home",
|
||||
target_states=[AlarmControlPanelState.ARMED_HOME],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_HOME),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
|
||||
},
|
||||
trigger_from_none=False,
|
||||
@@ -292,7 +292,7 @@ async def test_alarm_control_panel_state_trigger_behavior_first(
|
||||
trigger="alarm_control_panel.armed_night",
|
||||
target_states=[AlarmControlPanelState.ARMED_NIGHT],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
},
|
||||
trigger_from_none=False,
|
||||
@@ -301,7 +301,7 @@ async def test_alarm_control_panel_state_trigger_behavior_first(
|
||||
trigger="alarm_control_panel.armed_vacation",
|
||||
target_states=[AlarmControlPanelState.ARMED_VACATION],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
|
||||
additional_attributes={
|
||||
required_filter_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
},
|
||||
trigger_from_none=False,
|
||||
|
||||
@@ -9,13 +9,13 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -83,32 +83,17 @@ async def test_assist_satellite_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the assist satellite state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_assist_satellites["included"]) - {entity_id}
|
||||
|
||||
# Set all assist satellites, including the tested one, to the initial state
|
||||
for eid in target_assist_satellites["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_assist_satellites,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other assist satellites also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -151,29 +136,13 @@ async def test_assist_satellite_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the assist satellite state condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_assist_satellites["included"]) - {entity_id}
|
||||
|
||||
# Set all assist satellites, including the tested one, to the initial state
|
||||
for eid in target_assist_satellites["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_assist_satellites,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
@@ -40,36 +40,19 @@ async def test_button_triggers_gated_by_labs_flag(
|
||||
(
|
||||
"button.pressed",
|
||||
[
|
||||
{"included": {"state": None, "attributes": {}}, "count": 0},
|
||||
{
|
||||
"included": {
|
||||
"included_state": {"state": None, "attributes": {}},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2021-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included": {
|
||||
"state": "2022-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
"button.pressed",
|
||||
[
|
||||
{"included": {"state": "foo", "attributes": {}}, "count": 0},
|
||||
{
|
||||
"included": {
|
||||
"state": "2021-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"included": {
|
||||
"included_state": {
|
||||
"state": "2022-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
@@ -81,25 +64,54 @@ async def test_button_triggers_gated_by_labs_flag(
|
||||
"button.pressed",
|
||||
[
|
||||
{
|
||||
"included": {"state": STATE_UNAVAILABLE, "attributes": {}},
|
||||
"included_state": {"state": "foo", "attributes": {}},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included": {
|
||||
"included_state": {
|
||||
"state": "2021-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2022-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
"button.pressed",
|
||||
[
|
||||
{
|
||||
"included_state": {
|
||||
"state": STATE_UNAVAILABLE,
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2021-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included": {
|
||||
"included_state": {
|
||||
"state": "2022-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"included": {"state": STATE_UNAVAILABLE, "attributes": {}},
|
||||
"included_state": {
|
||||
"state": STATE_UNAVAILABLE,
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
],
|
||||
@@ -107,27 +119,33 @@ async def test_button_triggers_gated_by_labs_flag(
|
||||
(
|
||||
"button.pressed",
|
||||
[
|
||||
{"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0},
|
||||
{
|
||||
"included": {
|
||||
"included_state": {"state": STATE_UNKNOWN, "attributes": {}},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2021-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"included": {
|
||||
"included_state": {
|
||||
"state": "2022-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0},
|
||||
{
|
||||
"included_state": {"state": STATE_UNKNOWN, "attributes": {}},
|
||||
"count": 0,
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_button_state_trigger_behavior_any(
|
||||
async def test_button_state_trigger(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_buttons: dict[str, list[str]],
|
||||
@@ -137,18 +155,18 @@ async def test_button_state_trigger_behavior_any(
|
||||
trigger: str,
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test that the button state trigger fires when any button state changes to a specific state."""
|
||||
other_entity_ids = set(target_buttons["included"]) - {entity_id}
|
||||
"""Test that the button state trigger fires when targeted button state changes."""
|
||||
other_entity_ids = set(target_buttons["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all buttons, including the tested button, to the initial state
|
||||
for eid in target_buttons["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
for eid in target_buttons["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, None, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
included_state = state["included_state"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
'fide': None,
|
||||
'followers': 2,
|
||||
'is_streamer': False,
|
||||
'joined': '2026-02-20T11:48:14',
|
||||
'last_online': '2026-03-06T13:32:59',
|
||||
'joined': '2026-02-20T10:48:14',
|
||||
'last_online': '2026-03-06T12:32:59',
|
||||
'location': 'Utrecht',
|
||||
'name': 'Joost',
|
||||
'player_id': 532748851,
|
||||
|
||||
@@ -13,13 +13,13 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -85,32 +85,17 @@ async def test_climate_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the climate state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_climates["included"]) - {entity_id}
|
||||
|
||||
# Set all climates, including the tested climate, to the initial state
|
||||
for eid in target_climates["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_climates,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other climates also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -150,33 +135,17 @@ async def test_climate_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the climate state condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_climates["included"]) - {entity_id}
|
||||
|
||||
# Set all climates, including the tested climate, to the initial state
|
||||
for eid in target_climates["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_climates,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -214,32 +183,17 @@ async def test_climate_attribute_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the climate attribute condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_climates["included"]) - {entity_id}
|
||||
|
||||
# Set all climates, including the tested climate, to the initial state
|
||||
for eid in target_climates["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_climates,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other climates also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -277,29 +231,13 @@ async def test_climate_attribute_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the climate attribute condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_climates["included"]) - {entity_id}
|
||||
|
||||
# Set all climates, including the tested climate, to the initial state
|
||||
for eid in target_climates["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_climates,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
@@ -49,8 +49,8 @@ async def target_entities(hass: HomeAssistant, domain: str) -> dict[str, list[st
|
||||
"""Create multiple entities associated with different targets.
|
||||
|
||||
Returns a dict with the following keys:
|
||||
- included: List of entity_ids meant to be targeted.
|
||||
- excluded: List of entity_ids not meant to be targeted.
|
||||
- included_entities: List of entity_ids meant to be targeted.
|
||||
- excluded_entities: List of entity_ids not meant to be targeted.
|
||||
"""
|
||||
config_entry = MockConfigEntry(domain="test")
|
||||
config_entry.add_to_hass(hass)
|
||||
@@ -133,7 +133,7 @@ async def target_entities(hass: HomeAssistant, domain: str) -> dict[str, list[st
|
||||
|
||||
# Return all available entities
|
||||
return {
|
||||
"included": [
|
||||
"included_entities": [
|
||||
f"{domain}.standalone_{domain}",
|
||||
f"{domain}.standalone2_{domain}",
|
||||
f"{domain}.label_{domain}",
|
||||
@@ -141,7 +141,7 @@ async def target_entities(hass: HomeAssistant, domain: str) -> dict[str, list[st
|
||||
f"{domain}.device_{domain}",
|
||||
f"{domain}.device2_{domain}",
|
||||
],
|
||||
"excluded": [
|
||||
"excluded_entities": [
|
||||
f"{domain}.standalone_{domain}_excluded",
|
||||
f"{domain}.label_{domain}_excluded",
|
||||
f"{domain}.area_{domain}_excluded",
|
||||
@@ -176,7 +176,7 @@ def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]:
|
||||
]
|
||||
|
||||
|
||||
class _StateDescription(TypedDict):
|
||||
class StateDescription(TypedDict):
|
||||
"""Test state with attributes."""
|
||||
|
||||
state: str | None
|
||||
@@ -186,16 +186,16 @@ class _StateDescription(TypedDict):
|
||||
class TriggerStateDescription(TypedDict):
|
||||
"""Test state and expected service call count."""
|
||||
|
||||
included: _StateDescription # State for entities meant to be targeted
|
||||
excluded: _StateDescription # State for entities not meant to be targeted
|
||||
included_state: StateDescription # State for entities meant to be targeted
|
||||
excluded_state: StateDescription # State for entities not meant to be targeted
|
||||
count: int # Expected service call count
|
||||
|
||||
|
||||
class ConditionStateDescription(TypedDict):
|
||||
"""Test state and expected condition evaluation."""
|
||||
|
||||
included: _StateDescription # State for entities meant to be targeted
|
||||
excluded: _StateDescription # State for entities not meant to be targeted
|
||||
included_state: StateDescription # State for entities meant to be targeted
|
||||
excluded_state: StateDescription # State for entities not meant to be targeted
|
||||
|
||||
condition_true: bool # If the condition is expected to evaluate to true
|
||||
condition_true_first_entity: bool # If the condition is expected to evaluate to true for the first targeted entity
|
||||
@@ -207,7 +207,7 @@ def _parametrize_condition_states(
|
||||
condition_options: dict[str, Any] | None = None,
|
||||
target_states: list[str | None | tuple[str | None, dict]],
|
||||
other_states: list[str | None | tuple[str | None, dict]],
|
||||
additional_attributes: dict | None,
|
||||
required_filter_attributes: dict | None,
|
||||
condition_true_if_invalid: bool,
|
||||
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
|
||||
"""Parametrize states and expected condition evaluations.
|
||||
@@ -219,8 +219,9 @@ def _parametrize_condition_states(
|
||||
where states is a list of ConditionStateDescription dicts.
|
||||
"""
|
||||
|
||||
additional_attributes = additional_attributes or {}
|
||||
required_filter_attributes = required_filter_attributes or {}
|
||||
condition_options = condition_options or {}
|
||||
has_required_filter_attributes = bool(required_filter_attributes)
|
||||
|
||||
def state_with_attributes(
|
||||
state: str | None | tuple[str | None, dict],
|
||||
@@ -230,24 +231,24 @@ def _parametrize_condition_states(
|
||||
"""Return ConditionStateDescription dict."""
|
||||
if isinstance(state, str) or state is None:
|
||||
return {
|
||||
"included": {
|
||||
"included_state": {
|
||||
"state": state,
|
||||
"attributes": additional_attributes,
|
||||
"attributes": required_filter_attributes,
|
||||
},
|
||||
"excluded": {
|
||||
"state": state,
|
||||
"excluded_state": {
|
||||
"state": state if has_required_filter_attributes else None,
|
||||
"attributes": {},
|
||||
},
|
||||
"condition_true": condition_true,
|
||||
"condition_true_first_entity": condition_true_first_entity,
|
||||
}
|
||||
return {
|
||||
"included": {
|
||||
"included_state": {
|
||||
"state": state[0],
|
||||
"attributes": state[1] | additional_attributes,
|
||||
"attributes": state[1] | required_filter_attributes,
|
||||
},
|
||||
"excluded": {
|
||||
"state": state[0],
|
||||
"excluded_state": {
|
||||
"state": state[0] if has_required_filter_attributes else None,
|
||||
"attributes": state[1],
|
||||
},
|
||||
"condition_true": condition_true,
|
||||
@@ -299,7 +300,7 @@ def parametrize_condition_states_any(
|
||||
condition_options: dict[str, Any] | None = None,
|
||||
target_states: list[str | None | tuple[str | None, dict]],
|
||||
other_states: list[str | None | tuple[str | None, dict]],
|
||||
additional_attributes: dict | None = None,
|
||||
required_filter_attributes: dict | None = None,
|
||||
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
|
||||
"""Parametrize states and expected condition evaluations.
|
||||
|
||||
@@ -315,7 +316,7 @@ def parametrize_condition_states_any(
|
||||
condition_options=condition_options,
|
||||
target_states=target_states,
|
||||
other_states=other_states,
|
||||
additional_attributes=additional_attributes,
|
||||
required_filter_attributes=required_filter_attributes,
|
||||
condition_true_if_invalid=False,
|
||||
)
|
||||
|
||||
@@ -326,7 +327,7 @@ def parametrize_condition_states_all(
|
||||
condition_options: dict[str, Any] | None = None,
|
||||
target_states: list[str | None | tuple[str | None, dict]],
|
||||
other_states: list[str | None | tuple[str | None, dict]],
|
||||
additional_attributes: dict | None = None,
|
||||
required_filter_attributes: dict | None = None,
|
||||
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
|
||||
"""Parametrize states and expected condition evaluations.
|
||||
|
||||
@@ -342,7 +343,7 @@ def parametrize_condition_states_all(
|
||||
condition_options=condition_options,
|
||||
target_states=target_states,
|
||||
other_states=other_states,
|
||||
additional_attributes=additional_attributes,
|
||||
required_filter_attributes=required_filter_attributes,
|
||||
condition_true_if_invalid=True,
|
||||
)
|
||||
|
||||
@@ -354,7 +355,7 @@ def parametrize_trigger_states(
|
||||
target_states: list[str | None | tuple[str | None, dict]],
|
||||
other_states: list[str | None | tuple[str | None, dict]],
|
||||
extra_invalid_states: list[str | None | tuple[str | None, dict]] | None = None,
|
||||
additional_attributes: dict | None = None,
|
||||
required_filter_attributes: dict | None = None,
|
||||
trigger_from_none: bool = True,
|
||||
retrigger_on_target_state: bool = False,
|
||||
) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]:
|
||||
@@ -377,7 +378,7 @@ def parametrize_trigger_states(
|
||||
|
||||
extra_invalid_states = extra_invalid_states or []
|
||||
invalid_states = [STATE_UNAVAILABLE, STATE_UNKNOWN, *extra_invalid_states]
|
||||
additional_attributes = additional_attributes or {}
|
||||
required_filter_attributes = required_filter_attributes or {}
|
||||
trigger_options = trigger_options or {}
|
||||
|
||||
def state_with_attributes(
|
||||
@@ -386,23 +387,23 @@ def parametrize_trigger_states(
|
||||
"""Return TriggerStateDescription dict."""
|
||||
if isinstance(state, str) or state is None:
|
||||
return {
|
||||
"included": {
|
||||
"included_state": {
|
||||
"state": state,
|
||||
"attributes": additional_attributes,
|
||||
"attributes": required_filter_attributes,
|
||||
},
|
||||
"excluded": {
|
||||
"state": state if additional_attributes else None,
|
||||
"excluded_state": {
|
||||
"state": state if required_filter_attributes else None,
|
||||
"attributes": {},
|
||||
},
|
||||
"count": count,
|
||||
}
|
||||
return {
|
||||
"included": {
|
||||
"included_state": {
|
||||
"state": state[0],
|
||||
"attributes": state[1] | additional_attributes,
|
||||
"attributes": state[1] | required_filter_attributes,
|
||||
},
|
||||
"excluded": {
|
||||
"state": state[0] if additional_attributes else None,
|
||||
"excluded_state": {
|
||||
"state": state[0] if required_filter_attributes else None,
|
||||
"attributes": state[1],
|
||||
},
|
||||
"count": count,
|
||||
@@ -644,14 +645,14 @@ def parametrize_numerical_state_value_changed_trigger_states(
|
||||
"""
|
||||
from homeassistant.const import ATTR_DEVICE_CLASS # noqa: PLC0415
|
||||
|
||||
additional_attributes = {ATTR_DEVICE_CLASS: device_class}
|
||||
required_filter_attributes = {ATTR_DEVICE_CLASS: device_class}
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={},
|
||||
target_states=["0", "50", "100"],
|
||||
other_states=["none"],
|
||||
additional_attributes=additional_attributes,
|
||||
required_filter_attributes=required_filter_attributes,
|
||||
retrigger_on_target_state=True,
|
||||
trigger_from_none=False,
|
||||
),
|
||||
@@ -660,7 +661,7 @@ def parametrize_numerical_state_value_changed_trigger_states(
|
||||
trigger_options={CONF_ABOVE: 10},
|
||||
target_states=["50", "100"],
|
||||
other_states=["none", "0"],
|
||||
additional_attributes=additional_attributes,
|
||||
required_filter_attributes=required_filter_attributes,
|
||||
retrigger_on_target_state=True,
|
||||
trigger_from_none=False,
|
||||
),
|
||||
@@ -669,7 +670,7 @@ def parametrize_numerical_state_value_changed_trigger_states(
|
||||
trigger_options={CONF_BELOW: 90},
|
||||
target_states=["0", "50"],
|
||||
other_states=["none", "100"],
|
||||
additional_attributes=additional_attributes,
|
||||
required_filter_attributes=required_filter_attributes,
|
||||
retrigger_on_target_state=True,
|
||||
trigger_from_none=False,
|
||||
),
|
||||
@@ -687,7 +688,7 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states(
|
||||
"""
|
||||
from homeassistant.const import ATTR_DEVICE_CLASS # noqa: PLC0415
|
||||
|
||||
additional_attributes = {ATTR_DEVICE_CLASS: device_class}
|
||||
required_filter_attributes = {ATTR_DEVICE_CLASS: device_class}
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
@@ -698,7 +699,7 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states(
|
||||
},
|
||||
target_states=["50", "60"],
|
||||
other_states=["none", "0", "100"],
|
||||
additional_attributes=additional_attributes,
|
||||
required_filter_attributes=required_filter_attributes,
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -710,7 +711,7 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states(
|
||||
},
|
||||
target_states=["0", "100"],
|
||||
other_states=["none", "50", "60"],
|
||||
additional_attributes=additional_attributes,
|
||||
required_filter_attributes=required_filter_attributes,
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -721,7 +722,7 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states(
|
||||
},
|
||||
target_states=["50", "100"],
|
||||
other_states=["none", "0"],
|
||||
additional_attributes=additional_attributes,
|
||||
required_filter_attributes=required_filter_attributes,
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -732,7 +733,7 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states(
|
||||
},
|
||||
target_states=["0", "50"],
|
||||
other_states=["none", "100"],
|
||||
additional_attributes=additional_attributes,
|
||||
required_filter_attributes=required_filter_attributes,
|
||||
trigger_from_none=False,
|
||||
),
|
||||
]
|
||||
@@ -791,7 +792,7 @@ async def create_target_condition(
|
||||
def set_or_remove_state(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
state: TriggerStateDescription,
|
||||
state: StateDescription,
|
||||
) -> None:
|
||||
"""Set or remove the state of an entity."""
|
||||
if state["state"] is None:
|
||||
@@ -864,6 +865,105 @@ async def assert_trigger_gated_by_labs_flag(
|
||||
) in caplog.text
|
||||
|
||||
|
||||
async def assert_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
target_entities: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_entities["included_entities"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id}
|
||||
|
||||
for eid in target_entities["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included_state"]
|
||||
excluded_state = state["excluded_state"]
|
||||
|
||||
# Set excluded entities first to verify that they don't make the
|
||||
# condition evaluate to true
|
||||
for excluded_entity_id in excluded_entity_ids:
|
||||
set_or_remove_state(hass, excluded_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) is False
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Set other included entities to the included state to verify that
|
||||
# they don't change the condition evaluation
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
async def assert_condition_behavior_all(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
target_entities: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_entities["included_entities"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id}
|
||||
|
||||
for eid in target_entities["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included_state"]
|
||||
excluded_state = state["excluded_state"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
for excluded_entity_id in excluded_entity_ids:
|
||||
set_or_remove_state(hass, excluded_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
async def assert_trigger_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
@@ -877,21 +977,21 @@ async def assert_trigger_behavior_any(
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test trigger fires in mode any."""
|
||||
other_entity_ids = set(target_entities["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_entities["excluded"]) - {entity_id}
|
||||
other_entity_ids = set(target_entities["included_entities"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id}
|
||||
|
||||
for eid in target_entities["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
for eid in target_entities["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
set_or_remove_state(hass, eid, states[0]["excluded_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, trigger_options, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
excluded_state = state["excluded"]
|
||||
included_state = state["included"]
|
||||
excluded_state = state["excluded_state"]
|
||||
included_state = state["included_state"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
@@ -922,14 +1022,14 @@ async def assert_trigger_behavior_first(
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test trigger fires in mode first."""
|
||||
other_entity_ids = set(target_entities["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_entities["excluded"]) - {entity_id}
|
||||
other_entity_ids = set(target_entities["included_entities"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id}
|
||||
|
||||
for eid in target_entities["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
for eid in target_entities["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
set_or_remove_state(hass, eid, states[0]["excluded_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(
|
||||
@@ -937,8 +1037,8 @@ async def assert_trigger_behavior_first(
|
||||
)
|
||||
|
||||
for state in states[1:]:
|
||||
excluded_state = state["excluded"]
|
||||
included_state = state["included"]
|
||||
excluded_state = state["excluded_state"]
|
||||
included_state = state["included_state"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
@@ -968,14 +1068,14 @@ async def assert_trigger_behavior_last(
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test trigger fires in mode last."""
|
||||
other_entity_ids = set(target_entities["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_entities["excluded"]) - {entity_id}
|
||||
other_entity_ids = set(target_entities["included_entities"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id}
|
||||
|
||||
for eid in target_entities["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
for eid in target_entities["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
set_or_remove_state(hass, eid, states[0]["excluded_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(
|
||||
@@ -983,8 +1083,8 @@ async def assert_trigger_behavior_last(
|
||||
)
|
||||
|
||||
for state in states[1:]:
|
||||
excluded_state = state["excluded"]
|
||||
included_state = state["included"]
|
||||
excluded_state = state["excluded_state"]
|
||||
included_state = state["included_state"]
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -10,12 +10,13 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -71,7 +72,7 @@ async def test_cover_conditions_gated_by_labs_flag(
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
),
|
||||
*parametrize_condition_states_any(
|
||||
condition=is_closed_key,
|
||||
@@ -84,7 +85,7 @@ async def test_cover_conditions_gated_by_labs_flag(
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
),
|
||||
)
|
||||
],
|
||||
@@ -100,38 +101,17 @@ async def test_cover_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test cover condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_covers["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_covers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_covers,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
excluded_state = state["excluded"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
for excluded_entity_id in excluded_entity_ids:
|
||||
set_or_remove_state(hass, excluded_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -155,7 +135,7 @@ async def test_cover_condition_behavior_any(
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
),
|
||||
*parametrize_condition_states_all(
|
||||
condition=is_closed_key,
|
||||
@@ -168,7 +148,7 @@ async def test_cover_condition_behavior_any(
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
),
|
||||
)
|
||||
],
|
||||
@@ -184,40 +164,17 @@ async def test_cover_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test cover condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_covers["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_covers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_covers,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
excluded_state = state["excluded"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
for excluded_entity_id in excluded_entity_ids:
|
||||
set_or_remove_state(hass, excluded_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -75,7 +75,7 @@ async def test_cover_triggers_gated_by_labs_flag(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -93,7 +93,7 @@ async def test_cover_triggers_gated_by_labs_flag(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
)
|
||||
@@ -149,7 +149,7 @@ async def test_cover_trigger_behavior_any(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -167,7 +167,7 @@ async def test_cover_trigger_behavior_any(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
)
|
||||
@@ -223,7 +223,7 @@ async def test_cover_trigger_behavior_first(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -241,7 +241,7 @@ async def test_cover_trigger_behavior_first(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: device_class},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""The tests for the Demo valve platform."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -17,10 +18,9 @@ from homeassistant.components.valve import (
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import async_capture_events, async_fire_time_changed
|
||||
from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed
|
||||
|
||||
FRONT_GARDEN = "valve.front_garden"
|
||||
ORCHARD = "valve.orchard"
|
||||
@@ -28,7 +28,7 @@ BACK_GARDEN = "valve.back_garden"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def valve_only() -> None:
|
||||
def valve_only() -> Generator[None]:
|
||||
"""Enable only the valve platform."""
|
||||
with patch(
|
||||
"homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM",
|
||||
@@ -38,11 +38,12 @@ async def valve_only() -> None:
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_comp(hass: HomeAssistant, valve_only: None):
|
||||
"""Set up demo component."""
|
||||
assert await async_setup_component(
|
||||
hass, VALVE_DOMAIN, {VALVE_DOMAIN: {"platform": DOMAIN}}
|
||||
)
|
||||
async def setup_comp(hass: HomeAssistant, valve_only: None) -> None:
|
||||
"""Set up demo component from config entry."""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@@ -50,6 +51,7 @@ async def setup_comp(hass: HomeAssistant, valve_only: None):
|
||||
async def test_closing(hass: HomeAssistant) -> None:
|
||||
"""Test the closing of a valve."""
|
||||
state = hass.states.get(FRONT_GARDEN)
|
||||
assert state is not None
|
||||
assert state.state == ValveState.OPEN
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -63,9 +65,11 @@ async def test_closing(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert state_changes[0].data["entity_id"] == FRONT_GARDEN
|
||||
assert state_changes[0].data["new_state"] is not None
|
||||
assert state_changes[0].data["new_state"].state == ValveState.CLOSING
|
||||
|
||||
assert state_changes[1].data["entity_id"] == FRONT_GARDEN
|
||||
assert state_changes[1].data["new_state"] is not None
|
||||
assert state_changes[1].data["new_state"].state == ValveState.CLOSED
|
||||
|
||||
|
||||
@@ -73,6 +77,7 @@ async def test_closing(hass: HomeAssistant) -> None:
|
||||
async def test_opening(hass: HomeAssistant) -> None:
|
||||
"""Test the opening of a valve."""
|
||||
state = hass.states.get(ORCHARD)
|
||||
assert state is not None
|
||||
assert state.state == ValveState.CLOSED
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -83,15 +88,18 @@ async def test_opening(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert state_changes[0].data["entity_id"] == ORCHARD
|
||||
assert state_changes[0].data["new_state"] is not None
|
||||
assert state_changes[0].data["new_state"].state == ValveState.OPENING
|
||||
|
||||
assert state_changes[1].data["entity_id"] == ORCHARD
|
||||
assert state_changes[1].data["new_state"] is not None
|
||||
assert state_changes[1].data["new_state"].state == ValveState.OPEN
|
||||
|
||||
|
||||
async def test_set_valve_position(hass: HomeAssistant) -> None:
|
||||
"""Test moving the valve to a specific position."""
|
||||
state = hass.states.get(BACK_GARDEN)
|
||||
assert state is not None
|
||||
assert state.attributes[ATTR_CURRENT_POSITION] == 70
|
||||
|
||||
# close to 10%
|
||||
@@ -102,6 +110,7 @@ async def test_set_valve_position(hass: HomeAssistant) -> None:
|
||||
blocking=True,
|
||||
)
|
||||
state = hass.states.get(BACK_GARDEN)
|
||||
assert state is not None
|
||||
assert state.state == ValveState.CLOSING
|
||||
|
||||
for _ in range(6):
|
||||
@@ -110,6 +119,7 @@ async def test_set_valve_position(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(BACK_GARDEN)
|
||||
assert state is not None
|
||||
assert state.attributes[ATTR_CURRENT_POSITION] == 10
|
||||
assert state.state == ValveState.OPEN
|
||||
|
||||
@@ -121,6 +131,7 @@ async def test_set_valve_position(hass: HomeAssistant) -> None:
|
||||
blocking=True,
|
||||
)
|
||||
state = hass.states.get(BACK_GARDEN)
|
||||
assert state is not None
|
||||
assert state.state == ValveState.OPENING
|
||||
|
||||
for _ in range(7):
|
||||
@@ -129,6 +140,7 @@ async def test_set_valve_position(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(BACK_GARDEN)
|
||||
assert state is not None
|
||||
assert state.attributes[ATTR_CURRENT_POSITION] == 80
|
||||
assert state.state == ValveState.OPEN
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -70,32 +70,17 @@ async def test_device_tracker_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the device tracker state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_device_trackers["included"]) - {entity_id}
|
||||
|
||||
# Set all device trackers, including the tested one, to the initial state
|
||||
for eid in target_device_trackers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_device_trackers,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other device trackers also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -128,29 +113,13 @@ async def test_device_tracker_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the device tracker state condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_device_trackers["included"]) - {entity_id}
|
||||
|
||||
# Set all device trackers, including the tested one, to the initial state
|
||||
for eid in target_device_trackers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_device_trackers,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
@@ -59,14 +59,14 @@ async def test_door_triggers_gated_by_labs_flag(
|
||||
trigger="door.opened",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="door.closed",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -118,7 +118,7 @@ async def test_door_trigger_binary_sensor_behavior_any(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -136,7 +136,7 @@ async def test_door_trigger_binary_sensor_behavior_any(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -178,14 +178,14 @@ async def test_door_trigger_cover_behavior_any(
|
||||
trigger="door.opened",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="door.closed",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -227,14 +227,14 @@ async def test_door_trigger_binary_sensor_behavior_first(
|
||||
trigger="door.opened",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="door.closed",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -286,7 +286,7 @@ async def test_door_trigger_binary_sensor_behavior_last(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -304,7 +304,7 @@ async def test_door_trigger_binary_sensor_behavior_last(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -356,7 +356,7 @@ async def test_door_trigger_cover_behavior_first(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -374,7 +374,7 @@ async def test_door_trigger_cover_behavior_first(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
|
||||
287
tests/components/event/test_trigger.py
Normal file
287
tests/components/event/test_trigger.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""Test event trigger."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.event.const import ATTR_EVENT_TYPE
|
||||
from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
|
||||
from tests.components.common import (
|
||||
TriggerStateDescription,
|
||||
arm_trigger,
|
||||
assert_trigger_gated_by_labs_flag,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_events(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple event entities associated with different targets."""
|
||||
return await target_entities(hass, "event")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trigger_key", ["event.received"])
|
||||
async def test_event_triggers_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
|
||||
) -> None:
|
||||
"""Test the event triggers are gated by the labs flag."""
|
||||
await assert_trigger_gated_by_labs_flag(
|
||||
hass,
|
||||
caplog,
|
||||
trigger_key,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("event"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
# Event received with matching event_type
|
||||
(
|
||||
"event.received",
|
||||
{"event_type": ["button_press"]},
|
||||
[
|
||||
{"included_state": {"state": None, "attributes": {}}, "count": 0},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:00.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:01.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
],
|
||||
),
|
||||
# Event received with non-matching event_type then matching
|
||||
(
|
||||
"event.received",
|
||||
{"event_type": ["button_press"]},
|
||||
[
|
||||
{"included_state": {"state": None, "attributes": {}}, "count": 0},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:00.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "other_event"},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:01.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
],
|
||||
),
|
||||
# Multiple event types configured
|
||||
(
|
||||
"event.received",
|
||||
{"event_type": ["button_press", "button_long_press"]},
|
||||
[
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:00.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:01.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_long_press"},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:02.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "other_event"},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:03.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
],
|
||||
),
|
||||
# From unavailable - first valid state after unavailable is not triggered
|
||||
(
|
||||
"event.received",
|
||||
{"event_type": ["button_press"]},
|
||||
[
|
||||
{
|
||||
"included_state": {
|
||||
"state": STATE_UNAVAILABLE,
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:00.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:01.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
],
|
||||
),
|
||||
# From unknown - first valid state after unknown is triggered
|
||||
(
|
||||
"event.received",
|
||||
{"event_type": ["button_press"]},
|
||||
[
|
||||
{
|
||||
"included_state": {
|
||||
"state": STATE_UNKNOWN,
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:00.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:01.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": STATE_UNKNOWN,
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
],
|
||||
),
|
||||
# Same event type fires again (different timestamps)
|
||||
(
|
||||
"event.received",
|
||||
{"event_type": ["button_press"]},
|
||||
[
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:00.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:01.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:02.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
],
|
||||
),
|
||||
# To unavailable - should not trigger, and first state after unavailable is skipped
|
||||
(
|
||||
"event.received",
|
||||
{"event_type": ["button_press"]},
|
||||
[
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:00.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": STATE_UNAVAILABLE,
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:01.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included_state": {
|
||||
"state": "2026-01-01T00:00:02.000+00:00",
|
||||
"attributes": {ATTR_EVENT_TYPE: "button_press"},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_event_state_trigger(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_events: dict[str, list[str]],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
trigger_options: dict,
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test that the event trigger fires targeted event entity state changes."""
|
||||
other_entity_ids = set(target_events["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all events to the initial state
|
||||
for eid in target_events["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, trigger_options, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included_state"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Check if changing other events also triggers
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == (entities_in_target - 1) * state["count"]
|
||||
service_calls.clear()
|
||||
@@ -81,11 +81,11 @@ async def test_fan_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the fan state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_fans["included"]) - {entity_id}
|
||||
other_entity_ids = set(target_fans["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all fans, including the tested fan, to the initial state
|
||||
for eid in target_fans["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
for eid in target_fans["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
@@ -97,13 +97,13 @@ async def test_fan_state_condition_behavior_any(
|
||||
|
||||
# Set state for switches to ensure that they don't impact the condition
|
||||
for state in states:
|
||||
for eid in target_switches["included"]:
|
||||
set_or_remove_state(hass, eid, state["included"])
|
||||
for eid in target_switches["included_entities"]:
|
||||
set_or_remove_state(hass, eid, state["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) is False
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
included_state = state["included_state"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
@@ -150,11 +150,11 @@ async def test_fan_state_condition_behavior_all(
|
||||
hass.states.async_set("switch.label_switch_1", STATE_OFF)
|
||||
hass.states.async_set("switch.label_switch_2", STATE_ON)
|
||||
|
||||
other_entity_ids = set(target_fans["included"]) - {entity_id}
|
||||
other_entity_ids = set(target_fans["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all fans, including the tested fan, to the initial state
|
||||
for eid in target_fans["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
for eid in target_fans["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
@@ -165,7 +165,7 @@ async def test_fan_state_condition_behavior_all(
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
included_state = state["included_state"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -59,14 +59,14 @@ async def test_garage_door_triggers_gated_by_labs_flag(
|
||||
trigger="garage_door.opened",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.closed",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -118,7 +118,7 @@ async def test_garage_door_trigger_binary_sensor_behavior_any(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -136,7 +136,7 @@ async def test_garage_door_trigger_binary_sensor_behavior_any(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -178,14 +178,14 @@ async def test_garage_door_trigger_cover_behavior_any(
|
||||
trigger="garage_door.opened",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.closed",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -227,14 +227,14 @@ async def test_garage_door_trigger_binary_sensor_behavior_first(
|
||||
trigger="garage_door.opened",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.closed",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -286,7 +286,7 @@ async def test_garage_door_trigger_binary_sensor_behavior_last(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -304,7 +304,7 @@ async def test_garage_door_trigger_binary_sensor_behavior_last(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -356,7 +356,7 @@ async def test_garage_door_trigger_cover_behavior_first(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -374,7 +374,7 @@ async def test_garage_door_trigger_cover_behavior_first(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -63,7 +63,7 @@ async def test_gate_triggers_gated_by_labs_flag(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -81,7 +81,7 @@ async def test_gate_triggers_gated_by_labs_flag(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -133,7 +133,7 @@ async def test_gate_trigger_cover_behavior_any(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -151,7 +151,7 @@ async def test_gate_trigger_cover_behavior_any(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -203,7 +203,7 @@ async def test_gate_trigger_cover_behavior_first(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
@@ -221,7 +221,7 @@ async def test_gate_trigger_cover_behavior_first(
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
'name': 'TLX123456',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'serial_number': 'TLX123456',
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
@@ -56,7 +56,7 @@
|
||||
'name': 'MIN123456',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'serial_number': 'MIN123456',
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.min123456_ac_frequency',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -2994,7 +2994,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.min123456_reactive_voltage',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3398,7 +3398,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.min123456_temperature_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3454,7 +3454,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.min123456_temperature_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3510,7 +3510,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.min123456_temperature_3',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3566,7 +3566,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.min123456_temperature_4',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -3622,7 +3622,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.min123456_temperature_5',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -4018,7 +4018,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.inv123456_ac_frequency',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -4667,7 +4667,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.inv123456_intelligent_power_management_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -4785,7 +4785,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.inv123456_inverter_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -4962,7 +4962,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.inv123456_reactive_amperage',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -5021,7 +5021,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.inv123456_reactive_voltage',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -5080,7 +5080,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.inv123456_reactive_wattage',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -7623,7 +7623,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.sto123456_ac_input_frequency',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -7741,7 +7741,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.sto123456_ac_output_frequency',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -10297,7 +10297,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_ac_frequency',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -13279,7 +13279,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_reactive_voltage',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -13683,7 +13683,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_temperature_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -13739,7 +13739,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_temperature_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -13795,7 +13795,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_temperature_3',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -13851,7 +13851,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_temperature_4',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -13907,7 +13907,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_temperature_5',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -13965,7 +13965,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.sph123456_ac_frequency',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -15651,7 +15651,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.sph123456_temperature_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -15710,7 +15710,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.sph123456_temperature_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -15769,7 +15769,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.sph123456_temperature_3',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -15828,7 +15828,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.sph123456_temperature_4',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -15887,7 +15887,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.sph123456_temperature_5',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -16622,7 +16622,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_ac_frequency',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -19604,7 +19604,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_reactive_voltage',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -20008,7 +20008,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_temperature_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -20064,7 +20064,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_temperature_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -20120,7 +20120,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_temperature_3',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -20176,7 +20176,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_temperature_4',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -20232,7 +20232,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.tlx123456_temperature_5',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
|
||||
@@ -254,7 +254,7 @@ async def test_classic_api_coordinator_auth_failed_triggers_reauth(
|
||||
and flow["context"]["entry_id"] == mock_config_entry_classic.entry_id
|
||||
for flow in flows
|
||||
)
|
||||
assert hass.states.get("sensor.tlx123456_ac_frequency").state == STATE_UNAVAILABLE
|
||||
assert hass.states.get("sensor.tlx123456_output_power").state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_classic_api_setup(
|
||||
|
||||
@@ -10,12 +10,12 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -73,32 +73,17 @@ async def test_humidifier_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the humidifier state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_humidifiers["included"]) - {entity_id}
|
||||
|
||||
# Set all humidifiers, including the tested humidifier, to the initial state
|
||||
for eid in target_humidifiers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_humidifiers,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other humidifiers also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -131,33 +116,17 @@ async def test_humidifier_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the humidifier state condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_humidifiers["included"]) - {entity_id}
|
||||
|
||||
# Set all humidifiers, including the tested humidifier, to the initial state
|
||||
for eid in target_humidifiers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_humidifiers,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -190,32 +159,17 @@ async def test_humidifier_attribute_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the humidifier attribute condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_humidifiers["included"]) - {entity_id}
|
||||
|
||||
# Set all humidifiers, including the tested humidifier, to the initial state
|
||||
for eid in target_humidifiers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_humidifiers,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other humidifiers also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -248,29 +202,13 @@ async def test_humidifier_attribute_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the humidifier attribute condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_humidifiers["included"]) - {entity_id}
|
||||
|
||||
# Set all humidifiers, including the tested humidifier, to the initial state
|
||||
for eid in target_humidifiers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_humidifiers,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': 'mdi:radiator-off',
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'huum',
|
||||
'previous_unique_id': None,
|
||||
@@ -53,7 +53,6 @@
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
<HVACMode.OFF: 'off'>,
|
||||
]),
|
||||
'icon': 'mdi:radiator-off',
|
||||
'max_temp': 110,
|
||||
'min_temp': 40,
|
||||
'supported_features': <ClimateEntityFeature: 385>,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# serializer version: 1
|
||||
# name: test_button[1][button.bk1600_enable_standby_mode-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
@@ -50,8 +51,9 @@
|
||||
# ---
|
||||
# name: test_button[2][button.cms_sf2000_enable_standby_mode-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
|
||||
@@ -9,13 +9,13 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -89,32 +89,17 @@ async def test_lawn_mower_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the lawn mower state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_lawn_mowers["included"]) - {entity_id}
|
||||
|
||||
# Set all lawn mowers, including the tested lawn mower, to the initial state
|
||||
for eid in target_lawn_mowers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_lawn_mowers,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other lawn mowers also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -162,29 +147,13 @@ async def test_lawn_mower_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the lawn mower state condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_lawn_mowers["included"]) - {entity_id}
|
||||
|
||||
# Set all lawn mowers, including the tested lawn mower, to the initial state
|
||||
for eid in target_lawn_mowers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_lawn_mowers,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
@@ -81,11 +81,11 @@ async def test_light_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the light state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_lights["included"]) - {entity_id}
|
||||
other_entity_ids = set(target_lights["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all lights, including the tested light, to the initial state
|
||||
for eid in target_lights["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
for eid in target_lights["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
@@ -97,13 +97,13 @@ async def test_light_state_condition_behavior_any(
|
||||
|
||||
# Set state for switches to ensure that they don't impact the condition
|
||||
for state in states:
|
||||
for eid in target_switches["included"]:
|
||||
set_or_remove_state(hass, eid, state["included"])
|
||||
for eid in target_switches["included_entities"]:
|
||||
set_or_remove_state(hass, eid, state["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) is False
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
included_state = state["included_state"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
@@ -150,11 +150,11 @@ async def test_light_state_condition_behavior_all(
|
||||
hass.states.async_set("switch.label_switch_1", STATE_OFF)
|
||||
hass.states.async_set("switch.label_switch_2", STATE_ON)
|
||||
|
||||
other_entity_ids = set(target_lights["included"]) - {entity_id}
|
||||
other_entity_ids = set(target_lights["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all lights, including the tested light, to the initial state
|
||||
for eid in target_lights["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
for eid in target_lights["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
@@ -165,7 +165,7 @@ async def test_light_state_condition_behavior_all(
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
included_state = state["included_state"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -9,13 +9,13 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -83,32 +83,17 @@ async def test_lock_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the lock state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_locks["included"]) - {entity_id}
|
||||
|
||||
# Set all locks, including the tested lock, to the initial state
|
||||
for eid in target_locks["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_locks,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other locks also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -151,29 +136,13 @@ async def test_lock_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the lock state condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_locks["included"]) - {entity_id}
|
||||
|
||||
# Set all locks, including the tested lock, to the initial state
|
||||
for eid in target_locks["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_locks,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
'discoverable': True,
|
||||
'group': False,
|
||||
'locked': False,
|
||||
'created_at': datetime.datetime(2016, 11, 24, 0, 0, tzinfo=tzutc()),
|
||||
'created_at': datetime.datetime(2016, 11, 24, 0, 0, tzinfo=tzlocal()),
|
||||
'following_count': 328,
|
||||
'followers_count': 3169,
|
||||
'statuses_count': 69523,
|
||||
|
||||
@@ -9,13 +9,13 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -101,32 +101,17 @@ async def test_media_player_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the media player state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_media_players["included"]) - {entity_id}
|
||||
|
||||
# Set all media players, including the tested media player, to the initial state
|
||||
for eid in target_media_players["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_media_players,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other media players also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -186,29 +171,13 @@ async def test_media_player_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the media player state condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_media_players["included"]) - {entity_id}
|
||||
|
||||
# Set all media players, including the tested media player, to the initial state
|
||||
for eid in target_media_players["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_media_players,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
@@ -52,14 +52,14 @@ async def test_motion_triggers_gated_by_labs_flag(
|
||||
trigger="motion.detected",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="motion.cleared",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -101,14 +101,14 @@ async def test_motion_trigger_binary_sensor_behavior_any(
|
||||
trigger="motion.detected",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="motion.cleared",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -150,14 +150,14 @@ async def test_motion_trigger_binary_sensor_behavior_first(
|
||||
trigger="motion.detected",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="motion.cleared",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "motion"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -30,6 +30,7 @@ from homeassistant.components.vacuum import (
|
||||
from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
|
||||
from .common import (
|
||||
help_custom_config,
|
||||
@@ -63,7 +64,11 @@ from .common import (
|
||||
|
||||
from tests.common import async_fire_mqtt_message
|
||||
from tests.components.vacuum import common
|
||||
from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient
|
||||
from tests.typing import (
|
||||
MqttMockHAClientGenerator,
|
||||
MqttMockPahoClient,
|
||||
WebSocketGenerator,
|
||||
)
|
||||
|
||||
COMMAND_TOPIC = "vacuum/command"
|
||||
SEND_COMMAND_TOPIC = "vacuum/send_command"
|
||||
@@ -82,6 +87,27 @@ DEFAULT_CONFIG = {
|
||||
}
|
||||
}
|
||||
|
||||
CONFIG_CLEAN_SEGMENTS_1 = {
|
||||
mqtt.DOMAIN: {
|
||||
vacuum.DOMAIN: {
|
||||
"name": "test",
|
||||
"unique_id": "veryunique",
|
||||
"segments": ["Livingroom", "Kitchen"],
|
||||
"clean_segments_command_topic": "vacuum/clean_segment",
|
||||
}
|
||||
}
|
||||
}
|
||||
CONFIG_CLEAN_SEGMENTS_2 = {
|
||||
mqtt.DOMAIN: {
|
||||
vacuum.DOMAIN: {
|
||||
"name": "test",
|
||||
"unique_id": "veryunique",
|
||||
"segments": ["1.Livingroom", "2.Kitchen"],
|
||||
"clean_segments_command_topic": "vacuum/clean_segment",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}}
|
||||
|
||||
CONFIG_ALL_SERVICES = help_custom_config(
|
||||
@@ -294,6 +320,347 @@ async def test_command_without_command_topic(
|
||||
mqtt_mock.async_publish.reset_mock()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS_1])
|
||||
async def test_clean_segments_initial_setup_without_repair_issue(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
) -> None:
|
||||
"""Test cleanable segments initial setup does not fire repair flow."""
|
||||
await mqtt_mock_entry()
|
||||
issue_registry = ir.async_get(hass)
|
||||
assert len(issue_registry.issues) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS_1])
|
||||
async def test_clean_segments_command_without_id(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
) -> None:
|
||||
"""Test cleanable segments without ID."""
|
||||
config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
|
||||
entity_registry.async_get_or_create(
|
||||
vacuum.DOMAIN,
|
||||
mqtt.DOMAIN,
|
||||
"veryunique",
|
||||
config_entry=config_entry,
|
||||
suggested_object_id="test",
|
||||
)
|
||||
entity_registry.async_update_entity_options(
|
||||
"vacuum.test",
|
||||
vacuum.DOMAIN,
|
||||
{
|
||||
"area_mapping": {"Nabu Casa": ["Kitchen", "Livingroom"]},
|
||||
"last_seen_segments": [
|
||||
{"id": "Livingroom", "name": "Livingroom"},
|
||||
{"id": "Kitchen", "name": "Kitchen"},
|
||||
],
|
||||
},
|
||||
)
|
||||
mqtt_mock = await mqtt_mock_entry()
|
||||
await hass.async_block_till_done()
|
||||
issue_registry = ir.async_get(hass)
|
||||
# We do not expect a repair flow
|
||||
assert len(issue_registry.issues) == 0
|
||||
|
||||
state = hass.states.get("vacuum.test")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
await common.async_clean_area(hass, ["Nabu Casa"], entity_id="vacuum.test")
|
||||
assert (
|
||||
call("vacuum/clean_segment", '["Kitchen","Livingroom"]', 0, False)
|
||||
in mqtt_mock.async_publish.mock_calls
|
||||
)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(
|
||||
{"type": "vacuum/get_segments", "entity_id": "vacuum.test"}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"]["segments"] == [
|
||||
{"id": "Livingroom", "name": "Livingroom", "group": None},
|
||||
{"id": "Kitchen", "name": "Kitchen", "group": None},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS_2])
|
||||
async def test_clean_segments_command_with_id(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
) -> None:
|
||||
"""Test cleanable segments with ID."""
|
||||
mqtt_mock = await mqtt_mock_entry()
|
||||
# Set the area mapping
|
||||
entity_registry.async_update_entity_options(
|
||||
"vacuum.test",
|
||||
vacuum.DOMAIN,
|
||||
{
|
||||
"area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]},
|
||||
"last_seen_segments": [
|
||||
{"id": "1", "name": "Livingroom"},
|
||||
{"id": "2", "name": "Kitchen"},
|
||||
],
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("vacuum.test")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
await common.async_clean_area(hass, ["Kitchen"], entity_id="vacuum.test")
|
||||
assert (
|
||||
call("vacuum/clean_segment", '["2"]', 0, False)
|
||||
in mqtt_mock.async_publish.mock_calls
|
||||
)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(
|
||||
{"type": "vacuum/get_segments", "entity_id": "vacuum.test"}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"]["segments"] == [
|
||||
{"id": "1", "name": "Livingroom", "group": None},
|
||||
{"id": "2", "name": "Kitchen", "group": None},
|
||||
]
|
||||
|
||||
|
||||
async def test_clean_segments_command_update(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test cleanable segments update via discovery."""
|
||||
# Prepare original entity config entry
|
||||
config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
|
||||
entity_registry.async_get_or_create(
|
||||
vacuum.DOMAIN,
|
||||
mqtt.DOMAIN,
|
||||
"veryunique",
|
||||
config_entry=config_entry,
|
||||
suggested_object_id="test",
|
||||
)
|
||||
entity_registry.async_update_entity_options(
|
||||
"vacuum.test",
|
||||
vacuum.DOMAIN,
|
||||
{
|
||||
"area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]},
|
||||
"last_seen_segments": [
|
||||
{"id": "1", "name": "Livingroom"},
|
||||
{"id": "2", "name": "Kitchen"},
|
||||
],
|
||||
},
|
||||
)
|
||||
await mqtt_mock_entry()
|
||||
# Do initial discovery
|
||||
config1 = CONFIG_CLEAN_SEGMENTS_2[mqtt.DOMAIN][vacuum.DOMAIN]
|
||||
payload1 = json.dumps(config1)
|
||||
config_topic = "homeassistant/vacuum/bla/config"
|
||||
async_fire_mqtt_message(hass, config_topic, payload1)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get("vacuum.test")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
issue_registry = ir.async_get(hass)
|
||||
# We do not expect a repair flow
|
||||
assert len(issue_registry.issues) == 0
|
||||
|
||||
# Update the segments
|
||||
config2 = config1.copy()
|
||||
config2["segments"] = ["1.Livingroom", "2.Kitchen", "3.Diningroom"]
|
||||
payload2 = json.dumps(config2)
|
||||
async_fire_mqtt_message(hass, config_topic, payload2)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# A repair flow should start
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(
|
||||
{"type": "vacuum/get_segments", "entity_id": "vacuum.test"}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"]["segments"] == [
|
||||
{"id": "1", "name": "Livingroom", "group": None},
|
||||
{"id": "2", "name": "Kitchen", "group": None},
|
||||
{"id": "3", "name": "Diningroom", "group": None},
|
||||
]
|
||||
|
||||
# Test update with a non-unique segment list fails
|
||||
config3 = config1.copy()
|
||||
config3["segments"] = ["1.Livingroom", "2.Kitchen", "2.Diningroom"]
|
||||
payload3 = json.dumps(config3)
|
||||
async_fire_mqtt_message(hass, config_topic, payload3)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
"Error 'The `segments` option contains an invalid or non-unique segment ID '2'"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
{
|
||||
mqtt.DOMAIN: {
|
||||
vacuum.DOMAIN: {
|
||||
"name": "test",
|
||||
"unique_id": "veryunique",
|
||||
"segments": ["Livingroom", "Kitchen", "Kitchen"],
|
||||
"clean_segments_command_topic": "vacuum/clean_segment",
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
mqtt.DOMAIN: {
|
||||
vacuum.DOMAIN: {
|
||||
"name": "test",
|
||||
"unique_id": "veryunique",
|
||||
"segments": ["Livingroom", "Kitchen", ""],
|
||||
"clean_segments_command_topic": "vacuum/clean_segment",
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
mqtt.DOMAIN: {
|
||||
vacuum.DOMAIN: {
|
||||
"name": "test",
|
||||
"unique_id": "veryunique",
|
||||
"segments": ["1.Livingroom", "1.Kitchen"],
|
||||
"clean_segments_command_topic": "vacuum/clean_segment",
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
mqtt.DOMAIN: {
|
||||
vacuum.DOMAIN: {
|
||||
"name": "test",
|
||||
"unique_id": "veryunique",
|
||||
"segments": ["1.Livingroom", "1.Kitchen", ".Diningroom"],
|
||||
"clean_segments_command_topic": "vacuum/clean_segment",
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_non_unique_segments(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test with non-unique list of cleanable segments with valid segment IDs."""
|
||||
await mqtt_mock_entry()
|
||||
assert (
|
||||
"The `segments` option contains an invalid or non-unique segment ID"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass")
|
||||
@pytest.mark.parametrize(
|
||||
("hass_config", "error_message"),
|
||||
[
|
||||
(
|
||||
help_custom_config(
|
||||
vacuum.DOMAIN,
|
||||
DEFAULT_CONFIG,
|
||||
({"clean_segments_command_topic": "test-topic"},),
|
||||
),
|
||||
"Options `segments` and "
|
||||
"`clean_segments_command_topic` must be defined together",
|
||||
),
|
||||
(
|
||||
help_custom_config(
|
||||
vacuum.DOMAIN,
|
||||
DEFAULT_CONFIG,
|
||||
({"segments": ["Livingroom"]},),
|
||||
),
|
||||
"Options `segments` and "
|
||||
"`clean_segments_command_topic` must be defined together",
|
||||
),
|
||||
(
|
||||
help_custom_config(
|
||||
vacuum.DOMAIN,
|
||||
DEFAULT_CONFIG,
|
||||
(
|
||||
{
|
||||
"segments": ["Livingroom"],
|
||||
"clean_segments_command_topic": "test-topic",
|
||||
},
|
||||
),
|
||||
),
|
||||
"Option `segments` requires `unique_id` to be configured",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_clean_segments_config_validation(
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
error_message: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test status clean segment config validation."""
|
||||
await mqtt_mock_entry()
|
||||
assert error_message in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
help_custom_config(
|
||||
vacuum.DOMAIN,
|
||||
CONFIG_CLEAN_SEGMENTS_2,
|
||||
({"clean_segments_command_template": "{{ ';'.join(value) }}"},),
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_clean_segments_command_with_id_and_command_template(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
) -> None:
|
||||
"""Test clean segments with command template."""
|
||||
mqtt_mock = await mqtt_mock_entry()
|
||||
entity_registry.async_update_entity_options(
|
||||
"vacuum.test",
|
||||
vacuum.DOMAIN,
|
||||
{
|
||||
"area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]},
|
||||
"last_seen_segments": [
|
||||
{"id": "1", "name": "Livingroom"},
|
||||
{"id": "2", "name": "Kitchen"},
|
||||
],
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("vacuum.test")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
await common.async_clean_area(
|
||||
hass, ["Livingroom", "Kitchen"], entity_id="vacuum.test"
|
||||
)
|
||||
assert (
|
||||
call("vacuum/clean_segment", "1;2", 0, False)
|
||||
in mqtt_mock.async_publish.mock_calls
|
||||
)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(
|
||||
{"type": "vacuum/get_segments", "entity_id": "vacuum.test"}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"]["segments"] == [
|
||||
{"id": "1", "name": "Livingroom", "group": None},
|
||||
{"id": "2", "name": "Kitchen", "group": None},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES])
|
||||
async def test_status(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
|
||||
@@ -52,14 +52,14 @@ async def test_occupancy_triggers_gated_by_labs_flag(
|
||||
trigger="occupancy.detected",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="occupancy.cleared",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -101,14 +101,14 @@ async def test_occupancy_trigger_binary_sensor_behavior_any(
|
||||
trigger="occupancy.detected",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="occupancy.cleared",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
@@ -150,14 +150,14 @@ async def test_occupancy_trigger_binary_sensor_behavior_first(
|
||||
trigger="occupancy.detected",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="occupancy.cleared",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
required_filter_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -6,10 +6,11 @@ from unittest.mock import AsyncMock, patch
|
||||
from opower import CostRead
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.opower.const import DOMAIN
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
@@ -114,3 +115,111 @@ async def test_sensors(
|
||||
state = hass.states.get("sensor.gas_account_222222_last_updated")
|
||||
assert state
|
||||
assert state.state == "2023-01-02T08:00:00+00:00"
|
||||
|
||||
|
||||
async def test_dynamic_and_stale_devices(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_opower_api: AsyncMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test the dynamic addition and removal of Opower devices."""
|
||||
original_accounts = mock_opower_api.async_get_accounts.return_value
|
||||
original_forecasts = mock_opower_api.async_get_forecast.return_value
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
devices = dr.async_entries_for_config_entry(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
initial_device_ids = {device.id for device in devices}
|
||||
initial_entity_ids = {entity.entity_id for entity in entities}
|
||||
# Ensure we actually created some devices and entities for this entry
|
||||
assert initial_device_ids
|
||||
assert initial_entity_ids
|
||||
|
||||
# Remove the second account and update data
|
||||
mock_opower_api.async_get_accounts.return_value = [original_accounts[0]]
|
||||
mock_opower_api.async_get_forecast.return_value = [original_forecasts[0]]
|
||||
|
||||
coordinator = mock_config_entry.runtime_data
|
||||
await coordinator.async_refresh()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
devices = dr.async_entries_for_config_entry(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
device_ids_after_removal = {device.id for device in devices}
|
||||
entity_ids_after_removal = {entity.entity_id for entity in entities}
|
||||
# After removing one account, we should have removed some devices/entities
|
||||
# but not added any new ones.
|
||||
assert device_ids_after_removal <= initial_device_ids
|
||||
assert entity_ids_after_removal <= initial_entity_ids
|
||||
assert device_ids_after_removal != initial_device_ids
|
||||
assert entity_ids_after_removal != initial_entity_ids
|
||||
|
||||
# Add back the second account
|
||||
mock_opower_api.async_get_accounts.return_value = original_accounts
|
||||
mock_opower_api.async_get_forecast.return_value = original_forecasts
|
||||
|
||||
await coordinator.async_refresh()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
devices = dr.async_entries_for_config_entry(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
device_ids_after_restore = {device.id for device in devices}
|
||||
entity_ids_after_restore = {entity.entity_id for entity in entities}
|
||||
# After restoring the second account, we should be back to the original
|
||||
# number of devices and entities (IDs themselves may change on re-create).
|
||||
assert len(device_ids_after_restore) == len(initial_device_ids)
|
||||
assert len(entity_ids_after_restore) == len(initial_entity_ids)
|
||||
|
||||
|
||||
async def test_stale_device_removed_on_load(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_opower_api: AsyncMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test that a stale device present before setup is removed on first load."""
|
||||
# Simulate a device that was created by a previous version / old account
|
||||
# and is already registered before the integration sets up.
|
||||
stale_device = device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
identifiers={(DOMAIN, "pge_stale_account_99999")},
|
||||
)
|
||||
assert device_registry.async_get(stale_device.id) is not None
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Stale device should have been removed on first coordinator update
|
||||
assert device_registry.async_get(stale_device.id) is None
|
||||
|
||||
# Active devices for known accounts should still be present,
|
||||
# and the stale identifier should no longer be registered.
|
||||
active_devices = dr.async_entries_for_config_entry(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
active_identifiers = {
|
||||
identifier
|
||||
for device in active_devices
|
||||
for (_domain, identifier) in device.identifiers
|
||||
}
|
||||
assert "pge_111111" in active_identifiers
|
||||
assert "pge_222222" in active_identifiers
|
||||
assert "pge_stale_account_99999" not in active_identifiers
|
||||
|
||||
@@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -70,32 +70,17 @@ async def test_person_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the person state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_persons["included"]) - {entity_id}
|
||||
|
||||
# Set all persons, including the tested person, to the initial state
|
||||
for eid in target_persons["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_persons,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other persons also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -128,29 +113,13 @@ async def test_person_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the person state condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_persons["included"]) - {entity_id}
|
||||
|
||||
# Set all persons, including the tested person, to the initial state
|
||||
for eid in target_persons["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_persons,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
60
tests/components/prana/snapshots/test_number.ambr
Normal file
60
tests/components/prana/snapshots/test_number.ambr
Normal file
@@ -0,0 +1,60 @@
|
||||
# serializer version: 1
|
||||
# name: test_numbers[number.prana_recuperator_display_brightness-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 6,
|
||||
'min': 0,
|
||||
'mode': <NumberMode.SLIDER: 'slider'>,
|
||||
'step': 1,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'number',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'number.prana_recuperator_display_brightness',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Display brightness',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Display brightness',
|
||||
'platform': 'prana',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'display_brightness',
|
||||
'unique_id': 'ECC9FFE0E574_display_brightness',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[number.prana_recuperator_display_brightness-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'PRANA RECUPERATOR Display brightness',
|
||||
'max': 6,
|
||||
'min': 0,
|
||||
'mode': <NumberMode.SLIDER: 'slider'>,
|
||||
'step': 1,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'number.prana_recuperator_display_brightness',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '6',
|
||||
})
|
||||
# ---
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user