Add Freebox Home alarm panel (#102607)

* add alarm control panel

* optimize update node

* Modify comment

* move const to alarm

* add alarm panel tests

* tests modified

* add file into coveragerc

* Review: DATA_HOME_GET_VALUES -> DATA_HOME_PIR_GET_VALUES

* Review: commands rename

* Review: precise what "alarm2" is for features

* Review: remove custom attributes & properties that exists in parent

* Review: Avoid duplicates of async_write_ha_state()

* make functions private

* Review: initial state never works

* Review: remove extra attrs

* Review: fix tests

* Fix tests

* Remove line in .coveragerc

---------

Co-authored-by: Quentame <polletquentin74@me.com>
pull/102857/head
nachonam 2023-10-26 14:35:51 +02:00 committed by GitHub
parent 9ea97fd8d2
commit cf03f8338a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 627 additions and 5 deletions

View File

@ -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()

View File

@ -18,6 +18,7 @@ APP_DESC = {
API_VERSION = "v6"
PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BUTTON,
Platform.DEVICE_TRACKER,
Platform.SENSOR,
@ -84,6 +85,7 @@ CATEGORY_TO_MODEL = {
}
HOME_COMPATIBLE_CATEGORIES = [
FreeboxHomeCategory.ALARM,
FreeboxHomeCategory.CAMERA,
FreeboxHomeCategory.DWS,
FreeboxHomeCategory.IOHOME,

View File

@ -10,7 +10,7 @@ from .const import (
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

View File

@ -515,7 +515,7 @@ DATA_LAN_GET_HOSTS_LIST = [
# 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 @@ DATA_HOME_GET_VALUES = {
"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 @@ DATA_HOME_GET_NODES = [
"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,
},
},
]

View File

@ -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),
],
)

View File

@ -13,7 +13,7 @@ from homeassistant.const import ATTR_DEVICE_CLASS
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