diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py new file mode 100644 index 00000000000000..52b7109045cf78 --- /dev/null +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -0,0 +1,138 @@ +"""Support for Freebox alarms.""" +import logging +from typing import Any + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, FreeboxHomeCategory +from .home_base import FreeboxHomeEntity +from .router import FreeboxRouter + +FREEBOX_TO_STATUS = { + "alarm1_arming": STATE_ALARM_ARMING, + "alarm2_arming": STATE_ALARM_ARMING, + "alarm1_armed": STATE_ALARM_ARMED_AWAY, + "alarm2_armed": STATE_ALARM_ARMED_NIGHT, + "alarm1_alert_timer": STATE_ALARM_TRIGGERED, + "alarm2_alert_timer": STATE_ALARM_TRIGGERED, + "alert": STATE_ALARM_TRIGGERED, +} + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up alarm panel.""" + router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + + alarm_entities: list[AlarmControlPanelEntity] = [] + + for node in router.home_devices.values(): + if node["category"] == FreeboxHomeCategory.ALARM: + alarm_entities.append(FreeboxAlarm(hass, router, node)) + + if alarm_entities: + async_add_entities(alarm_entities, True) + + +class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity): + """Representation of a Freebox alarm.""" + + def __init__( + self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] + ) -> None: + """Initialize an alarm.""" + super().__init__(hass, router, node) + + # Commands + self._command_trigger = self.get_command_id( + node["type"]["endpoints"], "slot", "trigger" + ) + self._command_arm_away = self.get_command_id( + node["type"]["endpoints"], "slot", "alarm1" + ) + self._command_arm_home = self.get_command_id( + node["type"]["endpoints"], "slot", "alarm2" + ) + self._command_disarm = self.get_command_id( + node["type"]["endpoints"], "slot", "off" + ) + self._command_state = self.get_command_id( + node["type"]["endpoints"], "signal", "state" + ) + self._set_features(self._router.home_devices[self._id]) + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + if await self.set_home_endpoint_value(self._command_disarm): + self._set_state(STATE_ALARM_DISARMED) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + if await self.set_home_endpoint_value(self._command_arm_away): + self._set_state(STATE_ALARM_ARMING) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + if await self.set_home_endpoint_value(self._command_arm_home): + self._set_state(STATE_ALARM_ARMING) + + async def async_alarm_trigger(self, code: str | None = None) -> None: + """Send alarm trigger command.""" + if await self.set_home_endpoint_value(self._command_trigger): + self._set_state(STATE_ALARM_TRIGGERED) + + async def async_update_signal(self): + """Update signal.""" + state = await self.get_home_endpoint_value(self._command_state) + if state: + self._set_state(state) + + def _set_features(self, node: dict[str, Any]) -> None: + """Add alarm features.""" + # Search if the arm home feature is present => has an "alarm2" endpoint + can_arm_home = False + for nodeid, local_node in self._router.home_devices.items(): + if nodeid == local_node["id"]: + alarm2 = next( + filter( + lambda x: (x["name"] == "alarm2" and x["ep_type"] == "signal"), + local_node["show_endpoints"], + ), + None, + ) + if alarm2: + can_arm_home = alarm2["value"] + break + + if can_arm_home: + self._attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_HOME + ) + + else: + self._attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + + def _set_state(self, state: str) -> None: + """Update state.""" + self._attr_state = FREEBOX_TO_STATUS.get(state) + if not self._attr_state: + self._attr_state = STATE_ALARM_DISARMED + self.async_write_ha_state() diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 0c3450d13b628d..f74f6f49ebf1e7 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -18,6 +18,7 @@ API_VERSION = "v6" PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, @@ -84,6 +85,7 @@ class FreeboxHomeCategory(enum.StrEnum): } HOME_COMPATIBLE_CATEGORIES = [ + FreeboxHomeCategory.ALARM, FreeboxHomeCategory.CAMERA, FreeboxHomeCategory.DWS, FreeboxHomeCategory.IOHOME, diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 63bc1d76d1a191..5d1b6fab0c8d33 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -10,7 +10,7 @@ DATA_CALL_GET_CALLS_LOG, DATA_CONNECTION_GET_STATUS, DATA_HOME_GET_NODES, - DATA_HOME_GET_VALUES, + DATA_HOME_PIR_GET_VALUES, DATA_LAN_GET_HOSTS_LIST, DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_RAIDS, @@ -81,7 +81,7 @@ def mock_router(mock_device_registry_devices): # home devices instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) instance.home.get_home_endpoint_value = AsyncMock( - return_value=DATA_HOME_GET_VALUES + return_value=DATA_HOME_PIR_GET_VALUES ) instance.close = AsyncMock() yield service_mock diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 788310bdbc0ac2..0cd854b22bf806 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -515,7 +515,7 @@ # Home # PIR node id 26, endpoint id 6 -DATA_HOME_GET_VALUES = { +DATA_HOME_PIR_GET_VALUES = { "category": "", "ep_type": "signal", "id": 6, @@ -527,6 +527,15 @@ "visibility": "normal", } +# Home +# ALARM node id 7, endpoint id 11 +DATA_HOME_ALARM_GET_VALUES = { + "refresh": 2000, + "value": "alarm2_armed", + "value_type": "string", +} + + # Home # ALL DATA_HOME_GET_NODES = [ @@ -2526,4 +2535,354 @@ "inherit": "node::ios", }, }, + { + "adapter": 5, + "category": "alarm", + "group": {"label": ""}, + "id": 7, + "label": "Système d'alarme", + "name": "node_7", + "props": { + "Address": 3, + "Challenge": "447599f5cab8620122b913e55faf8e1d", + "FwVersion": 47396239, + "Gateway": 1, + "ItemId": "e515a55b04f32e6d", + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Code PIN", + "name": "pin", + "ui": {...}, + "value": "", + "value_type": "string", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Puissance des bips", + "name": "sound", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Puissance de la sirène", + "name": "volume", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 8, + "label": "Délai avant armement", + "name": "timeout1", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 9, + "label": "Délai avant sirène", + "name": "timeout2", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 10, + "label": "Durée de la sirène", + "name": "timeout3", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 12, + "label": "Code PIN", + "name": "pin", + "refresh": 2000, + "ui": {...}, + "value": "0000", + "value_type": "string", + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Puissance des bips", + "name": "sound", + "refresh": 2000, + "ui": {...}, + "value": 1, + "value_type": "int", + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Puissance de la sirène", + "name": "volume", + "refresh": 2000, + "ui": {...}, + "value": 100, + "value_type": "int", + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 16, + "label": "Délai avant armement", + "name": "timeout1", + "refresh": 2000, + "ui": {...}, + "value": 15, + "value_type": "int", + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 17, + "label": "Délai avant sirène", + "name": "timeout2", + "refresh": 2000, + "ui": {...}, + "value": 15, + "value_type": "int", + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 18, + "label": "Durée de la sirène", + "name": "timeout3", + "refresh": 2000, + "ui": {...}, + "value": 300, + "value_type": "int", + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "ui": {...}, + "value": 85, + "value_type": "int", + }, + ], + "type": { + "abstract": False, + "endpoints": [ + { + "ep_type": "slot", + "id": 0, + "label": "Trigger", + "name": "trigger", + "value_type": "void", + "visibility": "internal", + }, + { + "ep_type": "slot", + "id": 1, + "label": "Alarme principale", + "name": "alarm1", + "value_type": "void", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 2, + "label": "Alarme secondaire", + "name": "alarm2", + "value_type": "void", + "visibility": "internal", + }, + { + "ep_type": "slot", + "id": 3, + "label": "Passer le délai", + "name": "skip", + "value_type": "void", + "visibility": "internal", + }, + { + "ep_type": "slot", + "id": 4, + "label": "Désactiver l'alarme", + "name": "off", + "value_type": "void", + "visibility": "internal", + }, + { + "ep_type": "slot", + "id": 5, + "label": "Code PIN", + "name": "pin", + "value_type": "string", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 6, + "label": "Puissance des bips", + "name": "sound", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 7, + "label": "Puissance de la sirène", + "name": "volume", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 8, + "label": "Délai avant armement", + "name": "timeout1", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 9, + "label": "Délai avant sirène", + "name": "timeout2", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 10, + "label": "Durée de la sirène", + "name": "timeout3", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 11, + "label": "État de l'alarme", + "name": "state", + "param_type": "void", + "value_type": "string", + "visibility": "internal", + }, + { + "ep_type": "signal", + "id": 12, + "label": "Code PIN", + "name": "pin", + "param_type": "void", + "value_type": "string", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 13, + "label": "Erreur", + "name": "error", + "param_type": "void", + "value_type": "string", + "visibility": "internal", + }, + { + "ep_type": "signal", + "id": 14, + "label": "Puissance des bips", + "name": "sound", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 15, + "label": "Puissance de la sirène", + "name": "volume", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 16, + "label": "Délai avant armement", + "name": "timeout1", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 17, + "label": "Délai avant sirène", + "name": "timeout2", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 18, + "label": "Durée de la sirène", + "name": "timeout3", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 19, + "label": "Niveau de Batterie", + "name": "battery", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 20, + "label": "Batterie faible", + "name": "battery_warning", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + ], + "generic": False, + "icon": "/resources/images/home/pictos/alarm_system.png", + "inherit": "node::domus", + "label": "Système d'alarme", + "name": "node::domus::freebox::secmod", + "params": {}, + "physical": True, + }, + }, ] diff --git a/tests/components/freebox/test_alarm_control_panel.py b/tests/components/freebox/test_alarm_control_panel.py new file mode 100644 index 00000000000000..d24c747f2a3fbb --- /dev/null +++ b/tests/components/freebox/test_alarm_control_panel.py @@ -0,0 +1,123 @@ +"""Tests for the Freebox sensors.""" +from copy import deepcopy +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL, + AlarmControlPanelEntityFeature, +) +from homeassistant.components.freebox import SCAN_INTERVAL +from homeassistant.const import ( + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.state import async_reproduce_state + +from .common import setup_platform +from .const import DATA_HOME_ALARM_GET_VALUES + +from tests.common import async_fire_time_changed, async_mock_service + + +async def test_panel( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test home binary sensors.""" + await setup_platform(hass, ALARM_CONTROL_PANEL) + + # Initial state + assert hass.states.get("alarm_control_panel.systeme_d_alarme").state == "unknown" + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").attributes[ + "supported_features" + ] + == AlarmControlPanelEntityFeature.ARM_AWAY + ) + + # Now simulate a changed status + data_get_home_endpoint_value = deepcopy(DATA_HOME_ALARM_GET_VALUES) + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state == "armed_night" + ) + # Fake that the entity is triggered. + hass.states.async_set("alarm_control_panel.systeme_d_alarme", STATE_ALARM_DISARMED) + assert hass.states.get("alarm_control_panel.systeme_d_alarme").state == "disarmed" + + +async def test_reproducing_states( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test reproducing Alarm control panel states.""" + hass.states.async_set( + "alarm_control_panel.entity_armed_away", STATE_ALARM_ARMED_AWAY, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_armed_custom_bypass", + STATE_ALARM_ARMED_CUSTOM_BYPASS, + {}, + ) + hass.states.async_set( + "alarm_control_panel.entity_armed_home", STATE_ALARM_ARMED_HOME, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_NIGHT, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_VACATION, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_disarmed", STATE_ALARM_DISARMED, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_triggered", STATE_ALARM_TRIGGERED, {} + ) + + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_AWAY) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_CUSTOM_BYPASS) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_HOME) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_NIGHT) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_VACATION) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_DISARM) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_TRIGGER) + + # These calls should do nothing as entities already in desired state + await async_reproduce_state( + hass, + [ + State("alarm_control_panel.entity_armed_away", STATE_ALARM_ARMED_AWAY), + State( + "alarm_control_panel.entity_armed_custom_bypass", + STATE_ALARM_ARMED_CUSTOM_BYPASS, + ), + State("alarm_control_panel.entity_armed_home", STATE_ALARM_ARMED_HOME), + State("alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_NIGHT), + State( + "alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_VACATION + ), + State("alarm_control_panel.entity_disarmed", STATE_ALARM_DISARMED), + State("alarm_control_panel.entity_triggered", STATE_ALARM_TRIGGERED), + ], + ) diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index b37d6a3c72c1b4..2fd308ea667c49 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_HOME_GET_VALUES, DATA_STORAGE_GET_RAIDS +from .const import DATA_HOME_PIR_GET_VALUES, DATA_STORAGE_GET_RAIDS from tests.common import async_fire_time_changed @@ -73,7 +73,7 @@ async def test_home( assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "off" # Now simulate a changed status - data_home_get_values_changed = deepcopy(DATA_HOME_GET_VALUES) + data_home_get_values_changed = deepcopy(DATA_HOME_PIR_GET_VALUES) data_home_get_values_changed["value"] = True router().home.get_home_endpoint_value.return_value = data_home_get_values_changed