Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7659eba376 | |||
| 2f083190fc | |||
| 640ec2bf34 | |||
| fb24beccfc | |||
| 690d73b97e | |||
| 510485b711 | |||
| 3b15efab36 | |||
| 3a4d9ba425 | |||
| 6276525780 | |||
| ee9af3d1d3 | |||
| 07c4fbf7c3 | |||
| 46043165f1 | |||
| 158c0211f7 | |||
| 9160f2b42d | |||
| b30d702d3f | |||
| fdaa3175fb | |||
| 9e541fc872 | |||
| fc2b7902a5 | |||
| 3aa4cbeeb0 | |||
| 3c2f171158 | |||
| ffc6eeadc2 | |||
| 5cc1a0a1ef | |||
| 1cbbce5b35 | |||
| 9f5cb635f0 | |||
| 50de2c070e | |||
| a7f012350f | |||
| 04d2211d1e | |||
| 5b4c2c6017 | |||
| f36a491ebd |
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Avosdim."""
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "avosdim",
|
||||
"name": "Avosdim",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "motion_blinds"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+1
@@ -727,6 +727,7 @@ FLOWS = {
|
||||
"sunweg",
|
||||
"surepetcare",
|
||||
"swiss_public_transport",
|
||||
"swisscom",
|
||||
"switchbee",
|
||||
"switchbot",
|
||||
"switchbot_cloud",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+5
-2
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Swisscom Internet-Box integration."""
|
||||
@@ -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
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] == []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user