Compare commits

...

29 Commits

Author SHA1 Message Date
G Johansson 7659eba376 multiple domains 2026-06-07 19:11:14 +00:00
G Johansson 2f083190fc Fix comments 2026-06-07 19:05:15 +00:00
G Johansson 640ec2bf34 Mod binary test 2026-06-07 18:14:23 +00:00
G Johansson fb24beccfc Migrate test 2026-06-07 18:08:39 +00:00
G Johansson 690d73b97e Fix light test 2026-06-07 18:01:31 +00:00
G Johansson 510485b711 group member 2026-06-07 18:01:19 +00:00
G Johansson 3b15efab36 entity_ids is a list 2026-06-07 18:00:33 +00:00
G Johansson 3a4d9ba425 Mods 2026-06-07 11:13:02 +00:00
G Johansson 6276525780 Fix group member 2026-06-07 11:09:21 +00:00
G Johansson ee9af3d1d3 Fix event 2026-06-07 11:01:07 +00:00
G Johansson 07c4fbf7c3 prop 2026-06-07 10:47:15 +00:00
G Johansson 46043165f1 Fix group members 2026-06-07 10:40:03 +00:00
G Johansson 158c0211f7 Fix migration 2026-06-07 10:31:29 +00:00
G Johansson 9160f2b42d Mod tests 2026-06-07 10:29:10 +00:00
G Johansson b30d702d3f entities 2026-06-07 10:28:57 +00:00
G Johansson fdaa3175fb Hide members 2026-06-07 10:28:00 +00:00
G Johansson 9e541fc872 Migrate group helper to use target selector 2026-06-06 20:14:19 +00:00
Raphael Hehl fc2b7902a5 Bump av to 17.0.1 (#172892) 2026-06-04 17:02:29 +02:00
bkobus-bbx 3aa4cbeeb0 Add icon translations for Blebox integration (#172565) 2026-06-04 16:58:51 +02:00
Yardian Support 3c2f171158 Bump pyyardian to 1.4.0 (#173020)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-04 16:54:51 +02:00
Erik Montnemery ffc6eeadc2 Always include template errors in trace (#172917) 2026-06-04 16:28:05 +02:00
mbo18 5cc1a0a1ef Add Motionblinds virtual integration Avosdim (#172821) 2026-06-04 16:22:06 +02:00
Erik Montnemery 1cbbce5b35 Fix person in_zones propagation from scanner in home zone (#173007) 2026-06-04 15:50:06 +02:00
Anatosun 9f5cb635f0 Upgrade Swisscom integration (#171816)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-04 14:54:54 +02:00
epenet 50de2c070e Use DOMAIN constant in test (hass.states.async_entity_ids) (#173009) 2026-06-04 14:01:51 +02:00
Samuel Xiao a7f012350f Switchbot Cloud: Fixed an issue where condition filtering for enabled Webhooks was abnormal (#172903) 2026-06-04 13:46:57 +02:00
Yardian Support 04d2211d1e Refactor Yardian zones into sub-devices using via_device (#172835) 2026-06-04 13:36:38 +02:00
Markus Adrario 5b4c2c6017 Homee: Add stop_tilt action for covers (#172952) 2026-06-04 13:19:09 +02:00
Joost Lekkerkerker f36a491ebd Fix double annotations for Pylint (#172477) 2026-06-04 13:18:33 +02:00
75 changed files with 1337 additions and 563 deletions
@@ -0,0 +1 @@
"""Virtual integration: Avosdim."""
@@ -0,0 +1,6 @@
{
"domain": "avosdim",
"name": "Avosdim",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}
+20 -16
View File
@@ -2,7 +2,7 @@
import blebox_uniapi.button
from homeassistant.components.button import ButtonEntity
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -13,6 +13,16 @@ from .util import blebox_command
PARALLEL_UPDATES = 1
BUTTON_TYPES: dict[str, ButtonEntityDescription] = {
"up": ButtonEntityDescription(key="up", translation_key="up"),
"down": ButtonEntityDescription(key="down", translation_key="down"),
"fav": ButtonEntityDescription(key="fav", translation_key="fav"),
"open": ButtonEntityDescription(key="open", translation_key="open"),
"close": ButtonEntityDescription(key="close", translation_key="close"),
}
_DEFAULT_BUTTON = ButtonEntityDescription(key="button")
async def async_setup_entry(
hass: HomeAssistant,
@@ -35,22 +45,16 @@ class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
) -> None:
"""Initialize a BleBox button feature."""
super().__init__(coordinator, feature)
self._attr_icon = self.get_icon()
def get_icon(self) -> str | None:
"""Return icon for endpoint."""
if "up" in self._feature.query_string:
return "mdi:arrow-up-circle"
if "down" in self._feature.query_string:
return "mdi:arrow-down-circle"
if "fav" in self._feature.query_string:
return "mdi:heart-circle"
if "open" in self._feature.query_string:
return "mdi:arrow-up-circle"
if "close" in self._feature.query_string:
return "mdi:arrow-down-circle"
return None
super().__init__(coordinator, feature)
self.entity_description = self._get_description()
def _get_description(self) -> ButtonEntityDescription:
"""Return the description matching this button's query string."""
for key, description in BUTTON_TYPES.items():
if key in self._feature.query_string:
return description
return _DEFAULT_BUTTON
@blebox_command
async def async_press(self) -> None:
@@ -0,0 +1,26 @@
{
"entity": {
"button": {
"close": {
"default": "mdi:arrow-down-circle"
},
"down": {
"default": "mdi:arrow-down-circle"
},
"fav": {
"default": "mdi:heart-circle"
},
"open": {
"default": "mdi:arrow-up-circle"
},
"up": {
"default": "mdi:arrow-up-circle"
}
},
"sensor": {
"power_consumption": {
"default": "mdi:lightning-bolt"
}
}
}
}
+1 -1
View File
@@ -57,9 +57,9 @@ SENSOR_TYPES = (
),
SensorEntityDescription(
key="powerConsumption",
translation_key="power_consumption",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2,
icon="mdi:lightning-bolt",
),
SensorEntityDescription(
key="humidity",
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["av==16.0.1", "Pillow==12.2.0"]
"requirements": ["av==17.0.1", "Pillow==12.2.0"]
}
+27 -1
View File
@@ -13,6 +13,7 @@ from homeassistant.const import (
ATTR_ICON,
ATTR_NAME,
CONF_ENTITIES,
CONF_ENTITY_ID,
CONF_ICON,
CONF_NAME,
SERVICE_RELOAD,
@@ -140,6 +141,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate a config entry."""
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 1:
# Migrate Entity selector to Target selector
new_options = dict(entry.options)
current_entities = new_options[CONF_ENTITIES]
new_options[CONF_ENTITIES] = {CONF_ENTITY_ID: current_entities}
_LOGGER.debug(
"Migrating from version 1 to version 2: %s -> %s",
entry.options,
new_options,
)
hass.config_entries.async_update_entry(entry, version=2, options=new_options)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
@@ -155,7 +180,8 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
if not entry.options[CONF_HIDE_MEMBERS]:
return
for member in entry.options[CONF_ENTITIES]:
entity_ids = entry.options[CONF_ENTITIES].get(CONF_ENTITY_ID, [])
for member in entity_ids:
if not (entity_id := er.async_resolve_entity_id(registry, member)):
continue
if (entity_entry := registry.async_get(entity_id)) is None:
+12 -10
View File
@@ -13,7 +13,6 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_DEVICE_CLASS,
CONF_ENTITIES,
CONF_NAME,
@@ -55,13 +54,14 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Binary Sensor Group platform."""
entities = {"entity_id": config[CONF_ENTITIES]}
async_add_entities(
[
BinarySensorGroup(
config.get(CONF_UNIQUE_ID),
config[CONF_NAME],
config.get(CONF_DEVICE_CLASS),
config[CONF_ENTITIES],
entities,
config.get(CONF_ALL),
)
]
@@ -74,16 +74,18 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Binary Sensor Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
target_config = dict(config_entry.options[CONF_ENTITIES])
entity_ids = target_config.get("entity_id", [])
if entity_ids:
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(registry, entity_ids)
target_config["entity_id"] = entities
mode = config_entry.options[CONF_ALL]
async_add_entities(
[
BinarySensorGroup(
config_entry.entry_id, config_entry.title, None, entities, mode
config_entry.entry_id, config_entry.title, None, target_config, mode
)
]
)
@@ -113,14 +115,14 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity):
unique_id: str | None,
name: str,
device_class: BinarySensorDeviceClass | None,
entity_ids: list[str],
target_config: dict[str, Any],
mode: bool | None,
) -> None:
"""Initialize a BinarySensorGroup entity."""
super().__init__()
self._entity_ids = entity_ids
self._target_config = target_config
self._domains = [BINARY_SENSOR_DOMAIN]
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
self._device_class = device_class
self.mode = any
+13 -9
View File
@@ -49,12 +49,13 @@ async def async_setup_platform(
__: DiscoveryInfoType | None = None,
) -> None:
"""Set up the button group platform."""
entities = {"entity_id": config[CONF_ENTITIES]}
async_add_entities(
[
ButtonGroup(
config.get(CONF_UNIQUE_ID),
config[CONF_NAME],
config[CONF_ENTITIES],
entities,
)
]
)
@@ -66,16 +67,18 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize button group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
target_config = dict(config_entry.options[CONF_ENTITIES])
entity_ids = target_config.get("entity_id", [])
if entity_ids:
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(registry, entity_ids)
target_config["entity_id"] = entities
async_add_entities(
[
ButtonGroup(
config_entry.entry_id,
config_entry.title,
entities,
target_config,
)
]
)
@@ -103,12 +106,13 @@ class ButtonGroup(GroupEntity, ButtonEntity):
self,
unique_id: str | None,
name: str,
entity_ids: list[str],
target_config: dict[str, Any],
) -> None:
"""Initialize a button group."""
self._entity_ids = entity_ids
super().__init__()
self._target_config = target_config
self._domains = [BUTTON_DOMAIN]
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
async def async_press(self) -> None:
+17 -20
View File
@@ -7,7 +7,7 @@ from typing import Any, cast
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.const import CONF_ENTITIES, CONF_TYPE
from homeassistant.const import CONF_ENTITIES, CONF_ENTITY_ID, CONF_TYPE
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
@@ -16,8 +16,6 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaConfigFlowHandler,
SchemaFlowFormStep,
SchemaFlowMenuStep,
SchemaOptionsFlowHandler,
entity_selector_without_own_entities,
)
from .binary_sensor import CONF_ALL, async_create_preview_binary_sensor
@@ -53,20 +51,14 @@ async def basic_group_options_schema(
domain: str | list[str], handler: SchemaCommonFlowHandler | None
) -> vol.Schema:
"""Generate options schema."""
entity_selector: selector.Selector[Any] | vol.Schema
if handler is None:
entity_selector = selector.selector(
{"entity": {"domain": domain, "multiple": True, "reorder": True}}
)
else:
entity_selector = entity_selector_without_own_entities(
cast(SchemaOptionsFlowHandler, handler.parent_handler),
selector.EntitySelectorConfig(domain=domain, multiple=True, reorder=True),
)
return vol.Schema(
{
vol.Required(CONF_ENTITIES): entity_selector,
vol.Required(CONF_ENTITIES): selector.TargetSelector(
selector.TargetSelectorConfig(
entity=selector.EntityFilterSelectorConfig(domain=domain)
)
),
vol.Required(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(),
}
)
@@ -77,10 +69,10 @@ def basic_group_config_schema(domain: str | list[str]) -> vol.Schema:
return vol.Schema(
{
vol.Required("name"): selector.TextSelector(),
vol.Required(CONF_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=domain, multiple=True, reorder=True
),
vol.Required(CONF_ENTITIES): selector.TargetSelector(
selector.TargetSelectorConfig(
entity=selector.EntityFilterSelectorConfig(domain=domain)
)
),
vol.Required(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(),
}
@@ -338,6 +330,8 @@ CREATE_PREVIEW_ENTITY: dict[
class GroupConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config or options flow for groups."""
VERSION = 2
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
@@ -387,11 +381,14 @@ class GroupConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
def _async_hide_members(
hass: HomeAssistant, members: list[str], hidden_by: er.RegistryEntryHider | None
hass: HomeAssistant,
members: dict[str, Any],
hidden_by: er.RegistryEntryHider | None,
) -> None:
"""Hide or unhide group members."""
registry = er.async_get(hass)
for member in members:
entity_ids = members.get(CONF_ENTITY_ID, [])
for member in entity_ids:
if not (entity_id := er.async_resolve_entity_id(registry, member)):
continue
if entity_id not in registry.entities:
+15 -14
View File
@@ -69,12 +69,9 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Cover Group platform."""
entities = {"entity_id": config[CONF_ENTITIES]}
async_add_entities(
[
CoverGroup(
config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES]
)
]
[CoverGroup(config.get(CONF_UNIQUE_ID), config[CONF_NAME], entities)]
)
@@ -84,13 +81,14 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Cover Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
target_config = dict(config_entry.options[CONF_ENTITIES])
entity_ids = target_config.get("entity_id", [])
if entity_ids:
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(registry, entity_ids)
target_config["entity_id"] = entities
async_add_entities(
[CoverGroup(config_entry.entry_id, config_entry.title, entities)]
[CoverGroup(config_entry.entry_id, config_entry.title, target_config)]
)
@@ -115,9 +113,13 @@ class CoverGroup(GroupEntity, CoverEntity):
_attr_is_closing: bool | None = False
_attr_current_cover_position: int | None = 100
def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None:
def __init__(
self, unique_id: str | None, name: str, target_config: dict[str, Any]
) -> None:
"""Initialize a CoverGroup entity."""
self._entity_ids = entities
super().__init__()
self._target_config = target_config
self._domains = [COVER_DOMAIN]
self._covers: dict[str, set[str]] = {
KEY_OPEN_CLOSE: set(),
KEY_STOP: set(),
@@ -130,7 +132,6 @@ class CoverGroup(GroupEntity, CoverEntity):
}
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities}
self._attr_unique_id = unique_id
@callback
+87 -3
View File
@@ -25,6 +25,12 @@ from homeassistant.helpers import start
from homeassistant.helpers.entity import Entity, async_generate_entity_id
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.target import (
TargetSelection,
TargetStateChangedData,
async_extract_referenced_entity_ids,
async_track_target_selector_state_change_event,
)
from .const import ATTR_AUTO, ATTR_ORDER, DATA_COMPONENT, DOMAIN, GROUP_ORDER, REG_KEY
from .registry import GroupIntegrationRegistry, SingleStateType
@@ -43,6 +49,8 @@ class GroupEntity(Entity):
_attr_should_poll = False
_entity_ids: list[str]
_target_config: dict[str, Any]
_domains: list[str]
@callback
def async_start_preview(
@@ -51,6 +59,7 @@ class GroupEntity(Entity):
) -> CALLBACK_TYPE:
"""Render a preview."""
self.update_entities(False)
for entity_id in self._entity_ids:
if (state := self.hass.states.get(entity_id)) is None:
continue
@@ -74,8 +83,51 @@ class GroupEntity(Entity):
self.hass, self._entity_ids, async_state_changed_listener
)
@callback
def filter_entities_by_domain(self, entity_ids: set[str]) -> set[str]:
"""Filter entities by domain."""
return {
entity_id
for entity_id in entity_ids
if split_entity_id(entity_id)[0] in self._domains
and entity_id != self.entity_id
}
@callback
def update_entities(self, update_group_members: bool = True) -> None:
"""Update the entities in the group."""
selected = async_extract_referenced_entity_ids(
self.hass,
TargetSelection(self._target_config),
expand_group=True,
primary_entities_only=False,
)
self._entity_ids = []
# Prepend entities from config to ensure order for explicitly configured entities
if entity_list := self._target_config.get("entity_id"):
self._entity_ids = list(entity_list)
self._entity_ids.extend(
[
entity
for entity in self.filter_entities_by_domain(
selected.referenced | selected.indirectly_referenced
)
if entity not in self._entity_ids
]
)
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: self._entity_ids}
if update_group_members:
self.update_group_member(self._entity_ids)
def update_group_member(self, entities: list[str]) -> None:
"""Update the group member."""
async def async_added_to_hass(self) -> None:
"""Register listeners."""
self.update_entities()
for entity_id in self._entity_ids:
if (state := self.hass.states.get(entity_id)) is None:
continue
@@ -83,18 +135,50 @@ class GroupEntity(Entity):
@callback
def async_state_changed_listener(
event: Event[EventStateChangedData],
target_state_change_data: TargetStateChangedData,
) -> None:
"""Handle child updates."""
event = target_state_change_data.state_change_event
self.async_set_context(event.context)
self.async_update_supported_features(
event.data["entity_id"], event.data["new_state"]
)
self.async_defer_or_update_ha_state()
@callback
def async_update_entities(added: set[str], removed: set[str]) -> None:
"""Handle entity changes."""
for entity_id in added:
if entity_id not in self._entity_ids:
self._entity_ids.append(entity_id)
for entity_id in removed:
if entity_id in self._entity_ids:
self._entity_ids.remove(entity_id)
# Ensure the group does not include itself as member
if self.entity_id in self._entity_ids:
self._entity_ids.remove(self.entity_id)
self._attr_extra_state_attributes = {
ATTR_ENTITY_ID: sorted(self._entity_ids)
}
for entity_id in self._entity_ids:
if (state := self.hass.states.get(entity_id)) is None:
continue
self.async_update_supported_features(entity_id, state)
self.update_group_member(self._entity_ids)
self.async_defer_or_update_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, self._entity_ids, async_state_changed_listener
async_track_target_selector_state_change_event(
self.hass,
self._target_config,
async_state_changed_listener,
self.filter_entities_by_domain,
on_entities_update=async_update_entities,
primary_entities_only=False,
)
)
self.async_on_remove(start.async_at_start(self.hass, self._update_at_start))
+35 -24
View File
@@ -15,7 +15,6 @@ from homeassistant.components.event import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_FRIENDLY_NAME,
CONF_ENTITIES,
CONF_NAME,
@@ -23,13 +22,16 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
@@ -55,12 +57,13 @@ async def async_setup_platform(
__: DiscoveryInfoType | None = None,
) -> None:
"""Set up the event group platform."""
entities = {"entity_id": config[CONF_ENTITIES]}
async_add_entities(
[
EventGroup(
config.get(CONF_UNIQUE_ID),
config[CONF_NAME],
config[CONF_ENTITIES],
entities,
)
]
)
@@ -72,16 +75,18 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize event group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
target_config = dict(config_entry.options[CONF_ENTITIES])
entity_ids = target_config.get("entity_id", [])
if entity_ids:
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(registry, entity_ids)
target_config["entity_id"] = entities
async_add_entities(
[
EventGroup(
config_entry.entry_id,
config_entry.title,
entities,
target_config,
)
]
)
@@ -109,12 +114,13 @@ class EventGroup(GroupEntity, EventEntity):
self,
unique_id: str | None,
name: str,
entity_ids: list[str],
target_config: dict[str, Any],
) -> None:
"""Initialize an event group."""
self._entity_ids = entity_ids
super().__init__()
self._target_config = target_config
self._domains = [EVENT_DOMAIN]
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
self._attr_event_types = []
@@ -123,23 +129,26 @@ class EventGroup(GroupEntity, EventEntity):
@callback
def async_state_changed_listener(
event: Event[EventStateChangedData],
target_state_change_data: TargetStateChangedData,
) -> None:
"""Handle child updates."""
if not self.hass.is_running:
return
self.async_set_context(event.context)
# Update all properties of the group
self.async_update_group_state()
# Re-fire if one of the members fires an event, but only
# if the original state was not unavailable or unknown.
if (
(old_state := event.data["old_state"])
(
old_state := target_state_change_data.state_change_event.data[
"old_state"
]
)
and old_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and (new_state := event.data["new_state"])
and (
new_state := target_state_change_data.state_change_event.data[
"new_state"
]
)
and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and (event_type := new_state.attributes.get(ATTR_EVENT_TYPE))
):
@@ -155,11 +164,13 @@ class EventGroup(GroupEntity, EventEntity):
# Fire the group event
self._trigger_event(event_type, event_attributes)
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, self._entity_ids, async_state_changed_listener
async_track_target_selector_state_change_event(
self.hass,
self._target_config,
async_state_changed_listener,
self.filter_entities_by_domain,
primary_entities_only=False,
)
)
+16 -9
View File
@@ -75,8 +75,9 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Fan Group platform."""
entities = {"entity_id": config[CONF_ENTITIES]}
async_add_entities(
[FanGroup(config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES])]
[FanGroup(config.get(CONF_UNIQUE_ID), config[CONF_NAME], entities)]
)
@@ -86,13 +87,16 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Fan Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
target_config = dict(config_entry.options[CONF_ENTITIES])
entity_ids = target_config.get("entity_id", [])
if entity_ids:
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(registry, entity_ids)
target_config["entity_id"] = entities
async_add_entities(
[FanGroup(config_entry.entry_id, config_entry.title, target_config)]
)
async_add_entities([FanGroup(config_entry.entry_id, config_entry.title, entities)])
@callback
def async_create_preview_fan(
@@ -111,9 +115,13 @@ class FanGroup(GroupEntity, FanEntity):
_attr_available: bool = False
def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None:
def __init__(
self, unique_id: str | None, name: str, target_config: dict[str, Any]
) -> None:
"""Initialize a FanGroup entity."""
self._entity_ids = entities
super().__init__()
self._target_config = target_config
self._domains = [FAN_DOMAIN]
self._fans: dict[int, set[str]] = {flag: set() for flag in SUPPORTED_FLAGS}
self._percentage = None
self._oscillating = None
@@ -121,7 +129,6 @@ class FanGroup(GroupEntity, FanEntity):
self._speed_count = 100
self._is_on: bool | None = False
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities}
self._attr_unique_id = unique_id
@property
+18 -10
View File
@@ -25,6 +25,7 @@ from homeassistant.components.light import (
ATTR_TRANSITION,
ATTR_WHITE,
ATTR_XY_COLOR,
DOMAIN as LIGHT_DOMAIN,
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
ColorMode,
LightEntity,
@@ -84,12 +85,13 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Initialize light.group platform."""
entities = {"entity_id": config[CONF_ENTITIES]}
async_add_entities(
[
LightGroup(
config.get(CONF_UNIQUE_ID),
config[CONF_NAME],
config[CONF_ENTITIES],
entities,
config.get(CONF_ALL),
)
]
@@ -102,14 +104,16 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Light Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
target_config = dict(config_entry.options[CONF_ENTITIES])
entity_ids = target_config.get("entity_id", [])
if entity_ids:
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(registry, entity_ids)
target_config["entity_id"] = entities
mode = config_entry.options.get(CONF_ALL, False)
async_add_entities(
[LightGroup(config_entry.entry_id, config_entry.title, entities, mode)]
[LightGroup(config_entry.entry_id, config_entry.title, target_config, mode)]
)
@@ -153,13 +157,17 @@ class LightGroup(GroupEntity, LightEntity):
_attr_should_poll = False
def __init__(
self, unique_id: str | None, name: str, entity_ids: list[str], mode: bool | None
self,
unique_id: str | None,
name: str,
target_config: dict[str, Any],
mode: bool | None,
) -> None:
"""Initialize a light group."""
self._entity_ids = entity_ids
super().__init__()
self._target_config = target_config
self._domains = [LIGHT_DOMAIN]
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
self.mode = any
if mode:
+19 -15
View File
@@ -14,7 +14,6 @@ from homeassistant.components.lock import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_ENTITIES,
CONF_NAME,
CONF_UNIQUE_ID,
@@ -55,12 +54,13 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Lock Group platform."""
entities = {"entity_id": config[CONF_ENTITIES]}
async_add_entities(
[
LockGroup(
config.get(CONF_UNIQUE_ID),
config[CONF_NAME],
config[CONF_ENTITIES],
entities,
)
]
)
@@ -72,16 +72,18 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Lock Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
target_config = dict(config_entry.options[CONF_ENTITIES])
entity_ids = target_config.get("entity_id", [])
if entity_ids:
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(registry, entity_ids)
target_config["entity_id"] = entities
async_add_entities(
[
LockGroup(
config_entry.entry_id,
config_entry.title,
entities,
target_config,
)
]
)
@@ -104,22 +106,24 @@ class LockGroup(GroupEntity, LockEntity):
_attr_available = False
_attr_should_poll = False
group: GenericGroup
def __init__(
self,
unique_id: str | None,
name: str,
entity_ids: list[str],
self, unique_id: str | None, name: str, target_config: dict[str, Any]
) -> None:
"""Initialize a lock group."""
self._entity_ids = entity_ids
self.group = GenericGroup(self, entity_ids)
super().__init__()
self._target_config = target_config
self._domains = [LOCK_DOMAIN]
self.group = GenericGroup(self, target_config.get("entity_id", []))
self._attr_supported_features = LockEntityFeature.OPEN
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
def update_group_member(self, entities: list[str]) -> None:
"""Update the group member."""
self.group._member_entity_ids = entities # noqa: SLF001
@callback
def async_update_group_state(self) -> None:
"""Query all members and determine the lock group state."""
+20 -76
View File
@@ -1,6 +1,5 @@
"""Platform allowing several media players to be grouped into one media player."""
from collections.abc import Callable, Mapping
from contextlib import suppress
from typing import Any
@@ -43,22 +42,16 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
KEY_ANNOUNCE = "announce"
KEY_CLEAR_PLAYLIST = "clear_playlist"
KEY_ENQUEUE = "enqueue"
@@ -88,12 +81,9 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the MediaPlayer Group platform."""
entities = {"entity_id": config[CONF_ENTITIES]}
async_add_entities(
[
MediaPlayerGroup(
config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES]
)
]
[MediaPlayerGroup(config.get(CONF_UNIQUE_ID), config[CONF_NAME], entities)]
)
@@ -103,13 +93,14 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize MediaPlayer Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
target_config = dict(config_entry.options[CONF_ENTITIES])
entity_ids = target_config.get("entity_id", [])
if entity_ids:
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(registry, entity_ids)
target_config["entity_id"] = entities
async_add_entities(
[MediaPlayerGroup(config_entry.entry_id, config_entry.title, entities)]
[MediaPlayerGroup(config_entry.entry_id, config_entry.title, target_config)]
)
@@ -125,20 +116,20 @@ def async_create_preview_media_player(
)
class MediaPlayerGroup(MediaPlayerEntity):
class MediaPlayerGroup(GroupEntity, MediaPlayerEntity):
"""Representation of a Media Group."""
_unrecorded_attributes = frozenset({ATTR_ENTITY_ID})
_attr_available: bool = False
_attr_should_poll = False
def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None:
def __init__(
self, unique_id: str | None, name: str, target_config: dict[str, Any]
) -> None:
"""Initialize a Media Group entity."""
super().__init__()
self._target_config = target_config
self._domains = [MEDIA_PLAYER_DOMAIN]
self._name = name
self._attr_unique_id = unique_id
self._entities = entities
self._features: dict[str, set[str]] = {
KEY_ANNOUNCE: set(),
KEY_CLEAR_PLAYLIST: set(),
@@ -152,16 +143,6 @@ class MediaPlayerGroup(MediaPlayerEntity):
KEY_VOLUME: set(),
}
@callback
def async_on_state_change(self, event: Event[EventStateChangedData]) -> None:
"""Update supported features and state when a new state is received."""
self.async_set_context(event.context)
self.async_update_supported_features(
event.data["entity_id"], event.data["new_state"]
)
self.async_update_group_state()
self.async_write_ha_state()
@callback
def async_update_supported_features(
self,
@@ -229,48 +210,11 @@ class MediaPlayerGroup(MediaPlayerEntity):
else:
self._features[KEY_ENQUEUE].discard(entity_id)
@callback
def async_start_preview(
self,
preview_callback: Callable[[str, Mapping[str, Any]], None],
) -> CALLBACK_TYPE:
"""Render a preview."""
@callback
def async_state_changed_listener(
event: Event[EventStateChangedData] | None,
) -> None:
"""Handle child updates."""
self.async_update_group_state()
calculated_state = self._async_calculate_state()
preview_callback(calculated_state.state, calculated_state.attributes)
async_state_changed_listener(None)
return async_track_state_change_event(
self.hass, self._entities, async_state_changed_listener
)
async def async_added_to_hass(self) -> None:
"""Register listeners."""
for entity_id in self._entities:
new_state = self.hass.states.get(entity_id)
self.async_update_supported_features(entity_id, new_state)
async_track_state_change_event(
self.hass, self._entities, self.async_on_state_change
)
self.async_update_group_state()
self.async_write_ha_state()
@property
def name(self) -> str:
"""Return the name of the entity."""
return self._name
@property
def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return the state attributes for the media group."""
return {ATTR_ENTITY_ID: self._entities}
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""
data = {ATTR_ENTITY_ID: self._features[KEY_CLEAR_PLAYLIST]}
@@ -440,7 +384,7 @@ class MediaPlayerGroup(MediaPlayerEntity):
"""Query all members and determine the media group state."""
states = [
state.state
for entity_id in self._entities
for entity_id in self._entity_ids
if (state := self.hass.states.get(entity_id)) is not None
]
+11 -12
View File
@@ -132,13 +132,14 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Notify Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
target_config = dict(config_entry.options[CONF_ENTITIES])
entity_ids = target_config.get("entity_id", [])
if entity_ids:
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(registry, entity_ids)
target_config["entity_id"] = entities
async_add_entities(
[NotifyGroup(config_entry.entry_id, config_entry.title, entities)]
[NotifyGroup(config_entry.entry_id, config_entry.title, target_config)]
)
@@ -160,15 +161,13 @@ class NotifyGroup(GroupEntity, NotifyEntity):
_attr_available: bool = False
def __init__(
self,
unique_id: str | None,
name: str,
entity_ids: list[str],
self, unique_id: str | None, name: str, target_config: dict[str, Any]
) -> None:
"""Initialize a NotifyGroup."""
self._entity_ids = entity_ids
super().__init__()
self._target_config = target_config
self._domains = [NOTIFY_DOMAIN]
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
async def async_send_message(self, message: str, title: str | None = None) -> None:
+17 -10
View File
@@ -27,7 +27,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_DEVICE_CLASS,
CONF_ENTITIES,
CONF_NAME,
@@ -117,13 +116,14 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Switch Group platform."""
entities = {"entity_id": config[CONF_ENTITIES]}
async_add_entities(
[
SensorGroup(
hass,
config.get(CONF_UNIQUE_ID),
config[CONF_NAME],
config[CONF_ENTITIES],
entities,
config[CONF_IGNORE_NON_NUMERIC],
config[CONF_TYPE],
config.get(CONF_UNIT_OF_MEASUREMENT),
@@ -140,17 +140,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Switch Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
target_config = dict(config_entry.options[CONF_ENTITIES])
entity_ids = target_config.get("entity_id", [])
if entity_ids:
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(registry, entity_ids)
target_config["entity_id"] = entities
async_add_entities(
[
SensorGroup(
hass,
config_entry.entry_id,
config_entry.title,
entities,
target_config,
config_entry.options.get(CONF_IGNORE_NON_NUMERIC, True),
config_entry.options[CONF_TYPE],
None,
@@ -345,7 +347,7 @@ class SensorGroup(GroupEntity, SensorEntity):
hass: HomeAssistant,
unique_id: str | None,
name: str,
entity_ids: list[str],
target_config: dict[str, Any],
ignore_non_numeric: bool,
sensor_type: str,
unit_of_measurement: str | None,
@@ -353,8 +355,10 @@ class SensorGroup(GroupEntity, SensorEntity):
device_class: SensorDeviceClass | None,
) -> None:
"""Initialize a sensor group."""
super().__init__()
self._target_config = target_config
self._domains = [SENSOR_DOMAIN, NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN]
self.hass = hass
self._entity_ids = entity_ids
self._sensor_type = sensor_type
self._configured_state_class = state_class
self._configured_device_class = device_class
@@ -482,7 +486,10 @@ class SensorGroup(GroupEntity, SensorEntity):
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the sensor."""
return {ATTR_ENTITY_ID: self._entity_ids, **self._extra_state_attribute}
return {
**self._extra_state_attribute,
**self._attr_extra_state_attributes,
}
@property
def icon(self) -> str | None:
+13 -10
View File
@@ -57,12 +57,13 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Switch Group platform."""
entities = {"entity_id": config[CONF_ENTITIES]}
async_add_entities(
[
SwitchGroup(
config.get(CONF_UNIQUE_ID),
config[CONF_NAME],
config[CONF_ENTITIES],
entities,
config.get(CONF_ALL, False),
)
]
@@ -75,16 +76,18 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Switch Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
target_config = dict(config_entry.options[CONF_ENTITIES])
entity_ids = target_config.get("entity_id", [])
if entity_ids:
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(registry, entity_ids)
target_config["entity_id"] = entities
async_add_entities(
[
SwitchGroup(
config_entry.entry_id,
config_entry.title,
entities,
target_config,
config_entry.options.get(CONF_ALL),
)
]
@@ -114,14 +117,14 @@ class SwitchGroup(GroupEntity, SwitchEntity):
self,
unique_id: str | None,
name: str,
entity_ids: list[str],
target_config: dict[str, Any],
mode: bool | None,
) -> None:
"""Initialize a switch group."""
self._entity_ids = entity_ids
super().__init__()
self._target_config = target_config
self._domains = [SWITCH_DOMAIN]
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
self.mode = any
if mode:
+15 -15
View File
@@ -63,12 +63,9 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Valve Group platform."""
entities = {"entity_id": config[CONF_ENTITIES]}
async_add_entities(
[
ValveGroup(
config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES]
)
]
[ValveGroup(config.get(CONF_UNIQUE_ID), config[CONF_NAME], entities)]
)
@@ -78,13 +75,14 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Valve Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
target_config = dict(config_entry.options[CONF_ENTITIES])
entity_ids = target_config.get("entity_id", [])
if entity_ids:
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(registry, entity_ids)
target_config["entity_id"] = entities
async_add_entities(
[ValveGroup(config_entry.entry_id, config_entry.title, entities)]
[ValveGroup(config_entry.entry_id, config_entry.title, target_config)]
)
@@ -110,17 +108,19 @@ class ValveGroup(GroupEntity, ValveEntity):
_attr_is_opening: bool | None = False
_attr_reports_position: bool = False
def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None:
def __init__(
self, unique_id: str | None, name: str, target_config: dict[str, Any]
) -> None:
"""Initialize a ValveGroup entity."""
self._entity_ids = entities
super().__init__()
self._target_config = target_config
self._domains = [VALVE_DOMAIN]
self._valves: dict[str, set[str]] = {
KEY_OPEN_CLOSE: set(),
KEY_STOP: set(),
KEY_SET_POSITION: set(),
}
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities}
self._attr_unique_id = unique_id
@callback
+15 -1
View File
@@ -51,6 +51,7 @@ class HomeeCoverState(float, Enum):
class HomeeSlatState(float, Enum):
"""Slat states for covers in homee."""
STOPPED = 0.0
CLOSED = 1.0
OPEN = 2.0
@@ -82,7 +83,11 @@ def get_cover_features(
features |= CoverEntityFeature.SET_POSITION
if node.get_attribute_by_type(AttributeType.SLAT_ROTATION_IMPULSE) is not None:
features |= CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
features |= (
CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT
)
if node.get_attribute_by_type(AttributeType.SHUTTER_SLAT_POSITION) is not None:
features |= CoverEntityFeature.SET_TILT_POSITION
@@ -313,6 +318,15 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
else:
await self.async_set_homee_value(slat_attribute, HomeeSlatState.OPEN)
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the cover tilt."""
if (
slat_attribute := self._node.get_attribute_by_type(
AttributeType.SLAT_ROTATION_IMPULSE
)
) is not None:
await self.async_set_homee_value(slat_attribute, HomeeSlatState.STOPPED)
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
if CoverEntityFeature.SET_TILT_POSITION in self.supported_features:
+1 -1
View File
@@ -565,7 +565,7 @@ class Person(
self._latitude = coordinates.attributes.get(ATTR_LATITUDE)
self._longitude = coordinates.attributes.get(ATTR_LONGITUDE)
self._gps_accuracy = coordinates.attributes.get(ATTR_GPS_ACCURACY)
self._in_zones = coordinates.attributes.get(ATTR_IN_ZONES, [])
self._in_zones = state.attributes.get(ATTR_IN_ZONES, [])
@callback
def _update_extra_state_attributes(self) -> None:
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["PyTurboJPEG==1.8.3", "av==16.0.1", "numpy==2.3.2"]
"requirements": ["PyTurboJPEG==1.8.3", "av==17.0.1", "numpy==2.3.2"]
}
+23 -1
View File
@@ -1 +1,23 @@
"""The swisscom component."""
"""The Swisscom Internet-Box integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import SwisscomConfigEntry, SwisscomDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
async def async_setup_entry(hass: HomeAssistant, entry: SwisscomConfigEntry) -> bool:
"""Set up Swisscom Internet-Box from a config entry."""
coordinator = SwisscomDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: SwisscomConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,67 @@
"""Config flow for the Swisscom Internet-Box integration."""
import logging
from typing import Any
from swisscom_internet_box import (
SwisscomAuthError,
SwisscomClient,
SwisscomConnectionError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class SwisscomConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Swisscom Internet-Box."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step."""
errors: dict[str, str] = {}
if user_input is not None:
client = SwisscomClient(
async_get_clientsession(self.hass),
user_input[CONF_HOST],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
try:
await client.login()
info = await client.get_box_info()
except SwisscomAuthError:
errors["base"] = "invalid_auth"
except SwisscomConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception during Swisscom config flow")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(format_mac(info.base_mac))
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=info.model_name or "Internet-Box", data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
@@ -0,0 +1,6 @@
"""Constants for the Swisscom Internet-Box integration."""
DOMAIN = "swisscom"
DEFAULT_HOST = "192.168.1.1"
DEFAULT_USERNAME = "admin"
@@ -0,0 +1,59 @@
"""DataUpdateCoordinator for the Swisscom Internet-Box."""
from datetime import timedelta
import logging
from swisscom_internet_box import (
Device,
SwisscomAuthError,
SwisscomClient,
SwisscomConnectionError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
type SwisscomConfigEntry = ConfigEntry[SwisscomDataUpdateCoordinator]
class SwisscomDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
"""Poll the Internet-Box for the list of LAN devices."""
config_entry: SwisscomConfigEntry
def __init__(self, hass: HomeAssistant, entry: SwisscomConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
config_entry=entry,
)
self.client = SwisscomClient(
async_get_clientsession(hass),
entry.data[CONF_HOST],
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
)
async def _async_update_data(self) -> dict[str, Device]:
"""Fetch device data from the box."""
try:
devices = await self.client.get_devices()
except SwisscomAuthError as err:
raise ConfigEntryAuthFailed(str(err)) from err
except SwisscomConnectionError as err:
raise UpdateFailed(str(err)) from err
return {device.key: device for device in devices if device.key}
@@ -1,111 +1,115 @@
"""Support for Swisscom routers (Internet-Box)."""
"""Device tracker for the Swisscom Internet-Box."""
from contextlib import suppress
import logging
import requests
import voluptuous as vol
from homeassistant.components.device_tracker import (
DOMAIN as DEVICE_TRACKER_DOMAIN,
PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA,
DeviceScanner,
AsyncSeeCallback,
ScannerEntity,
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
_LOGGER = logging.getLogger(__name__)
DEFAULT_IP = "192.168.1.1"
from .const import DEFAULT_HOST, DOMAIN
from .coordinator import SwisscomConfigEntry, SwisscomDataUpdateCoordinator
PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend(
{vol.Optional(CONF_HOST, default=DEFAULT_IP): cv.string}
{vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string}
)
def get_scanner(
hass: HomeAssistant, config: ConfigType
) -> SwisscomDeviceScanner | None:
"""Return the Swisscom device scanner."""
scanner = SwisscomDeviceScanner(config[DEVICE_TRACKER_DOMAIN])
return scanner if scanner.success_init else None
async def async_setup_scanner(
hass: HomeAssistant,
config: ConfigType,
async_see: AsyncSeeCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> bool:
"""Inform users that the YAML configuration is no longer supported."""
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_credentials_required",
breaks_in_ha_version="2027.1.0",
is_fixable=False,
is_persistent=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_credentials_required",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Swisscom Internet-Box",
"host": config[CONF_HOST],
},
)
return False
class SwisscomDeviceScanner(DeviceScanner):
"""Class which queries a router running Swisscom Internet-Box firmware."""
async def async_setup_entry(
hass: HomeAssistant,
entry: SwisscomConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker entities for the Swisscom Internet-Box."""
coordinator = entry.runtime_data
tracked: set[str] = set()
def __init__(self, config):
"""Initialize the scanner."""
self.host = config[CONF_HOST]
self.last_results = {}
@callback
def _add_new_entities() -> None:
new_keys = [key for key in coordinator.data if key not in tracked]
if new_keys:
tracked.update(new_keys)
async_add_entities(
SwisscomScannerEntity(coordinator, key) for key in new_keys
)
# Test the router is accessible.
data = self.get_swisscom_data()
self.success_init = data is not None
_add_new_entities()
entry.async_on_unload(coordinator.async_add_listener(_add_new_entities))
def scan_devices(self):
"""Scan for new devices and return a list with found device IDs."""
self._update_info()
return [client["mac"] for client in self.last_results]
def get_device_name(self, device):
"""Return the name of the given device or None if we don't know."""
if not self.last_results:
return None
for client in self.last_results:
if client["mac"] == device:
return client["host"]
return None
class SwisscomScannerEntity(
CoordinatorEntity[SwisscomDataUpdateCoordinator], ScannerEntity
):
"""A device tracked by the Swisscom Internet-Box."""
def _update_info(self):
"""Ensure the information from the Swisscom router is up to date.
def __init__(self, coordinator: SwisscomDataUpdateCoordinator, key: str) -> None:
"""Initialize the scanner entity."""
super().__init__(coordinator)
self._key = key
self._attr_unique_id = key
Return boolean if scanning successful.
"""
if not self.success_init:
return False
@property
def _device(self):
return self.coordinator.data.get(self._key)
_LOGGER.debug("Loading data from Swisscom Internet Box")
if not (data := self.get_swisscom_data()):
return False
@property
def is_connected(self) -> bool:
"""Return whether the device is currently connected to the LAN."""
device = self._device
return bool(device and device.active)
active_clients = [client for client in data.values() if client["status"]]
self.last_results = active_clients
return True
@property
def mac_address(self) -> str:
"""Return the MAC address of the device."""
device = self._device
return device.phys_address if device else self._key
def get_swisscom_data(self):
"""Retrieve data from Swisscom and return parsed result."""
url = f"http://{self.host}/ws"
headers = {"Content-Type": "application/x-sah-ws-4-call+json"}
data = """
{"service":"Devices", "method":"get",
"parameters":{"expression":"lan and not self"}}"""
@property
def hostname(self) -> str | None:
"""Return the hostname of the device."""
device = self._device
return device.name if device else None
devices = {}
@property
def ip_address(self) -> str | None:
"""Return the IP address of the device."""
device = self._device
return device.ip_address if device else None
try:
request = requests.post(url, headers=headers, data=data, timeout=10)
except (
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ConnectTimeout,
):
_LOGGER.debug("No response from Swisscom Internet Box")
return devices
if "status" not in request.json():
_LOGGER.debug("No status in response from Swisscom Internet Box")
return devices
for device in request.json()["status"]:
with suppress(KeyError, requests.exceptions.RequestException):
devices[device["Key"]] = {
"ip": device["IPAddress"],
"mac": device["PhysAddress"],
"host": device["Name"],
"status": device["Active"],
}
return devices
@property
def name(self) -> str | None:
"""Return the friendly name of the device."""
return self.hostname
@@ -2,7 +2,9 @@
"domain": "swisscom",
"name": "Swisscom Internet-Box",
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/swisscom",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "legacy"
"requirements": ["python-swisscom-internet-box==0.1.1"]
}
@@ -0,0 +1,32 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The hostname or IP address of your Swisscom Internet-Box.",
"password": "The administrator password printed on the bottom of the box.",
"username": "The administrator username, normally \"admin\"."
}
}
}
},
"issues": {
"deprecated_yaml_import_issue_credentials_required": {
"description": "Configuring the {integration_title} integration through YAML is deprecated. The integration now requires a username and password to authenticate to your Internet-Box, which cannot be safely carried over from YAML.\n\nSet up the integration through the UI to provide your credentials (your existing host `{host}` will need to be re-entered), then remove the `{domain}` entry from your `configuration.yaml` file and restart Home Assistant.",
"title": "The {integration_title} YAML configuration is being removed"
}
}
}
@@ -504,9 +504,15 @@ def _create_handle_webhook(
_LOGGER.debug("Received data from switchbot webhook: %s", repr(data))
device_mac = data["context"]["deviceMac"]
if device_mac not in coordinators_by_id:
_LOGGER.error(
"Received data for unknown entity from switchbot webhook: %s", data
registered_device_macs = [
coordinator.data.get("deviceMac") or coordinator.data.get("deviceId")
for coordinator in coordinators_by_id.values()
if coordinator.manageable_by_webhook() and coordinator.data is not None
]
if device_mac not in registered_device_macs:
_LOGGER.debug(
"Received data for an unregistered webhook entity from SwitchBot Webhook: %s",
data,
)
return
@@ -1070,7 +1070,7 @@ async def handle_test_condition(
# alongside the result.
condition_trace = trace.trace_get()
try:
with trace.record_template_errors():
with trace.suppress_template_error_logging():
check_result = condition.async_check(variables=msg.get("variables"))
except HomeAssistantError as err:
connection.send_error(
@@ -1134,7 +1134,7 @@ async def handle_subscribe_condition(
condition_trace = trace.trace_get()
try:
with trace.record_template_errors():
with trace.suppress_template_error_logging():
new_event_data = {"result": condition.async_check()}
except HomeAssistantError as err:
new_event_data = {"error": str(err)}
@@ -11,9 +11,9 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import YardianConfigEntry, YardianUpdateCoordinator
from .entity import YardianEntity, YardianZoneEntity
@dataclass(kw_only=True, frozen=True)
@@ -79,38 +79,30 @@ async def async_setup_entry(
"""Set up Yardian binary sensors."""
coordinator = config_entry.runtime_data
# 1. Global/Main device sensors
entities: list[BinarySensorEntity] = [
YardianBinarySensor(coordinator, description)
for description in SENSOR_DESCRIPTIONS
]
zone_descriptions = [
YardianBinarySensorEntityDescription(
# 2. Zone/Child device sensors
for zone_id in range(len(coordinator.data.zones)):
description = YardianBinarySensorEntityDescription(
key=f"zone_enabled_{zone_id}",
translation_key="zone_enabled",
translation_key="enabled",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=_zone_value_factory(zone_id),
translation_placeholders={"zone": str(zone_id + 1)},
)
for zone_id in range(len(coordinator.data.zones))
]
entities.extend(
YardianBinarySensor(coordinator, description)
for description in zone_descriptions
)
entities.append(YardianZoneBinarySensor(coordinator, description, zone_id))
async_add_entities(entities)
class YardianBinarySensor(
CoordinatorEntity[YardianUpdateCoordinator], BinarySensorEntity
):
"""Representation of a Yardian binary sensor based on a description."""
class YardianBinarySensor(YardianEntity, BinarySensorEntity):
"""Representation of a Yardian binary sensor assigned to the main device."""
entity_description: YardianBinarySensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
@@ -121,7 +113,28 @@ class YardianBinarySensor(
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.yid}-{description.key}"
self._attr_device_info = coordinator.device_info
@property
def is_on(self) -> bool | None:
"""Return the current state based on the description's value function."""
return self.entity_description.value_fn(self.coordinator)
class YardianZoneBinarySensor(YardianZoneEntity, BinarySensorEntity):
"""Representation of a Yardian binary sensor assigned to a zone child device."""
entity_description: YardianBinarySensorEntityDescription
def __init__(
self,
coordinator: YardianUpdateCoordinator,
description: YardianBinarySensorEntityDescription,
zone_id: int,
) -> None:
"""Initialize the Yardian zone binary sensor."""
super().__init__(coordinator, zone_id)
self.entity_description = description
self._attr_unique_id = f"{coordinator.yid}-{description.key}"
@property
def is_on(self) -> bool | None:
+1 -1
View File
@@ -2,6 +2,6 @@
DOMAIN = "yardian"
MANUFACTURER = "Aeon Matrix"
PRODUCT_NAME = "Yardian Smart Sprinkler"
PRODUCT_NAME = "Yardian Smart Sprinkler Controller"
DEFAULT_WATERING_DURATION = 6
@@ -0,0 +1,35 @@
"""Base entities for Yardian integration."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import YardianUpdateCoordinator
class YardianEntity(CoordinatorEntity[YardianUpdateCoordinator]):
"""Base class for Yardian entities assigned to the main device."""
_attr_has_entity_name = True
def __init__(self, coordinator: YardianUpdateCoordinator) -> None:
"""Initialize the main device entity."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
class YardianZoneEntity(CoordinatorEntity[YardianUpdateCoordinator]):
"""Base class for Yardian entities assigned to a zone child device."""
_attr_has_entity_name = True
def __init__(self, coordinator: YardianUpdateCoordinator, zone_id: int) -> None:
"""Initialize the zone device entity."""
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{coordinator.yid}_{zone_id}")},
name=coordinator.data.zones[zone_id].name,
manufacturer=MANUFACTURER,
via_device=(DOMAIN, coordinator.yid),
)
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/yardian",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pyyardian==1.3.3"]
"requirements": ["pyyardian==1.4.0"]
}
+2 -4
View File
@@ -13,10 +13,10 @@ from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .coordinator import YardianConfigEntry, YardianUpdateCoordinator
from .entity import YardianEntity
# Values above this threshold indicate the API returned an absolute
# timestamp instead of a relative delay, so convert to a remaining delta.
@@ -99,11 +99,10 @@ async def async_setup_entry(
)
class YardianSensor(CoordinatorEntity[YardianUpdateCoordinator], SensorEntity):
class YardianSensor(YardianEntity, SensorEntity):
"""Representation of a Yardian sensor defined by description."""
entity_description: YardianSensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
@@ -114,7 +113,6 @@ class YardianSensor(CoordinatorEntity[YardianUpdateCoordinator], SensorEntity):
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.yid}_{description.key}"
self._attr_device_info = coordinator.device_info
@property
def native_value(self) -> StateType:
@@ -32,7 +32,7 @@
"name": "Watering running"
},
"zone_enabled": {
"name": "Zone {zone} enabled"
"name": "Enabled"
}
},
"sensor": {
+5 -13
View File
@@ -9,10 +9,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEFAULT_WATERING_DURATION
from .coordinator import YardianConfigEntry, YardianUpdateCoordinator
from .entity import YardianZoneEntity
SERVICE_START_IRRIGATION = "start_irrigation"
SERVICE_SCHEMA_START_IRRIGATION: VolDictType = {
@@ -43,23 +43,15 @@ async def async_setup_entry(
)
class YardianSwitch(CoordinatorEntity[YardianUpdateCoordinator], SwitchEntity):
class YardianSwitch(YardianZoneEntity, SwitchEntity):
"""Representation of a Yardian switch."""
_attr_has_entity_name = True
_attr_translation_key = "switch"
_attr_name = None
def __init__(self, coordinator: YardianUpdateCoordinator, zone_id) -> None:
def __init__(self, coordinator: YardianUpdateCoordinator, zone_id: int) -> None:
"""Initialize a Yardian Switch Device."""
super().__init__(coordinator)
self._zone_id = zone_id
super().__init__(coordinator, zone_id)
self._attr_unique_id = f"{coordinator.yid}-{zone_id}"
self._attr_device_info = coordinator.device_info
@property
def name(self) -> str:
"""Return the zone name."""
return self.coordinator.data.zones[self._zone_id].name
@property
def is_on(self) -> bool:
+1
View File
@@ -727,6 +727,7 @@ FLOWS = {
"sunweg",
"surepetcare",
"swiss_public_transport",
"swisscom",
"switchbee",
"switchbot",
"switchbot_cloud",
+6 -1
View File
@@ -665,6 +665,11 @@
"config_flow": false,
"iot_class": "assumed_state"
},
"avosdim": {
"name": "Avosdim",
"integration_type": "virtual",
"supported_by": "motion_blinds"
},
"awair": {
"name": "Awair",
"integration_type": "hub",
@@ -6903,7 +6908,7 @@
"swisscom": {
"name": "Swisscom Internet-Box",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "local_polling"
},
"switchbee": {
+1 -1
View File
@@ -56,7 +56,7 @@ class GenericGroup(Group):
super().__init__(entity)
self._member_entity_ids = member_entity_ids
@cached_property
@property
def member_entity_ids(self) -> list[str]:
"""Return the list of member entity IDs."""
return self._member_entity_ids
@@ -512,22 +512,3 @@ def wrapped_entity_config_entry_title(
if state:
return state.name or object_id
return object_id
@callback
def entity_selector_without_own_entities(
handler: SchemaOptionsFlowHandler,
entity_selector_config: selector.EntitySelectorConfig,
) -> selector.EntitySelector:
"""Return an entity selector which excludes own entities."""
entity_registry = er.async_get(handler.hass)
entities = er.async_entries_for_config_entry(
entity_registry,
handler.config_entry.entry_id,
)
entity_ids = [ent.entity_id for ent in entities]
final_selector_config = entity_selector_config.copy()
final_selector_config["exclude_entities"] = entity_ids
return selector.EntitySelector(final_selector_config)
+2
View File
@@ -376,6 +376,8 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
"""Handle the tracked entities."""
previous_entities = self._tracked_entities
self._tracked_entities = tracked_entities
if previous_entities == tracked_entities:
return
if self._on_entities_update is not None:
added = tracked_entities - previous_entities
+7 -8
View File
@@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.trace import (
record_template_errors_cv,
suppress_template_error_logging_cv,
trace_stack_cv,
trace_stack_top,
)
@@ -632,14 +632,13 @@ def make_logging_undefined(
return jinja2.StrictUndefined
def _log_with_logger(level: int, msg: str) -> None:
# When a consumer such as the subscribe_condition websocket command has
# opted in, record the error on the active trace element instead of
# logging it, so repeated evaluations don't spam the log.
if record_template_errors_cv.get() and (
node := trace_stack_top(trace_stack_cv)
):
# Record the error on the active trace element so it is surfaced in the
# trace. Consumers such as the subscribe_condition websocket command can
# opt in to additionally suppress the (otherwise repeated) log entry.
if node := trace_stack_top(trace_stack_cv):
node.add_template_error(msg)
return
if suppress_template_error_logging_cv.get():
return
template, action = template_cv.get() or ("", "rendering or compiling")
_LOGGER.log(
+12 -11
View File
@@ -139,26 +139,27 @@ trace_id_cv: ContextVar[tuple[str, str] | None] = ContextVar(
script_execution_cv: ContextVar[StopReason | None] = ContextVar(
"script_execution_cv", default=None
)
# When set, template errors are recorded on the active TraceElement instead of
# being logged directly
record_template_errors_cv: ContextVar[bool] = ContextVar(
"record_template_errors_cv", default=False
# When set, template errors recorded on the active TraceElement are not also
# logged. Template errors are always recorded in the trace regardless.
suppress_template_error_logging_cv: ContextVar[bool] = ContextVar(
"suppress_template_error_logging_cv", default=False
)
@contextmanager
def record_template_errors() -> Generator[None]:
"""Record template errors in the active trace instead of logging them.
def suppress_template_error_logging() -> Generator[None]:
"""Suppress logging of template errors that are recorded in the trace.
Used by consumers such as the subscribe_condition websocket command, which
re-evaluate a condition repeatedly and forward template errors to the client
via the trace, so the errors don't spam the log.
Template errors are always recorded on the active trace element. Consumers
such as the subscribe_condition websocket command, which re-evaluate a
condition repeatedly and forward template errors to the client via the
trace, can use this to also stop the errors from spamming the log.
"""
token = record_template_errors_cv.set(True)
token = suppress_template_error_logging_cv.set(True)
try:
yield
finally:
record_template_errors_cv.reset(token)
suppress_template_error_logging_cv.reset(token)
def trace_id_set(trace_id: tuple[str, str]) -> None:
+1 -1
View File
@@ -17,7 +17,7 @@ async-upnp-client==0.46.2
atomicwrites-homeassistant==1.4.1
attrs==26.1.0
audioop-lts==0.2.2
av==16.0.1
av==17.0.1
awesomeversion==25.8.0
bcrypt==5.0.0
bleak-retry-connector==4.6.1
@@ -8,17 +8,38 @@ any module that defines a ``register(linter)`` function will be loaded.
import importlib
import pkgutil
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
from pylint_home_assistant import checkers
def register(linter: PyLinter) -> None:
"""Register all Home Assistant checkers."""
"""Register all Home Assistant checkers which have not been registered yet."""
existing_checker_types: set[type] = {
type(checker)
for checkers_list in linter._checkers.values() # noqa: SLF001
for checker in checkers_list
}
# Auto-discover and register all checker modules under ``checkers/``.
for module_info in pkgutil.walk_packages(
checkers.__path__, prefix=f"{checkers.__name__}."
):
module = importlib.import_module(module_info.name)
if hasattr(module, "register"):
module.register(linter)
if not hasattr(module, "register"):
continue
# Skip modules whose checker class is already registered (worker
# re-registration in parallel mode). Only consider checker classes
# defined in the module itself, not ones imported from pylint.
module_checker_types = {
value
for value in vars(module).values()
if (
isinstance(value, type)
and issubclass(value, BaseChecker)
and value.__module__ == module.__name__
)
}
if module_checker_types and module_checker_types <= existing_checker_types:
continue
module.register(linter)
+5 -2
View File
@@ -600,7 +600,7 @@ autoskope_client==1.4.1
# homeassistant.components.generic
# homeassistant.components.stream
av==16.0.1
av==17.0.1
# homeassistant.components.avea
avea==1.8.0
@@ -2738,6 +2738,9 @@ python-snoo==0.8.3
# homeassistant.components.songpal
python-songpal==0.16.2
# homeassistant.components.swisscom
python-swisscom-internet-box==0.1.1
# homeassistant.components.tado
python-tado==0.18.16
@@ -2836,7 +2839,7 @@ pyws66i==1.1
pyxeoma==1.4.2
# homeassistant.components.yardian
pyyardian==1.3.3
pyyardian==1.4.0
# homeassistant.components.qrcode
pyzbar==0.1.9
+1 -1
View File
@@ -1777,7 +1777,7 @@ async def test_automation_bad_config_validation(
assert issues[0]["translation_placeholders"]["error"].startswith(details)
# Make sure both automations are setup
assert set(hass.states.async_entity_ids("automation")) == {
assert set(hass.states.async_entity_ids(DOMAIN)) == {
"automation.bad_automation",
"automation.good_automation",
}
+28 -15
View File
@@ -6,17 +6,18 @@ from unittest.mock import PropertyMock
import blebox_uniapi
import pytest
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .conftest import async_setup_entity, mock_feature
query_icon_matching = [
("up", "mdi:arrow-up-circle"),
("down", "mdi:arrow-down-circle"),
("fav", "mdi:heart-circle"),
("open", "mdi:arrow-up-circle"),
("close", "mdi:arrow-down-circle"),
query_translation_key_matching = [
("up", "up"),
("down", "down"),
("fav", "fav"),
("open", "open"),
("close", "close"),
("unknown_action", None),
]
@@ -56,16 +57,28 @@ async def test_tvliftbox_init(
assert state.name == "My tvLiftBox tvLiftBox-open_or_stop"
@pytest.mark.parametrize("input", query_icon_matching)
async def test_get_icon(
input, tvliftbox, hass: HomeAssistant, caplog: pytest.LogCaptureFixture
@pytest.mark.parametrize(
("query_string", "expected_translation_key"),
query_translation_key_matching,
ids=[q[0] for q in query_translation_key_matching],
)
async def test_button_translation_key(
query_string: str,
expected_translation_key: str | None,
tvliftbox: tuple[blebox_uniapi.button.Button, str],
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test if proper icon is returned."""
"""Test that the correct translation_key is assigned based on query_string."""
caplog.set_level(logging.ERROR)
feature_mock, entity_id = tvliftbox
feature_mock.query_string = input[0]
_ = await async_setup_entity(hass, entity_id)
state = hass.states.get(entity_id)
feature_mock.query_string = query_string
await async_setup_entity(hass, entity_id)
assert state.attributes[ATTR_ICON] == input[1]
state = hass.states.get(entity_id)
assert state is not None
entity = er.async_get(hass).async_get(entity_id)
assert entity is not None
assert entity.translation_key == expected_translation_key
+95 -2
View File
@@ -10,9 +10,11 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import entity_registry as er, label_registry as lr
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
async def test_default_state(
hass: HomeAssistant, entity_registry: er.EntityRegistry
@@ -41,8 +43,8 @@ async def test_default_state(
assert state is not None
assert state.state == STATE_ON
assert state.attributes.get(ATTR_ENTITY_ID) == [
"binary_sensor.kitchen",
"binary_sensor.bedroom",
"binary_sensor.kitchen",
]
entry = entity_registry.async_get("binary_sensor.bedroom_group")
@@ -52,6 +54,97 @@ async def test_default_state(
assert entry.original_device_class == "presence"
async def test_multiple_targets(
hass: HomeAssistant,
label_registry: lr.LabelRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test binary sensor from config entry with multiple targets."""
hass.states.async_set("binary_sensor.kitchen", "on")
hass.states.async_set("binary_sensor.bedroom", "on")
group_config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={
"entities": {
"area_id": ["bedroom"],
"entity_id": [
"binary_sensor.kitchen",
"binary_sensor.bedroom",
"binary_sensor.not_exist",
],
"label_id": ["test"],
},
"group_type": "binary_sensor",
"name": "Bedroom Group",
"all": False,
},
title="Bedroom Group",
version=2,
)
group_config_entry.add_to_hass(hass)
label_registry.async_create("Test")
entity_registry.async_get_or_create(
"binary_sensor",
"test",
"in_a_label",
suggested_object_id="in_a_label",
config_entry=group_config_entry,
)
entity_registry.async_update_entity("binary_sensor.in_a_label", labels={"test"})
hass.states.async_set("binary_sensor.in_a_label", "on")
assert await hass.config_entries.async_setup(group_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("binary_sensor.bedroom_group")
assert state is not None
assert state.state == STATE_ON
assert state.attributes.get(ATTR_ENTITY_ID) == [
"binary_sensor.bedroom",
"binary_sensor.in_a_label",
"binary_sensor.kitchen",
"binary_sensor.not_exist",
]
entity_registry.async_get_or_create(
"binary_sensor",
"test",
"added_to_a_label",
suggested_object_id="added_to_a_label",
config_entry=group_config_entry,
)
entity_registry.async_update_entity(
"binary_sensor.added_to_a_label", labels={"test"}
)
hass.states.async_set("binary_sensor.added_to_a_label", "on")
entity_registry.async_get_or_create(
"test",
"test",
"not_to_be_included",
suggested_object_id="not_to_be_included",
config_entry=group_config_entry,
)
entity_registry.async_update_entity("test.not_to_be_included", labels={"test"})
hass.states.async_set("test.not_to_be_included", "on")
await hass.async_block_till_done()
state = hass.states.get("binary_sensor.bedroom_group")
assert state is not None
assert state.state == STATE_ON
assert state.attributes.get(ATTR_ENTITY_ID) == [
"binary_sensor.added_to_a_label",
"binary_sensor.bedroom",
"binary_sensor.in_a_label",
"binary_sensor.kitchen",
"binary_sensor.not_exist",
]
async def test_state_reporting_all(hass: HomeAssistant) -> None:
"""Test the state reporting in 'all' mode.
+35 -26
View File
@@ -98,7 +98,7 @@ async def test_config_flow(
result["flow_id"],
{
"name": "Living Room",
"entities": members,
"entities": {"entity_id": members},
**extra_input,
},
)
@@ -108,7 +108,7 @@ async def test_config_flow(
assert result["title"] == "Living Room"
assert result["data"] == {}
assert result["options"] == {
"entities": members,
"entities": {"entity_id": members},
"group_type": group_type,
"hide_members": False,
"name": "Living Room",
@@ -119,7 +119,7 @@ async def test_config_flow(
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
assert config_entry.data == {}
assert config_entry.options == {
"entities": members,
"entities": {"entity_id": members},
"group_type": group_type,
"hide_members": False,
"name": "Living Room",
@@ -192,7 +192,7 @@ async def test_config_flow_hides_members(
result["flow_id"],
{
"name": "Living Room",
"entities": members,
"entities": {"entity_id": members},
"hide_members": hide_members,
**extra_input,
},
@@ -232,7 +232,7 @@ async def test_options(
) -> None:
"""Test reconfiguring."""
members1 = [f"{group_type}.one", f"{group_type}.two"]
members2 = [f"{group_type}.four", f"{group_type}.five"]
members2 = [f"{group_type}.five", f"{group_type}.four"]
for member in members1:
hass.states.async_set(member, member_state, {})
@@ -243,12 +243,13 @@ async def test_options(
data={},
domain=DOMAIN,
options={
"entities": members1,
"entities": {"entity_id": members1},
"group_type": group_type,
"name": "Bed Room",
**extra_options,
},
title="Bed Room",
version=2,
)
group_config_entry.add_to_hass(hass)
@@ -263,21 +264,18 @@ async def test_options(
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == group_type
assert (
get_schema_suggested_value(result["data_schema"].schema, "entities") == members1
)
assert get_schema_suggested_value(result["data_schema"].schema, "entities") == {
"entity_id": members1
}
assert "name" not in result["data_schema"].schema
assert result["data_schema"].schema["entities"].config["exclude_entities"] == [
f"{group_type}.bed_room"
]
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"entities": members2, **options_options},
user_input={"entities": {"entity_id": members2}, **options_options},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
"entities": members2,
"entities": {"entity_id": members2},
"group_type": group_type,
"hide_members": False,
"name": "Bed Room",
@@ -285,7 +283,7 @@ async def test_options(
}
assert config_entry.data == {}
assert config_entry.options == {
"entities": members2,
"entities": {"entity_id": members2},
"group_type": group_type,
"hide_members": False,
"name": "Bed Room",
@@ -336,12 +334,13 @@ async def test_all_options(
data={},
domain=DOMAIN,
options={
"entities": members1,
"entities": {"entity_id": members1},
"group_type": group_type,
"name": "Bed Room",
**extra_options,
},
title="Bed Room",
version=2,
)
group_config_entry.add_to_hass(hass)
@@ -359,12 +358,12 @@ async def test_all_options(
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"entities": members2,
"entities": {"entity_id": members2},
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
"entities": members2,
"entities": {"entity_id": members2},
"group_type": group_type,
"hide_members": False,
"name": "Bed Room",
@@ -372,7 +371,7 @@ async def test_all_options(
}
assert config_entry.data == {}
assert config_entry.options == {
"entities": members2,
"entities": {"entity_id": members2},
"group_type": group_type,
"hide_members": False,
"name": "Bed Room",
@@ -439,13 +438,14 @@ async def test_options_flow_hides_members(
data={},
domain=DOMAIN,
options={
"entities": members,
"entities": {"entity_id": members},
"group_type": group_type,
"hide_members": False,
"name": "Bed Room",
**extra_input,
},
title="Bed Room",
version=2,
)
group_config_entry.add_to_hass(hass)
@@ -458,7 +458,7 @@ async def test_options_flow_hides_members(
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"entities": members,
"entities": {"entity_id": members},
"hide_members": hide_members,
},
)
@@ -539,7 +539,10 @@ async def test_config_flow_preview(
"type": "group/start_preview",
"flow_id": result["flow_id"],
"flow_type": "config_flow",
"user_input": {"name": "My group", "entities": input_entities}
"user_input": {
"name": "My group",
"entities": {"entity_id": input_entities},
}
| extra_user_input,
}
)
@@ -571,7 +574,10 @@ async def test_config_flow_preview(
"type": "group/start_preview",
"flow_id": result["flow_id"],
"flow_type": "config_flow",
"user_input": {"name": "My group", "entities": input_entities}
"user_input": {
"name": "My group",
"entities": {"entity_id": input_entities},
}
| extra_user_input,
}
)
@@ -642,13 +648,14 @@ async def test_option_flow_preview(
data={},
domain=DOMAIN,
options={
"entities": input_entities,
"entities": {"entity_id": input_entities},
"group_type": domain,
"hide_members": False,
"name": "My group",
}
| extra_config_flow_data,
title="My group",
version=2,
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
@@ -669,7 +676,8 @@ async def test_option_flow_preview(
"type": "group/start_preview",
"flow_id": result["flow_id"],
"flow_type": "options_flow",
"user_input": {"entities": input_entities} | extra_user_input,
"user_input": {"entities": {"entity_id": input_entities}}
| extra_user_input,
}
)
msg = await client.receive_json()
@@ -699,13 +707,14 @@ async def test_option_flow_sensor_preview_config_entry_removed(
data={},
domain=DOMAIN,
options={
"entities": input_entities,
"entities": {"entity_id": input_entities},
"group_type": "sensor",
"hide_members": False,
"name": "My sensor group",
"type": "min",
},
title="My min_max",
version=2,
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
+2 -2
View File
@@ -126,8 +126,8 @@ async def test_state(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
state = hass.states.get(COVER_GROUP)
assert state.attributes[ATTR_ENTITY_ID] == [
DEMO_COVER,
DEMO_COVER_POS,
DEMO_COVER,
DEMO_COVER_TILT,
DEMO_TILT,
]
@@ -287,8 +287,8 @@ async def test_attributes(
state = hass.states.get(COVER_GROUP)
assert state.state == CoverState.CLOSED
assert state.attributes[ATTR_ENTITY_ID] == [
DEMO_COVER,
DEMO_COVER_POS,
DEMO_COVER,
DEMO_COVER_TILT,
DEMO_TILT,
]
+12 -8
View File
@@ -135,10 +135,12 @@ async def test_state(hass: HomeAssistant, entity_registry: er.EntityRegistry) ->
hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_UNKNOWN, {})
await hass.async_block_till_done()
state = hass.states.get(FAN_GROUP)
assert state.attributes[ATTR_ENTITY_ID] == [
*FULL_FAN_ENTITY_IDS,
*LIMITED_FAN_ENTITY_IDS,
]
assert state.attributes[ATTR_ENTITY_ID] == sorted(
[
*FULL_FAN_ENTITY_IDS,
*LIMITED_FAN_ENTITY_IDS,
]
)
# All group members unavailable -> unavailable
hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_UNAVAILABLE)
@@ -228,10 +230,12 @@ async def test_attributes(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
state = hass.states.get(FAN_GROUP)
assert state.state == STATE_ON
assert state.attributes[ATTR_ENTITY_ID] == [
*FULL_FAN_ENTITY_IDS,
*LIMITED_FAN_ENTITY_IDS,
]
assert state.attributes[ATTR_ENTITY_ID] == sorted(
[
*FULL_FAN_ENTITY_IDS,
*LIMITED_FAN_ENTITY_IDS,
]
)
# Add Entity that supports speed
hass.states.async_set(
+47 -2
View File
@@ -1973,12 +1973,13 @@ async def test_setup_and_remove_config_entry(
data={},
domain=group.DOMAIN,
options={
"entities": members1,
"entities": {"entity_id": members1},
"group_type": group_type,
"name": "Bed Room",
**extra_options,
},
title="Bed Room",
version=2,
)
group_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(group_config_entry.entry_id)
@@ -2062,13 +2063,14 @@ async def test_unhide_members_on_remove(
data={},
domain=group.DOMAIN,
options={
"entities": members,
"entities": {"entity_id": members},
"group_type": group_type,
"hide_members": hide_members,
"name": "Bed Room",
**extra_options,
},
title="Bed Room",
version=2,
)
group_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(group_config_entry.entry_id)
@@ -2301,3 +2303,46 @@ async def test_entity_platforms_with_multiple_on_states_with_state_match(
group_state2,
grouped_groups,
)
async def test_migrate_from_version_1_to_2(hass: HomeAssistant) -> None:
"""Test migrating from version 1 to 2."""
hass.states.async_set("binary_sensor.kitchen", "on")
hass.states.async_set("binary_sensor.bedroom", "on")
group_config_entry = MockConfigEntry(
data={},
domain="group",
options={
"entities": [
"binary_sensor.kitchen",
"binary_sensor.bedroom",
],
"group_type": "binary_sensor",
"name": "Fancy Group",
"all": False,
},
title="Fancy Group",
)
group_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(group_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("binary_sensor.fancy_group")
assert state is not None
assert state.state == STATE_ON
entry = hass.config_entries.async_entries("group")[0]
assert entry.version == 2
assert entry.options == {
"all": False,
"entities": {
"entity_id": [
"binary_sensor.kitchen",
"binary_sensor.bedroom",
],
},
"group_type": "binary_sensor",
"name": "Fancy Group",
}
+2 -1
View File
@@ -77,7 +77,7 @@ async def test_default_state(
assert state is not None
assert state.state == STATE_ON
assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0
assert state.attributes.get(ATTR_ENTITY_ID) == ["light.kitchen", "light.bedroom"]
assert state.attributes.get(ATTR_ENTITY_ID) == ["light.bedroom", "light.kitchen"]
assert state.attributes.get(ATTR_BRIGHTNESS) is None
assert state.attributes.get(ATTR_HS_COLOR) is None
assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None
@@ -1473,6 +1473,7 @@ async def test_invalid_service_calls(hass: HomeAssistant) -> None:
assert add_entities.call_count == 1
grouped_light = add_entities.call_args[0][0][0]
grouped_light.hass = hass
grouped_light._entity_ids = ["light.test1", "light.test2"]
service_call_events = async_capture_events(hass, EVENT_CALL_SERVICE)
+1 -1
View File
@@ -47,7 +47,7 @@ async def test_default_state(
state = hass.states.get("lock.door_group")
assert state is not None
assert state.state == LockState.LOCKED
assert state.attributes.get(ATTR_ENTITY_ID) == ["lock.front", "lock.back"]
assert state.attributes.get(ATTR_ENTITY_ID) == ["lock.back", "lock.front"]
entry = entity_registry.async_get("lock.door_group")
assert entry
+1 -1
View File
@@ -53,7 +53,7 @@ async def test_default_state(
state = hass.states.get("switch.multimedia_group")
assert state is not None
assert state.state == STATE_ON
assert state.attributes.get(ATTR_ENTITY_ID) == ["switch.tv", "switch.soundbar"]
assert state.attributes.get(ATTR_ENTITY_ID) == ["switch.soundbar", "switch.tv"]
entry = entity_registry.async_get("switch.multimedia_group")
assert entry
+2 -2
View File
@@ -117,9 +117,9 @@ async def test_state(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
state = hass.states.get(VALVE_GROUP)
assert state.attributes[ATTR_ENTITY_ID] == [
DEMO_VALVE_POS1,
DEMO_VALVE1,
DEMO_VALVE2,
DEMO_VALVE_POS1,
DEMO_VALVE_POS2,
]
@@ -277,9 +277,9 @@ async def test_attributes(
state = hass.states.get(VALVE_GROUP)
assert state.state == ValveState.CLOSED
assert state.attributes[ATTR_ENTITY_ID] == [
DEMO_VALVE_POS1,
DEMO_VALVE1,
DEMO_VALVE2,
DEMO_VALVE_POS1,
DEMO_VALVE_POS2,
]
@@ -138,7 +138,7 @@
'platform': 'homee',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <CoverEntityFeature: 176>,
'supported_features': <CoverEntityFeature: 240>,
'translation_key': None,
'unique_id': '00055511EECC-1-0',
'unit_of_measurement': None,
@@ -151,7 +151,7 @@
'device_class': 'shutter',
'friendly_name': 'Slats & Position',
'is_closed': False,
'supported_features': <CoverEntityFeature: 176>,
'supported_features': <CoverEntityFeature: 240>,
}),
'context': <ANY>,
'entity_id': 'cover.slats_position',
+10 -1
View File
@@ -29,6 +29,7 @@ from homeassistant.const import (
SERVICE_SET_COVER_POSITION,
SERVICE_SET_COVER_TILT_POSITION,
SERVICE_STOP_COVER,
SERVICE_STOP_COVER_TILT,
STATE_UNAVAILABLE,
Platform,
)
@@ -164,9 +165,16 @@ async def test_close_open_slats(
assert attributes.get("supported_features") == (
CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT
| CoverEntityFeature.SET_TILT_POSITION
)
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_STOP_COVER_TILT,
{ATTR_ENTITY_ID: "cover.slats_position"},
blocking=True,
)
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_CLOSE_COVER_TILT,
@@ -181,7 +189,7 @@ async def test_close_open_slats(
)
calls = mock_homee.set_value.call_args_list
for index, call in enumerate(calls, start=1):
for index, call in enumerate(calls):
assert call[0] == (mock_homee.nodes[0].id, 2, index)
@@ -200,6 +208,7 @@ async def test_close_open_reversed_slats(
assert attributes.get("supported_features") == (
CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT
| CoverEntityFeature.SET_TILT_POSITION
)
+13 -8
View File
@@ -236,18 +236,19 @@ async def test_setup_two_trackers(
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
# Router tracker at home with gps_accuracy — the person entity gets latitude,
# longitude and accuracy from the home zone (the coordinates source), not
# from the router tracker's own attributes.
# Router tracker at home — the person entity gets latitude, longitude and
# accuracy from the home zone (the coordinates source), not from the router
# tracker's own attributes. `in_zones`, however, is propagated from the
# source tracker.
# Note: a router tracker would not really have gps_accuracy; it is set here
# (together with in_zones=["zone.fake"]) only to assert it is NOT propagated.
# only to assert it is NOT propagated.
hass.states.async_set(
DEVICE_TRACKER,
"home",
{
ATTR_SOURCE_TYPE: SourceType.ROUTER,
ATTR_GPS_ACCURACY: 99,
ATTR_IN_ZONES: ["zone.fake"],
ATTR_IN_ZONES: ["zone.home"],
},
)
await hass.async_block_till_done()
@@ -255,6 +256,7 @@ async def test_setup_two_trackers(
state = hass.states.get("person.tracked_person")
assert state.state == "home"
assert state.attributes == expected_attributes | {
ATTR_IN_ZONES: ["zone.home"],
ATTR_LATITUDE: 32.87336,
ATTR_LONGITUDE: -117.22743,
ATTR_SOURCE: DEVICE_TRACKER,
@@ -447,6 +449,7 @@ async def _async_setup_person_two_trackers(hass: HomeAssistant, user_id: str) ->
_GPS_NOT_HOME,
"home",
{
ATTR_IN_ZONES: ["zone.home"],
ATTR_LATITUDE: 32.87336,
ATTR_LONGITUDE: -117.22743,
ATTR_SOURCE: DEVICE_TRACKER,
@@ -459,6 +462,7 @@ async def _async_setup_person_two_trackers(hass: HomeAssistant, user_id: str) ->
_SCANNER_OFFICE,
"home",
{
ATTR_IN_ZONES: ["zone.home"],
ATTR_LATITUDE: 32.87336,
ATTR_LONGITUDE: -117.22743,
ATTR_SOURCE: DEVICE_TRACKER,
@@ -573,6 +577,7 @@ async def test_state_priority_overrides_recency(
_ROUTER_HOME,
"home",
{
ATTR_IN_ZONES: ["zone.home"],
ATTR_LATITUDE: 32.87336,
ATTR_LONGITUDE: -117.22743,
ATTR_SOURCE: DEVICE_TRACKER_2,
@@ -769,7 +774,7 @@ async def test_duplicate_ids(hass: HomeAssistant, hass_admin_user: MockUser) ->
}
assert await async_setup_component(hass, DOMAIN, config)
assert len(hass.states.async_entity_ids("person")) == 1
assert len(hass.states.async_entity_ids(DOMAIN)) == 1
assert hass.states.get("person.test_user_1") is not None
assert hass.states.get("person.test_user_2") is None
@@ -847,7 +852,7 @@ async def test_load_person_storage_two_nonlinked(
}
await async_setup_component(hass, DOMAIN, {})
assert len(hass.states.async_entity_ids("person")) == 2
assert len(hass.states.async_entity_ids(DOMAIN)) == 2
assert hass.states.get("person.tracked_person_1") is not None
assert hass.states.get("person.tracked_person_2") is not None
@@ -1028,7 +1033,7 @@ async def test_ws_delete(
assert len(persons) == 0
assert resp["success"]
assert len(hass.states.async_entity_ids("person")) == 0
assert len(hass.states.async_entity_ids(DOMAIN)) == 0
assert not entity_registry.async_is_registered("person.tracked_person")
+4 -4
View File
@@ -205,7 +205,7 @@ async def test_setup_with_invalid_configs(
"""Test setup with invalid configs."""
assert await async_setup_component(hass, "script", {"script": config})
assert len(hass.states.async_entity_ids("script")) == nbr_script_entities
assert len(hass.states.async_entity_ids(DOMAIN)) == nbr_script_entities
@pytest.mark.parametrize(
@@ -261,7 +261,7 @@ async def test_bad_config_validation_critical(
)
# Make sure one bad script does not prevent other scripts from setting up
assert hass.states.async_entity_ids("script") == ["script.good_script"]
assert hass.states.async_entity_ids(DOMAIN) == ["script.good_script"]
@pytest.mark.parametrize(
@@ -337,7 +337,7 @@ async def test_bad_config_validation(
assert issues[0]["translation_placeholders"]["error"].startswith(details)
# Make sure both scripts are setup
assert set(hass.states.async_entity_ids("script")) == {
assert set(hass.states.async_entity_ids(DOMAIN)) == {
"script.bad_script",
"script.good_script",
}
@@ -1518,7 +1518,7 @@ async def test_setup_with_duplicate_scripts(
},
)
assert "Duplicate script detected with name: 'duplicate'" in caplog.text
assert len(hass.states.async_entity_ids("script")) == 1
assert len(hass.states.async_entity_ids(DOMAIN)) == 1
async def test_websocket_config(
+1
View File
@@ -0,0 +1 @@
"""Tests for the Swisscom Internet-Box integration."""
+49
View File
@@ -0,0 +1,49 @@
"""Fixtures for the Swisscom Internet-Box integration tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.swisscom.const import DOMAIN
from .const import TEST_BASE_MAC, TEST_FORMATTED_MAC, TEST_MODEL_NAME, USER_INPUT
from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title=TEST_MODEL_NAME,
domain=DOMAIN,
data=USER_INPUT,
unique_id=TEST_FORMATTED_MAC,
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.swisscom.async_setup_entry", return_value=True
) as mock_fn:
yield mock_fn
@pytest.fixture
def mock_swisscom_client() -> Generator[MagicMock]:
"""Mock the SwisscomClient used in the config flow."""
box_info = MagicMock()
box_info.base_mac = TEST_BASE_MAC
box_info.model_name = TEST_MODEL_NAME
with patch(
"homeassistant.components.swisscom.config_flow.SwisscomClient",
autospec=True,
) as mock_cls:
client = mock_cls.return_value
client.login = AsyncMock()
client.get_box_info = AsyncMock(return_value=box_info)
yield client
+16
View File
@@ -0,0 +1,16 @@
"""Constants for the Swisscom Internet-Box integration tests."""
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
TEST_HOST = "192.168.1.1"
TEST_USERNAME = "admin"
TEST_PASSWORD = "test-password"
TEST_BASE_MAC = "AA:BB:CC:DD:EE:FF"
TEST_FORMATTED_MAC = "aa:bb:cc:dd:ee:ff"
TEST_MODEL_NAME = "Internet-Box plus"
USER_INPUT = {
CONF_HOST: TEST_HOST,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
}
@@ -0,0 +1,112 @@
"""Tests for the Swisscom Internet-Box config flow."""
from unittest.mock import MagicMock
import pytest
from swisscom_internet_box import SwisscomAuthError, SwisscomConnectionError
from homeassistant.components.swisscom.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import TEST_FORMATTED_MAC, TEST_MODEL_NAME, USER_INPUT
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_success(
hass: HomeAssistant, mock_swisscom_client: MagicMock
) -> None:
"""Test a successful user-initiated config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=USER_INPUT
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_MODEL_NAME
assert result["data"] == USER_INPUT
assert result["result"].unique_id == TEST_FORMATTED_MAC
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(SwisscomAuthError("bad creds"), "invalid_auth"),
(SwisscomConnectionError("unreachable"), "cannot_connect"),
(RuntimeError("boom"), "unknown"),
],
ids=["invalid_auth", "cannot_connect", "unknown"],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_error_and_recovery(
hass: HomeAssistant,
mock_swisscom_client: MagicMock,
side_effect: Exception,
expected_error: str,
) -> None:
"""Test user flow shows the correct error and the user can retry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_swisscom_client.login.side_effect = side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=USER_INPUT
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
mock_swisscom_client.login.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=USER_INPUT
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_MODEL_NAME
assert result["data"] == USER_INPUT
assert result["result"].unique_id == TEST_FORMATTED_MAC
async def test_user_flow_duplicate(
hass: HomeAssistant,
mock_swisscom_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that duplicate boxes are rejected."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=USER_INPUT
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_no_model_name_uses_default_title(
hass: HomeAssistant, mock_swisscom_client: MagicMock
) -> None:
"""Test the entry falls back to a default title when the box reports no model."""
mock_swisscom_client.get_box_info.return_value.model_name = ""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=USER_INPUT
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Internet-Box"
@@ -151,7 +151,7 @@
'state': 'on',
})
# ---
# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_zone_1_enabled-entry]
# name: test_all_entities[binary_sensor.zone_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -165,7 +165,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.yardian_smart_sprinkler_zone_1_enabled',
'entity_id': 'binary_sensor.zone_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -173,35 +173,35 @@
'labels': set({
}),
'name': None,
'object_id_base': 'Zone 1 enabled',
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Zone 1 enabled',
'original_name': None,
'platform': 'yardian',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'zone_enabled',
'translation_key': 'enabled',
'unique_id': 'yid123-zone_enabled_0',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_zone_1_enabled-state]
# name: test_all_entities[binary_sensor.zone_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Yardian Smart Sprinkler Zone 1 enabled',
'friendly_name': 'Zone 1',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.yardian_smart_sprinkler_zone_1_enabled',
'entity_id': 'binary_sensor.zone_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_zone_2_enabled-entry]
# name: test_all_entities[binary_sensor.zone_2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -215,7 +215,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.yardian_smart_sprinkler_zone_2_enabled',
'entity_id': 'binary_sensor.zone_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -223,28 +223,28 @@
'labels': set({
}),
'name': None,
'object_id_base': 'Zone 2 enabled',
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Zone 2 enabled',
'original_name': None,
'platform': 'yardian',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'zone_enabled',
'translation_key': 'enabled',
'unique_id': 'yid123-zone_enabled_1',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.yardian_smart_sprinkler_zone_2_enabled-state]
# name: test_all_entities[binary_sensor.zone_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Yardian Smart Sprinkler Zone 2 enabled',
'friendly_name': 'Zone 2',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.yardian_smart_sprinkler_zone_2_enabled',
'entity_id': 'binary_sensor.zone_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -1,5 +1,5 @@
# serializer version: 1
# name: test_all_entities[switch.yardian_smart_sprinkler_zone_1-entry]
# name: test_all_entities[switch.zone_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -13,7 +13,7 @@
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.yardian_smart_sprinkler_zone_1',
'entity_id': 'switch.zone_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -21,35 +21,35 @@
'labels': set({
}),
'name': None,
'object_id_base': 'Zone 1',
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Zone 1',
'original_name': None,
'platform': 'yardian',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'switch',
'translation_key': None,
'unique_id': 'yid123-0',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.yardian_smart_sprinkler_zone_1-state]
# name: test_all_entities[switch.zone_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Yardian Smart Sprinkler Zone 1',
'friendly_name': 'Zone 1',
}),
'context': <ANY>,
'entity_id': 'switch.yardian_smart_sprinkler_zone_1',
'entity_id': 'switch.zone_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[switch.yardian_smart_sprinkler_zone_2-entry]
# name: test_all_entities[switch.zone_2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -63,7 +63,7 @@
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.yardian_smart_sprinkler_zone_2',
'entity_id': 'switch.zone_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -71,28 +71,28 @@
'labels': set({
}),
'name': None,
'object_id_base': 'Zone 2',
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Zone 2',
'original_name': None,
'platform': 'yardian',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'switch',
'translation_key': None,
'unique_id': 'yid123-1',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.yardian_smart_sprinkler_zone_2-state]
# name: test_all_entities[switch.zone_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Yardian Smart Sprinkler Zone 2',
'friendly_name': 'Zone 2',
}),
'context': <ANY>,
'entity_id': 'switch.yardian_smart_sprinkler_zone_2',
'entity_id': 'switch.zone_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
+4 -2
View File
@@ -42,7 +42,8 @@ async def test_turn_on_switch(
"""Test turning on a switch."""
await setup_integration(hass, mock_config_entry)
entity_id = "switch.yardian_smart_sprinkler_zone_1"
entity_id = "switch.zone_1"
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
@@ -61,7 +62,8 @@ async def test_turn_off_switch(
"""Test turning off a switch."""
await setup_integration(hass, mock_config_entry)
entity_id = "switch.yardian_smart_sprinkler_zone_1"
entity_id = "switch.zone_1"
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
+5 -5
View File
@@ -65,7 +65,7 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]):
async def test_setup_no_zones_still_adds_home_zone(hass: HomeAssistant) -> None:
"""Test if no config is passed in we still get the home zone."""
assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": None})
assert len(hass.states.async_entity_ids("zone")) == 1
assert len(hass.states.async_entity_ids(DOMAIN)) == 1
state = hass.states.get("zone.home")
assert hass.config.location_name == state.name
assert hass.config.latitude == state.attributes["latitude"]
@@ -84,7 +84,7 @@ async def test_setup(hass: HomeAssistant) -> None:
}
assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": info})
assert len(hass.states.async_entity_ids("zone")) == 2
assert len(hass.states.async_entity_ids(DOMAIN)) == 2
state = hass.states.get("zone.test_zone")
assert info["name"] == state.name
assert info["latitude"] == state.attributes["latitude"]
@@ -98,7 +98,7 @@ async def test_setup_zone_skips_home_zone(hass: HomeAssistant) -> None:
info = {"name": "Home", "latitude": 1.1, "longitude": -2.2}
assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": info})
assert len(hass.states.async_entity_ids("zone")) == 1
assert len(hass.states.async_entity_ids(DOMAIN)) == 1
state = hass.states.get("zone.home")
assert info["name"] == state.name
@@ -107,7 +107,7 @@ async def test_setup_name_can_be_same_on_multiple_zones(hass: HomeAssistant) ->
"""Test that zone named Home should override hass home zone."""
info = {"name": "Test Zone", "latitude": 1.1, "longitude": -2.2}
assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": [info, info]})
assert len(hass.states.async_entity_ids("zone")) == 3
assert len(hass.states.async_entity_ids(DOMAIN)) == 3
async def test_active_zone_skips_passive_zones(hass: HomeAssistant) -> None:
@@ -811,7 +811,7 @@ async def test_state(hass: HomeAssistant) -> None:
}
assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": info})
assert len(hass.states.async_entity_ids("zone")) == 2
assert len(hass.states.async_entity_ids(DOMAIN)) == 2
state = hass.states.get("zone.test_zone")
assert state.state == "0"
assert state.attributes[ATTR_PERSONS] == []
+11 -9
View File
@@ -2242,8 +2242,8 @@ async def test_condition_template_error_traced_not_logged(
"""Test template errors are added to the trace and not logged when opted in.
The subscribe_condition websocket command re-evaluates a condition every
second and opts in via trace.record_template_errors(). Template variable
errors must then be recorded in the trace instead of being logged repeatedly.
second and opts in via trace.suppress_template_error_logging(). Template
variable errors are then recorded in the trace without being logged.
"""
caplog.set_level(logging.WARNING)
config = {"condition": "template", "value_template": value_template}
@@ -2251,7 +2251,7 @@ async def test_condition_template_error_traced_not_logged(
config = await condition.async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
with expectation, trace.record_template_errors():
with expectation, trace.suppress_template_error_logging():
test.async_check()
# The template errors are recorded in the trace...
@@ -2269,11 +2269,10 @@ async def test_condition_template_error_logged_without_opt_in(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test template errors are logged when recording is not opted in.
"""Test template errors are logged when suppression is not opted in.
An active trace is not enough to suppress logging; the consumer must opt in
via trace.record_template_errors(). Without it, the error is logged as usual
and not recorded in the trace.
The error is always recorded in the trace, but unless the consumer opts in
via trace.suppress_template_error_logging() it is also logged as usual.
"""
caplog.set_level(logging.WARNING)
config = {"condition": "template", "value_template": "{{ no_such_variable }}"}
@@ -2283,10 +2282,13 @@ async def test_condition_template_error_logged_without_opt_in(
assert test.async_check() is False
assert "Template variable warning: 'no_such_variable' is undefined" in caplog.text
# Recorded in the trace...
condition_trace = trace.trace_get(clear=False)
trace.trace_clear()
assert condition_trace[""][0].template_errors == []
assert condition_trace[""][0].template_errors == ["'no_such_variable' is undefined"]
# ...and also logged
assert "Template variable warning: 'no_such_variable' is undefined" in caplog.text
async def test_condition_template_invalid_results(hass: HomeAssistant) -> None:
+20 -4
View File
@@ -868,7 +868,8 @@ async def test_delay_template_invalid(
{
"error": (
"offset should be format 'HH:MM', 'HH:MM:SS' or 'HH:MM:SS.F'"
)
),
"template_errors": ["'invalid_delay' is undefined"],
}
],
},
@@ -933,7 +934,12 @@ async def test_delay_template_complex_invalid(
assert_action_trace(
{
"0": [{"result": {"event": "test_event", "event_data": {}}}],
"1": [{"error": "expected float for dictionary value @ data['seconds']"}],
"1": [
{
"error": "expected float for dictionary value @ data['seconds']",
"template_errors": ["'invalid_delay' is undefined"],
}
],
},
expected_script_execution="aborted",
)
@@ -2646,7 +2652,12 @@ async def test_repeat_for_each_invalid_template(
assert_action_trace(
{
"0": [{"error": "Repeat 'for_each' must be a list of items"}],
"0": [
{
"error": "Repeat 'for_each' must be a list of items",
"template_errors": ["'Muhaha' is undefined"],
}
],
},
expected_script_execution="aborted",
)
@@ -2715,7 +2726,12 @@ async def test_repeat_condition_warning(
expected_trace[f"0/repeat/{condition}/0"] = [
{"error": "In 'numeric_state':\n " + expected_error}
]
expected_trace[f"0/repeat/{condition}/0/entity_id/0"] = [{"error": expected_error}]
expected_trace[f"0/repeat/{condition}/0/entity_id/0"] = [
{
"error": expected_error,
"template_errors": ["'unassigned_variable' is undefined"],
}
]
assert_action_trace(expected_trace)