Add coordinator for Satel Integra (#158533)

Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
This commit is contained in:
Tom Matheussen
2026-02-17 13:55:00 +01:00
committed by GitHub
parent fdad9873e4
commit 58e4a42a1b
14 changed files with 428 additions and 240 deletions

View File

@@ -2,30 +2,21 @@
import logging
from satel_integra.satel_integra import AsyncSatel
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONF_CODE,
CONF_HOST,
CONF_NAME,
CONF_PORT,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.const import CONF_CODE, CONF_HOST, CONF_NAME, CONF_PORT, Platform
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
issue_registry as ir,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from homeassistant.helpers.typing import ConfigType
from .client import SatelClient
from .const import (
CONF_ARM_HOME_MODE,
CONF_DEVICE_PARTITIONS,
@@ -41,15 +32,17 @@ from .const import (
DEFAULT_PORT,
DEFAULT_ZONE_TYPE,
DOMAIN,
SIGNAL_OUTPUTS_UPDATED,
SIGNAL_PANEL_MESSAGE,
SIGNAL_ZONES_UPDATED,
SUBENTRY_TYPE_OUTPUT,
SUBENTRY_TYPE_PARTITION,
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
SUBENTRY_TYPE_ZONE,
ZONES,
)
from .coordinator import (
SatelConfigEntry,
SatelIntegraData,
SatelIntegraOutputsCoordinator,
SatelIntegraPartitionsCoordinator,
SatelIntegraZonesCoordinator,
)
_LOGGER = logging.getLogger(__name__)
@@ -159,51 +152,25 @@ async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> bool:
"""Set up Satel Integra from a config entry."""
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]
client = SatelClient(hass, entry)
# Make sure we initialize the Satel controller with the configured entries to monitor
partitions = [
subentry.data[CONF_PARTITION_NUMBER]
for subentry in entry.subentries.values()
if subentry.subentry_type == SUBENTRY_TYPE_PARTITION
]
coordinator_zones = SatelIntegraZonesCoordinator(hass, entry, client)
coordinator_outputs = SatelIntegraOutputsCoordinator(hass, entry, client)
coordinator_partitions = SatelIntegraPartitionsCoordinator(hass, entry, client)
zones = [
subentry.data[CONF_ZONE_NUMBER]
for subentry in entry.subentries.values()
if subentry.subentry_type == SUBENTRY_TYPE_ZONE
]
outputs = [
subentry.data[CONF_OUTPUT_NUMBER]
for subentry in entry.subentries.values()
if subentry.subentry_type == SUBENTRY_TYPE_OUTPUT
]
switchable_outputs = [
subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]
for subentry in entry.subentries.values()
if subentry.subentry_type == SUBENTRY_TYPE_SWITCHABLE_OUTPUT
]
monitored_outputs = outputs + switchable_outputs
controller = AsyncSatel(host, port, hass.loop, zones, monitored_outputs, partitions)
result = await controller.connect()
if not result:
raise ConfigEntryNotReady("Controller failed to connect")
entry.runtime_data = controller
@callback
def _close(*_):
controller.close()
await client.async_connect(
coordinator_zones.zones_update_callback,
coordinator_outputs.outputs_update_callback,
coordinator_partitions.partitions_update_callback,
)
entry.runtime_data = SatelIntegraData(
client=client,
coordinator_zones=coordinator_zones,
coordinator_outputs=coordinator_outputs,
coordinator_partitions=coordinator_partitions,
)
entry.async_on_unload(entry.add_update_listener(update_listener))
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
@@ -214,33 +181,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> boo
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@callback
def alarm_status_update_callback():
"""Send status update received from alarm to Home Assistant."""
_LOGGER.debug("Sending request to update panel state")
async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE)
@callback
def zones_update_callback(status):
"""Update zone objects as per notification from the alarm."""
_LOGGER.debug("Zones callback, status: %s", status)
async_dispatcher_send(hass, SIGNAL_ZONES_UPDATED, status[ZONES])
@callback
def outputs_update_callback(status):
"""Update zone objects as per notification from the alarm."""
_LOGGER.debug("Outputs updated callback , status: %s", status)
async_dispatcher_send(hass, SIGNAL_OUTPUTS_UPDATED, status["outputs"])
# Create a task instead of adding a tracking job, since this task will
# run until the connection to satel_integra is closed.
hass.loop.create_task(controller.keep_alive())
hass.loop.create_task(
controller.monitor_status(
alarm_status_update_callback, zones_update_callback, outputs_update_callback
)
)
return True
@@ -248,8 +188,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> bo
"""Unloading the Satel platforms."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
controller = entry.runtime_data
controller.close()
runtime_data = entry.runtime_data
runtime_data.client.close()
return unload_ok

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import logging
from satel_integra.satel_integra import AlarmState, AsyncSatel
from satel_integra.satel_integra import AlarmState
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
@@ -15,16 +15,10 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_ARM_HOME_MODE,
CONF_PARTITION_NUMBER,
SIGNAL_PANEL_MESSAGE,
SUBENTRY_TYPE_PARTITION,
SatelConfigEntry,
)
from .const import CONF_ARM_HOME_MODE, CONF_PARTITION_NUMBER, SUBENTRY_TYPE_PARTITION
from .coordinator import SatelConfigEntry, SatelIntegraPartitionsCoordinator
from .entity import SatelIntegraEntity
ALARM_STATE_MAP = {
@@ -49,7 +43,7 @@ async def async_setup_entry(
) -> None:
"""Set up for Satel Integra alarm panels."""
controller = config_entry.runtime_data
runtime_data = config_entry.runtime_data
partition_subentries = filter(
lambda entry: entry.subentry_type == SUBENTRY_TYPE_PARTITION,
@@ -63,7 +57,7 @@ async def async_setup_entry(
async_add_entities(
[
SatelIntegraAlarmPanel(
controller,
runtime_data.coordinator_partitions,
config_entry.entry_id,
subentry,
partition_num,
@@ -74,8 +68,10 @@ async def async_setup_entry(
)
class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity):
"""Representation of an AlarmDecoder-based alarm panel."""
class SatelIntegraAlarmPanel(
SatelIntegraEntity[SatelIntegraPartitionsCoordinator], AlarmControlPanelEntity
):
"""Representation of a Satel Integra-based alarm panel."""
_attr_code_format = CodeFormat.NUMBER
_attr_supported_features = (
@@ -85,7 +81,7 @@ class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity):
def __init__(
self,
controller: AsyncSatel,
coordinator: SatelIntegraPartitionsCoordinator,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
@@ -93,7 +89,7 @@ class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity):
) -> None:
"""Initialize the alarm panel."""
super().__init__(
controller,
coordinator,
config_entry_id,
subentry,
device_number,
@@ -101,19 +97,11 @@ class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity):
self._arm_home_mode = arm_home_mode
async def async_added_to_hass(self) -> None:
"""Update alarm status and register callbacks for future updates."""
self._attr_alarm_state = self._read_alarm_state()
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status
)
)
@callback
def _update_alarm_status(self) -> None:
"""Handle alarm status update."""
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
state = self._read_alarm_state()
if state != self._attr_alarm_state:
@@ -123,14 +111,14 @@ class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity):
def _read_alarm_state(self) -> AlarmControlPanelState | None:
"""Read current status of the alarm and translate it into HA status."""
if not self._satel.connected:
if not self._controller.connected:
_LOGGER.debug("Alarm panel not connected")
return None
for satel_state, ha_state in ALARM_STATE_MAP.items():
if (
satel_state in self._satel.partition_states
and self._device_number in self._satel.partition_states[satel_state]
satel_state in self.coordinator.data
and self._device_number in self.coordinator.data[satel_state]
):
return ha_state
@@ -146,21 +134,21 @@ class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity):
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED
)
await self._satel.disarm(code, [self._device_number])
await self._controller.disarm(code, [self._device_number])
if clear_alarm_necessary:
# Wait 1s before clearing the alarm
await asyncio.sleep(1)
await self._satel.clear_alarm(code, [self._device_number])
await self._controller.clear_alarm(code, [self._device_number])
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
if code:
await self._satel.arm(code, [self._device_number])
await self._controller.arm(code, [self._device_number])
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
if code:
await self._satel.arm(code, [self._device_number], self._arm_home_mode)
await self._controller.arm(code, [self._device_number], self._arm_home_mode)

View File

@@ -2,27 +2,22 @@
from __future__ import annotations
from satel_integra.satel_integra import AsyncSatel
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_OUTPUT_NUMBER,
CONF_ZONE_NUMBER,
CONF_ZONE_TYPE,
SIGNAL_OUTPUTS_UPDATED,
SIGNAL_ZONES_UPDATED,
SUBENTRY_TYPE_OUTPUT,
SUBENTRY_TYPE_ZONE,
SatelConfigEntry,
)
from .coordinator import SatelConfigEntry, SatelIntegraBaseCoordinator
from .entity import SatelIntegraEntity
@@ -33,7 +28,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Satel Integra binary sensor devices."""
controller = config_entry.runtime_data
runtime_data = config_entry.runtime_data
zone_subentries = filter(
lambda entry: entry.subentry_type == SUBENTRY_TYPE_ZONE,
@@ -47,12 +42,11 @@ async def async_setup_entry(
async_add_entities(
[
SatelIntegraBinarySensor(
controller,
runtime_data.coordinator_zones,
config_entry.entry_id,
subentry,
zone_num,
zone_type,
SIGNAL_ZONES_UPDATED,
)
],
config_subentry_id=subentry.subentry_id,
@@ -70,59 +64,50 @@ async def async_setup_entry(
async_add_entities(
[
SatelIntegraBinarySensor(
controller,
runtime_data.coordinator_outputs,
config_entry.entry_id,
subentry,
output_num,
ouput_type,
SIGNAL_OUTPUTS_UPDATED,
)
],
config_subentry_id=subentry.subentry_id,
)
class SatelIntegraBinarySensor(SatelIntegraEntity, BinarySensorEntity):
"""Representation of an Satel Integra binary sensor."""
class SatelIntegraBinarySensor[_CoordinatorT: SatelIntegraBaseCoordinator](
SatelIntegraEntity[_CoordinatorT], BinarySensorEntity
):
"""Base binary sensor for Satel Integra."""
def __init__(
self,
controller: AsyncSatel,
coordinator: _CoordinatorT,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
device_class: BinarySensorDeviceClass,
react_to_signal: str,
) -> None:
"""Initialize the binary_sensor."""
super().__init__(
controller,
coordinator,
config_entry_id,
subentry,
device_number,
)
self._attr_device_class = device_class
self._react_to_signal = react_to_signal
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED:
self._attr_is_on = self._device_number in self._satel.violated_outputs
else:
self._attr_is_on = self._device_number in self._satel.violated_zones
self.async_on_remove(
async_dispatcher_connect(
self.hass, self._react_to_signal, self._devices_updated
)
)
self._attr_is_on = self._get_state_from_coordinator()
@callback
def _devices_updated(self, zones: dict[int, int]):
"""Update the zone's state, if needed."""
if self._device_number in zones:
new_state = zones[self._device_number] == 1
if new_state != self._attr_is_on:
self._attr_is_on = new_state
self.async_write_ha_state()
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
new_state = self._get_state_from_coordinator()
if new_state != self._attr_is_on:
self._attr_is_on = new_state
self.async_write_ha_state()
def _get_state_from_coordinator(self) -> bool | None:
"""Method to get binary sensor state from coordinator data."""
return self.coordinator.data.get(self._device_number)

View File

@@ -0,0 +1,105 @@
"""Satel Integra client."""
from collections.abc import Callable
from satel_integra.satel_integra import AsyncSatel
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from .const import (
CONF_OUTPUT_NUMBER,
CONF_PARTITION_NUMBER,
CONF_SWITCHABLE_OUTPUT_NUMBER,
CONF_ZONE_NUMBER,
SUBENTRY_TYPE_OUTPUT,
SUBENTRY_TYPE_PARTITION,
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
SUBENTRY_TYPE_ZONE,
)
class SatelClient:
"""Client to connect to Satel Integra."""
controller: AsyncSatel
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize the client wrapper."""
self.hass = hass
self.config_entry = entry
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]
# Make sure we initialize the Satel controller with the configured entries to monitor
partitions = [
subentry.data[CONF_PARTITION_NUMBER]
for subentry in entry.subentries.values()
if subentry.subentry_type == SUBENTRY_TYPE_PARTITION
]
zones = [
subentry.data[CONF_ZONE_NUMBER]
for subentry in entry.subentries.values()
if subentry.subentry_type == SUBENTRY_TYPE_ZONE
]
outputs = [
subentry.data[CONF_OUTPUT_NUMBER]
for subentry in entry.subentries.values()
if subentry.subentry_type == SUBENTRY_TYPE_OUTPUT
]
switchable_outputs = [
subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]
for subentry in entry.subentries.values()
if subentry.subentry_type == SUBENTRY_TYPE_SWITCHABLE_OUTPUT
]
monitored_outputs = outputs + switchable_outputs
self.controller = AsyncSatel(
host, port, hass.loop, zones, monitored_outputs, partitions
)
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.close)
)
async def async_connect(
self,
zones_update_callback: Callable[[dict[str, dict[int, int]]], None],
outputs_update_callback: Callable[[dict[str, dict[int, int]]], None],
partitions_update_callback: Callable[[], None],
) -> None:
"""Start controller connection."""
result = await self.controller.connect()
if not result:
raise ConfigEntryNotReady("Controller failed to connect")
self.config_entry.async_create_background_task(
self.hass,
self.controller.keep_alive(),
f"satel_integra.{self.config_entry.entry_id}.keep_alive",
eager_start=False,
)
self.config_entry.async_create_background_task(
self.hass,
self.controller.monitor_status(
partitions_update_callback,
zones_update_callback,
outputs_update_callback,
),
f"satel_integra.{self.config_entry.entry_id}.monitor_status",
eager_start=False,
)
@callback
def close(self, *args, **kwargs) -> None:
"""Close the connection."""
self.controller.close()

View File

@@ -40,8 +40,8 @@ from .const import (
SUBENTRY_TYPE_PARTITION,
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
SUBENTRY_TYPE_ZONE,
SatelConfigEntry,
)
from .coordinator import SatelConfigEntry
_LOGGER = logging.getLogger(__package__)

View File

@@ -1,9 +1,5 @@
"""Constants for the Satel Integra integration."""
from satel_integra.satel_integra import AsyncSatel
from homeassistant.config_entries import ConfigEntry
DEFAULT_CONF_ARM_HOME_MODE = 1
DEFAULT_PORT = 7094
DEFAULT_ZONE_TYPE = "motion"
@@ -28,11 +24,3 @@ CONF_OUTPUTS = "outputs"
CONF_SWITCHABLE_OUTPUTS = "switchable_outputs"
ZONES = "zones"
SIGNAL_PANEL_MESSAGE = "satel_integra.panel_message"
SIGNAL_ZONES_UPDATED = "satel_integra.zones_updated"
SIGNAL_OUTPUTS_UPDATED = "satel_integra.outputs_updated"
type SatelConfigEntry = ConfigEntry[AsyncSatel]

View File

@@ -0,0 +1,114 @@
"""Coordinator for Satel Integra."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from satel_integra.satel_integra import AlarmState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .client import SatelClient
from .const import ZONES
_LOGGER = logging.getLogger(__name__)
@dataclass
class SatelIntegraData:
"""Data for the satel_integra integration."""
client: SatelClient
coordinator_zones: SatelIntegraZonesCoordinator
coordinator_outputs: SatelIntegraOutputsCoordinator
coordinator_partitions: SatelIntegraPartitionsCoordinator
type SatelConfigEntry = ConfigEntry[SatelIntegraData]
class SatelIntegraBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""DataUpdateCoordinator base class for Satel Integra."""
config_entry: SatelConfigEntry
def __init__(
self, hass: HomeAssistant, entry: SatelConfigEntry, client: SatelClient
) -> None:
"""Initialize the base coordinator."""
self.client = client
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"{entry.entry_id} {self.__class__.__name__}",
)
class SatelIntegraZonesCoordinator(SatelIntegraBaseCoordinator[dict[int, bool]]):
"""DataUpdateCoordinator to handle zone updates."""
def __init__(
self, hass: HomeAssistant, entry: SatelConfigEntry, client: SatelClient
) -> None:
"""Initialize the coordinator."""
super().__init__(hass, entry, client)
self.data = {}
@callback
def zones_update_callback(self, status: dict[str, dict[int, int]]) -> None:
"""Update zone objects as per notification from the alarm."""
_LOGGER.debug("Zones callback, status: %s", status)
update_data = {zone: value == 1 for zone, value in status[ZONES].items()}
self.async_set_updated_data(update_data)
class SatelIntegraOutputsCoordinator(SatelIntegraBaseCoordinator[dict[int, bool]]):
"""DataUpdateCoordinator to handle output updates."""
def __init__(
self, hass: HomeAssistant, entry: SatelConfigEntry, client: SatelClient
) -> None:
"""Initialize the coordinator."""
super().__init__(hass, entry, client)
self.data = {}
@callback
def outputs_update_callback(self, status: dict[str, dict[int, int]]) -> None:
"""Update output objects as per notification from the alarm."""
_LOGGER.debug("Outputs callback, status: %s", status)
update_data = {
output: value == 1 for output, value in status["outputs"].items()
}
self.async_set_updated_data(update_data)
class SatelIntegraPartitionsCoordinator(
SatelIntegraBaseCoordinator[dict[AlarmState, list[int]]]
):
"""DataUpdateCoordinator to handle partition state updates."""
def __init__(
self, hass: HomeAssistant, entry: SatelConfigEntry, client: SatelClient
) -> None:
"""Initialize the coordinator."""
super().__init__(hass, entry, client)
self.data = {}
@callback
def partitions_update_callback(self) -> None:
"""Update partition objects as per notification from the alarm."""
_LOGGER.debug("Sending request to update panel state")
self.async_set_updated_data(self.client.controller.partition_states)

View File

@@ -9,7 +9,7 @@ from satel_integra.satel_integra import AsyncSatel
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_NAME
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DOMAIN,
@@ -18,6 +18,7 @@ from .const import (
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
SUBENTRY_TYPE_ZONE,
)
from .coordinator import SatelIntegraBaseCoordinator
SubentryTypeToEntityType: dict[str, str] = {
SUBENTRY_TYPE_PARTITION: "alarm_panel",
@@ -27,23 +28,29 @@ SubentryTypeToEntityType: dict[str, str] = {
}
class SatelIntegraEntity(Entity):
class SatelIntegraEntity[_CoordinatorT: SatelIntegraBaseCoordinator](
CoordinatorEntity[_CoordinatorT]
):
"""Defines a base Satel Integra entity."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
_controller: AsyncSatel
def __init__(
self,
controller: AsyncSatel,
coordinator: _CoordinatorT,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
) -> None:
"""Initialize the Satel Integra entity."""
super().__init__(coordinator)
self._controller = coordinator.client.controller
self._satel = controller
self._device_number = device_number
entity_type = SubentryTypeToEntityType[subentry.subentry_type]

View File

@@ -4,21 +4,14 @@ from __future__ import annotations
from typing import Any
from satel_integra.satel_integra import AsyncSatel
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_CODE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_SWITCHABLE_OUTPUT_NUMBER,
SIGNAL_OUTPUTS_UPDATED,
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
SatelConfigEntry,
)
from .const import CONF_SWITCHABLE_OUTPUT_NUMBER, SUBENTRY_TYPE_SWITCHABLE_OUTPUT
from .coordinator import SatelConfigEntry, SatelIntegraOutputsCoordinator
from .entity import SatelIntegraEntity
@@ -29,7 +22,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Satel Integra switch devices."""
controller = config_entry.runtime_data
runtime_data = config_entry.runtime_data
switchable_output_subentries = filter(
lambda entry: entry.subentry_type == SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
@@ -42,7 +35,7 @@ async def async_setup_entry(
async_add_entities(
[
SatelIntegraSwitch(
controller,
runtime_data.coordinator_outputs,
config_entry.entry_id,
subentry,
switchable_output_num,
@@ -53,12 +46,14 @@ async def async_setup_entry(
)
class SatelIntegraSwitch(SatelIntegraEntity, SwitchEntity):
class SatelIntegraSwitch(
SatelIntegraEntity[SatelIntegraOutputsCoordinator], SwitchEntity
):
"""Representation of an Satel Integra switch."""
def __init__(
self,
controller: AsyncSatel,
coordinator: SatelIntegraOutputsCoordinator,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
@@ -66,7 +61,7 @@ class SatelIntegraSwitch(SatelIntegraEntity, SwitchEntity):
) -> None:
"""Initialize the switch."""
super().__init__(
controller,
coordinator,
config_entry_id,
subentry,
device_number,
@@ -74,33 +69,28 @@ class SatelIntegraSwitch(SatelIntegraEntity, SwitchEntity):
self._code = code
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self._attr_is_on = self._device_number in self._satel.violated_outputs
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_OUTPUTS_UPDATED, self._devices_updated
)
)
self._attr_is_on = self._get_state_from_coordinator()
@callback
def _devices_updated(self, outputs: dict[int, int]) -> None:
"""Update switch state, if needed."""
if self._device_number in outputs:
new_state = outputs[self._device_number] == 1
if new_state != self._attr_is_on:
self._attr_is_on = new_state
self.async_write_ha_state()
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
new_state = self._get_state_from_coordinator()
if new_state != self._attr_is_on:
self._attr_is_on = new_state
self.async_write_ha_state()
def _get_state_from_coordinator(self) -> bool | None:
"""Method to get switch state from coordinator data."""
return self.coordinator.data.get(self._device_number)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
await self._satel.set_output(self._code, self._device_number, True)
await self._controller.set_output(self._code, self._device_number, True)
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
await self._satel.set_output(self._code, self._device_number, False)
await self._controller.set_output(self._code, self._device_number, False)
self._attr_is_on = False
self.async_write_ha_state()

View File

@@ -1,5 +1,10 @@
"""The tests for Satel Integra integration."""
from collections.abc import Callable
from unittest.mock import AsyncMock
import pytest
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.satel_integra import (
CONF_ARM_HOME_MODE,
@@ -80,3 +85,19 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
def get_monitor_callbacks(
mock_satel: AsyncMock,
) -> tuple[
Callable[[], None],
Callable[[dict[str, dict[int, int]]], None],
Callable[[dict[str, dict[int, int]]], None],
]:
"""Return (partitions_cb, zones_cb, outputs_cb) passed to monitor_status."""
if not mock_satel.monitor_status.call_args_list:
pytest.fail("monitor_status was not called")
call = mock_satel.monitor_status.call_args_list[-1]
partitions_cb, zones_cb, outputs_cb = call.args
return partitions_cb, zones_cb, outputs_cb

View File

@@ -36,7 +36,7 @@ def mock_satel() -> Generator[AsyncMock]:
"""Override the satel test."""
with (
patch(
"homeassistant.components.satel_integra.AsyncSatel",
"homeassistant.components.satel_integra.client.AsyncSatel",
autospec=True,
) as mock_client,
patch(
@@ -45,12 +45,22 @@ def mock_satel() -> Generator[AsyncMock]:
),
):
client = mock_client.return_value
client.partition_states = {}
client.violated_outputs = []
client.violated_zones = []
client.connect = AsyncMock(return_value=True)
client.set_output = AsyncMock()
# Immediately push baseline values so entities have stable states for snapshots
async def _monitor_status(partitions_cb, zones_cb, outputs_cb):
partitions_cb()
zones_cb({"zones": {1: 0}})
outputs_cb({"outputs": {1: 0}})
client.monitor_status = AsyncMock(side_effect=_monitor_status)
yield client

View File

@@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceRegistry
from homeassistant.helpers.entity_registry import EntityRegistry
from . import MOCK_CODE, MOCK_ENTRY_ID, setup_integration
from . import MOCK_CODE, MOCK_ENTRY_ID, get_monitor_callbacks, setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@@ -59,7 +59,7 @@ async def test_alarm_control_panel(
assert device_entry == snapshot(name="device")
async def test_alarm_control_panel_initial_state_on(
async def test_alarm_control_panel_initial_state(
hass: HomeAssistant,
mock_satel: AsyncMock,
mock_config_entry_with_subentries: MockConfigEntry,
@@ -104,8 +104,7 @@ async def test_alarm_status_callback(
== AlarmControlPanelState.DISARMED
)
monitor_status_call = mock_satel.monitor_status.call_args_list[0][0]
alarm_panel_update_method = monitor_status_call[0]
alarm_panel_update_method, _, _ = get_monitor_callbacks(mock_satel)
mock_satel.partition_states = {source_state: [1]}

View File

@@ -6,15 +6,14 @@ from unittest.mock import AsyncMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.binary_sensor import STATE_OFF, STATE_ON
from homeassistant.components.satel_integra.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceRegistry
from homeassistant.helpers.entity_registry import EntityRegistry
from . import setup_integration
from . import get_monitor_callbacks, setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@@ -58,19 +57,36 @@ async def test_binary_sensors(
assert device_entry == snapshot(name="device-output")
async def test_binary_sensor_initial_state_on(
@pytest.mark.parametrize(
("violated_entries", "expected_state"),
[
({2: 1}, STATE_UNKNOWN),
({1: 0}, STATE_OFF),
({1: 1}, STATE_ON),
],
)
async def test_binary_sensor_initial_state(
hass: HomeAssistant,
mock_satel: AsyncMock,
mock_config_entry_with_subentries: MockConfigEntry,
violated_entries: dict[int, int],
expected_state: str,
) -> None:
"""Test binary sensors have a correct initial state ON after initialization."""
mock_satel.violated_zones = [1]
mock_satel.violated_outputs = [1]
"""Test binary sensors have a correct initial state after initialization."""
# Instantly call callback to ensure we have initial data set
async def mock_monitor_callback(
alarm_status_callback, zones_callback, outputs_callback
):
outputs_callback({"outputs": violated_entries})
zones_callback({"zones": violated_entries})
mock_satel.monitor_status = AsyncMock(side_effect=mock_monitor_callback)
await setup_integration(hass, mock_config_entry_with_subentries)
assert hass.states.get("binary_sensor.zone").state == STATE_ON
assert hass.states.get("binary_sensor.output").state == STATE_ON
assert hass.states.get("binary_sensor.zone").state == expected_state
assert hass.states.get("binary_sensor.output").state == expected_state
async def test_binary_sensor_callback(
@@ -84,19 +100,20 @@ async def test_binary_sensor_callback(
assert hass.states.get("binary_sensor.zone").state == STATE_OFF
assert hass.states.get("binary_sensor.output").state == STATE_OFF
monitor_status_call = mock_satel.monitor_status.call_args_list[0][0]
output_update_method = monitor_status_call[2]
zone_update_method = monitor_status_call[1]
# Should do nothing, only react to it's own number
output_update_method({"outputs": {2: 1}})
zone_update_method({"zones": {2: 1}})
assert hass.states.get("binary_sensor.zone").state == STATE_OFF
assert hass.states.get("binary_sensor.output").state == STATE_OFF
_, zone_update_method, output_update_method = get_monitor_callbacks(mock_satel)
output_update_method({"outputs": {1: 1}})
zone_update_method({"zones": {1: 1}})
assert hass.states.get("binary_sensor.zone").state == STATE_ON
assert hass.states.get("binary_sensor.output").state == STATE_ON
output_update_method({"outputs": {1: 0}})
zone_update_method({"zones": {1: 0}})
assert hass.states.get("binary_sensor.zone").state == STATE_OFF
assert hass.states.get("binary_sensor.output").state == STATE_OFF
# The client library should always report all entries, but test that we set the status correctly if it doesn't
output_update_method({"outputs": {2: 1}})
zone_update_method({"zones": {2: 1}})
assert hass.states.get("binary_sensor.zone").state == STATE_UNKNOWN
assert hass.states.get("binary_sensor.output").state == STATE_UNKNOWN

View File

@@ -6,19 +6,24 @@ from unittest.mock import AsyncMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.binary_sensor import STATE_OFF, STATE_ON
from homeassistant.components.satel_integra.const import DOMAIN
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.const import (
ATTR_ENTITY_ID,
STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceRegistry
from homeassistant.helpers.entity_registry import EntityRegistry
from . import MOCK_CODE, MOCK_ENTRY_ID, setup_integration
from . import MOCK_CODE, MOCK_ENTRY_ID, get_monitor_callbacks, setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@@ -53,17 +58,34 @@ async def test_switches(
assert device_entry == snapshot(name="device")
async def test_switch_initial_state_on(
@pytest.mark.parametrize(
("violated_outputs", "expected_state"),
[
({2: 1}, STATE_UNKNOWN),
({1: 0}, STATE_OFF),
({1: 1}, STATE_ON),
],
)
async def test_switch_initial_state(
hass: HomeAssistant,
mock_satel: AsyncMock,
mock_config_entry_with_subentries: MockConfigEntry,
violated_outputs: dict[int, int],
expected_state: str,
) -> None:
"""Test switch has a correct initial state ON after initialization."""
mock_satel.violated_outputs = [1]
"""Test switch has a correct initial state after initialization."""
# Instantly call callback to ensure we have initial data set
async def mock_monitor_callback(
alarm_status_callback, zones_callback, outputs_callback
):
outputs_callback({"outputs": violated_outputs})
mock_satel.monitor_status = AsyncMock(side_effect=mock_monitor_callback)
await setup_integration(hass, mock_config_entry_with_subentries)
assert hass.states.get("switch.switchable_output").state == STATE_ON
assert hass.states.get("switch.switchable_output").state == expected_state
async def test_switch_callback(
@@ -76,16 +98,18 @@ async def test_switch_callback(
assert hass.states.get("switch.switchable_output").state == STATE_OFF
monitor_status_call = mock_satel.monitor_status.call_args_list[0][0]
output_update_method = monitor_status_call[2]
# Should do nothing, only react to it's own number
output_update_method({"outputs": {2: 1}})
assert hass.states.get("switch.switchable_output").state == STATE_OFF
_, _, output_update_method = get_monitor_callbacks(mock_satel)
output_update_method({"outputs": {1: 1}})
assert hass.states.get("switch.switchable_output").state == STATE_ON
output_update_method({"outputs": {1: 0}})
assert hass.states.get("switch.switchable_output").state == STATE_OFF
# The client library should always report all entries, but test that we set the status correctly if it doesn't
output_update_method({"outputs": {2: 1}})
assert hass.states.get("switch.switchable_output").state == STATE_UNKNOWN
async def test_switch_change_state(
hass: HomeAssistant,