From 941f6bef3c50e8d870b6de24abce3fba5e76f930 Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Thu, 27 Apr 2023 09:12:31 +0200 Subject: [PATCH 1/9] Empty alarm panel --- custom_components/ksenia_lares/__init__.py | 3 +- .../ksenia_lares/alarm_control_panel.py | 70 +++++++++++++++++++ custom_components/ksenia_lares/base.py | 1 + custom_components/ksenia_lares/const.py | 7 +- custom_components/ksenia_lares/sensor.py | 28 ++++++-- 5 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 custom_components/ksenia_lares/alarm_control_panel.py diff --git a/custom_components/ksenia_lares/__init__.py b/custom_components/ksenia_lares/__init__.py index b675c84..52c0df4 100644 --- a/custom_components/ksenia_lares/__init__.py +++ b/custom_components/ksenia_lares/__init__.py @@ -5,13 +5,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.const import Platform from .base import LaresBase from .coordinator import LaresDataUpdateCoordinator from .const import DOMAIN CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) -PLATFORMS = ["binary_sensor", "sensor"] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.ALARM_CONTROL_PANEL] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): diff --git a/custom_components/ksenia_lares/alarm_control_panel.py b/custom_components/ksenia_lares/alarm_control_panel.py new file mode 100644 index 0000000..26330c1 --- /dev/null +++ b/custom_components/ksenia_lares/alarm_control_panel.py @@ -0,0 +1,70 @@ +"""This component provides support for Lares alarm control panel.""" +import logging +from datetime import timedelta + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + CodeFormat, +) + +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=10) + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up alarm control panel of the Lares alarm device from a config entry.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + device_info = await coordinator.client.device_info() + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + async_add_devices([LaresAlarmControlPanel(coordinator, device_info)]) + + +class LaresAlarmControlPanel(CoordinatorEntity, AlarmControlPanelEntity): + """An implementation of a Lares alarm control panel.""" + + TYPE = DOMAIN + + def __init__(self, coordinator, device_info) -> None: + """Initialize a the switch.""" + super().__init__(coordinator) + + self._cordinator = coordinator + self._attr_code_format = CodeFormat.NUMBER + self._attr_device_info = device_info + self._attr_code_arm_required = False + self._attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_HOME + ) + + @property + def unique_id(self) -> str: + """Return the unique ID for this entity.""" + name = self._attr_device_info["name"].replace(" ", "_") + return f"lares_panel_{name}" + + @property + def name(self): + """Return the name of this panel.""" + name = self._attr_device_info["name"] + return f"Panel {name}" + + @property + def state(self) -> StateType: + return STATE_ALARM_DISARMED diff --git a/custom_components/ksenia_lares/base.py b/custom_components/ksenia_lares/base.py index 2a05644..3250604 100644 --- a/custom_components/ksenia_lares/base.py +++ b/custom_components/ksenia_lares/base.py @@ -149,3 +149,4 @@ async def get(self, path): _LOGGER.debug("Host %s: Connection error %s", self._host, str(conn_err)) except: # pylint: disable=bare-except _LOGGER.debug("Host %s: Unknown exception occurred", self._host) + return diff --git a/custom_components/ksenia_lares/const.py b/custom_components/ksenia_lares/const.py index dd4817d..dbbbe93 100644 --- a/custom_components/ksenia_lares/const.py +++ b/custom_components/ksenia_lares/const.py @@ -14,4 +14,9 @@ ZONE_BYPASS_OFF = "UN_BYPASS" ZONE_BYPASS_ON = "BYPASS" -DISARMED_STATE = "DISARMED" +PARTITION_STATUS_DISARMED = "DISARMED" +PARTITION_STATUS_ARMED = "ARMED" +PARTITION_STATUS_ARMED_IMMEDIATE = "ARMED_IMMEDIATE" +PARTITION_STATUS_ARMING = "EXIT" +PARTITION_STATUS_PENDING = "PREALARM" +PARTITION_STATUS_ALARM = "ALARM" diff --git a/custom_components/ksenia_lares/sensor.py b/custom_components/ksenia_lares/sensor.py index 2fb514a..a60674f 100644 --- a/custom_components/ksenia_lares/sensor.py +++ b/custom_components/ksenia_lares/sensor.py @@ -1,9 +1,7 @@ """This component provides support for Lares partitions.""" from datetime import timedelta -import logging - -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorDeviceClass from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, ) @@ -12,12 +10,18 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, DATA_PARTITIONS, ZONE_STATUS_NOT_USED - -_LOGGER = logging.getLogger(__name__) +from .const import ( + DOMAIN, + DATA_PARTITIONS, + PARTITION_STATUS_DISARMED, + PARTITION_STATUS_ARMED, + PARTITION_STATUS_ARMED_IMMEDIATE, + PARTITION_STATUS_ARMING, + PARTITION_STATUS_PENDING, + PARTITION_STATUS_ALARM, +) SCAN_INTERVAL = timedelta(seconds=10) - DEFAULT_DEVICE_CLASS = "motion" @@ -52,7 +56,17 @@ def __init__(self, coordinator, idx, description, device_info) -> None: self._description = description self._idx = idx + self._attr_icon = "mdi:shield" self._attr_device_info = device_info + self._attr_device_class = SensorDeviceClass.ENUM + self._attr_options = [ + PARTITION_STATUS_DISARMED, + PARTITION_STATUS_ARMED, + PARTITION_STATUS_ARMED_IMMEDIATE, + PARTITION_STATUS_ARMING, + PARTITION_STATUS_PENDING, + PARTITION_STATUS_ALARM, + ] # Hide sensor if it has no description is_inactive = not self._description From 9230420a394c280e6fc403f163393589c9480df4 Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Mon, 1 May 2023 09:42:31 +0200 Subject: [PATCH 2/9] Add options flow --- .../ksenia_lares/alarm_control_panel.py | 5 +- custom_components/ksenia_lares/config_flow.py | 72 +++++++++++++++++-- custom_components/ksenia_lares/const.py | 4 ++ custom_components/ksenia_lares/strings.json | 17 +++++ .../ksenia_lares/translations/en.json | 17 +++++ 5 files changed, 104 insertions(+), 11 deletions(-) diff --git a/custom_components/ksenia_lares/alarm_control_panel.py b/custom_components/ksenia_lares/alarm_control_panel.py index 26330c1..8271e29 100644 --- a/custom_components/ksenia_lares/alarm_control_panel.py +++ b/custom_components/ksenia_lares/alarm_control_panel.py @@ -48,10 +48,7 @@ def __init__(self, coordinator, device_info) -> None: self._attr_code_format = CodeFormat.NUMBER self._attr_device_info = device_info self._attr_code_arm_required = False - self._attr_supported_features = ( - AlarmControlPanelEntityFeature.ARM_AWAY - | AlarmControlPanelEntityFeature.ARM_HOME - ) + self._attr_supported_features = () @property def unique_id(self) -> str: diff --git a/custom_components/ksenia_lares/config_flow.py b/custom_components/ksenia_lares/config_flow.py index 22575a0..45e8e43 100644 --- a/custom_components/ksenia_lares/config_flow.py +++ b/custom_components/ksenia_lares/config_flow.py @@ -1,12 +1,26 @@ """Config flow for Ksenia Lares Alarm integration.""" import logging +from typing import Any import voluptuous as vol - -from homeassistant import config_entries, core, exceptions +import homeassistant.helpers.config_validation as cv + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlow, + FlowResult, +) +from homeassistant.core import callback, HomeAssistant from .base import LaresBase -from .const import DOMAIN +from .const import ( + DOMAIN, + CONF_PARTITION_AWAY, + CONF_PARTITION_HOME, + CONF_PARTITION_NIGHT, +) _LOGGER = logging.getLogger(__name__) @@ -19,7 +33,7 @@ ) -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input(hass: HomeAssistant, data): """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. @@ -35,11 +49,19 @@ async def validate_input(hass: core.HomeAssistant, data): return {"title": info["name"], "id": info["id"]} -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class LaresConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ksenia Lares Alarm.""" VERSION = 1 + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlow: + """Return the options flow.""" + return LaresOptionsFlowHandler(config_entry) + async def async_step_user(self, user_input=None): """Handle the initial step.""" if user_input is None: @@ -70,9 +92,45 @@ async def async_step_user(self, user_input=None): ) -class CannotConnect(exceptions.HomeAssistantError): +class LaresOptionsFlowHandler(OptionsFlow): + """Handle a options flow for Ksenia Lares Alarm.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + self.client = LaresBase(config_entry.data) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + partitions = await self.client.partition_descriptions() + select_partitions = {v: v for v in list(filter(None, partitions)) if v != ""} + + options = { + vol.Required( + CONF_PARTITION_AWAY, + default=self.config_entry.options.get(CONF_PARTITION_AWAY, []), + ): cv.multi_select(select_partitions), + vol.Optional( + CONF_PARTITION_HOME, + default=self.config_entry.options.get(CONF_PARTITION_HOME, []), + ): cv.multi_select(select_partitions), + vol.Optional( + CONF_PARTITION_NIGHT, + default=self.config_entry.options.get(CONF_PARTITION_NIGHT, []), + ): cv.multi_select(select_partitions), + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + + +class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" -class InvalidAuth(exceptions.HomeAssistantError): +class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/custom_components/ksenia_lares/const.py b/custom_components/ksenia_lares/const.py index dbbbe93..bcd595e 100644 --- a/custom_components/ksenia_lares/const.py +++ b/custom_components/ksenia_lares/const.py @@ -20,3 +20,7 @@ PARTITION_STATUS_ARMING = "EXIT" PARTITION_STATUS_PENDING = "PREALARM" PARTITION_STATUS_ALARM = "ALARM" + +CONF_PARTITION_AWAY = "partition_away" +CONF_PARTITION_HOME = "partition_home" +CONF_PARTITION_NIGHT = "partition_night" diff --git a/custom_components/ksenia_lares/strings.json b/custom_components/ksenia_lares/strings.json index 365dd30..45abae2 100644 --- a/custom_components/ksenia_lares/strings.json +++ b/custom_components/ksenia_lares/strings.json @@ -18,5 +18,22 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "options": { + "step": { + "init": { + "description": "Link partitions to states, the state will be set when the selected partitions are armed", + "data": { + "partition_away": "Away paritions", + "partition_home": "Home partitions", + "partition_night": "Night partitions" + }, + "data_description": { + "partition_away": "Select all partitions that need to armed for away state", + "partition_home": "Select all partitions that need to armed for home state", + "partition_night": "Select all partitions that need to armed for night state" + } + } + } } } \ No newline at end of file diff --git a/custom_components/ksenia_lares/translations/en.json b/custom_components/ksenia_lares/translations/en.json index a3f8335..82de136 100644 --- a/custom_components/ksenia_lares/translations/en.json +++ b/custom_components/ksenia_lares/translations/en.json @@ -18,5 +18,22 @@ } } }, + "options": { + "step": { + "init": { + "description": "Link partitions to states, the state will be set when the selected partitions are armed", + "data": { + "partition_away": "Away paritions", + "partition_home": "Home partitions", + "partition_night": "Night partitions" + }, + "data_description": { + "partition_away": "Select all partitions that need to armed for away state", + "partition_home": "Select all partitions that need to armed for home state", + "partition_night": "Select all partitions that need to armed for night state" + } + } + } + }, "title": "Ksenia Lares Alarm" } \ No newline at end of file From 37a51d3d34c451d8be9051455438bdbbc83ed123 Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Mon, 1 May 2023 09:53:37 +0200 Subject: [PATCH 3/9] Fixes for Hassfest --- custom_components/ksenia_lares/manifest.json | 1 + custom_components/ksenia_lares/translations/en.json | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/ksenia_lares/manifest.json b/custom_components/ksenia_lares/manifest.json index a1a6ab3..d02a3a8 100644 --- a/custom_components/ksenia_lares/manifest.json +++ b/custom_components/ksenia_lares/manifest.json @@ -3,6 +3,7 @@ "name": "Ksenia Lares Alarm", "version": "1.1.0", "config_flow": true, + "iot_class": "local_polling", "documentation": "https://www.home-assistant.io/integrations/ksenia_lares", "requirements": [ "lxml==4.9.1", diff --git a/custom_components/ksenia_lares/translations/en.json b/custom_components/ksenia_lares/translations/en.json index 82de136..ee0c07a 100644 --- a/custom_components/ksenia_lares/translations/en.json +++ b/custom_components/ksenia_lares/translations/en.json @@ -34,6 +34,5 @@ } } } - }, - "title": "Ksenia Lares Alarm" + } } \ No newline at end of file From e7ef1e21e6de91981cf7d579a1a3d91c46905ec9 Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Mon, 1 May 2023 11:51:06 +0200 Subject: [PATCH 4/9] Alarm status based on partition mapping --- custom_components/ksenia_lares/__init__.py | 18 +++- .../ksenia_lares/alarm_control_panel.py | 102 ++++++++++++++++-- .../ksenia_lares/binary_sensor.py | 3 +- custom_components/ksenia_lares/const.py | 3 + custom_components/ksenia_lares/sensor.py | 3 +- 5 files changed, 117 insertions(+), 12 deletions(-) diff --git a/custom_components/ksenia_lares/__init__.py b/custom_components/ksenia_lares/__init__.py index 52c0df4..e7c972d 100644 --- a/custom_components/ksenia_lares/__init__.py +++ b/custom_components/ksenia_lares/__init__.py @@ -9,7 +9,7 @@ from .base import LaresBase from .coordinator import LaresDataUpdateCoordinator -from .const import DOMAIN +from .const import DOMAIN, DATA_COORDINATOR, DATA_UPDATE_LISTENER CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.ALARM_CONTROL_PANEL] @@ -24,7 +24,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # Preload device info await client.device_info() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + unsub_options_update_listener = entry.add_update_listener(options_update_listener) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + DATA_COORDINATOR: coordinator, + DATA_UPDATE_LISTENER: unsub_options_update_listener, + } hass.async_create_task( hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -33,6 +38,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True +async def options_update_listener(hass: HomeAssistant, config_entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_ok = all( @@ -44,4 +54,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ) ) + if unload_ok: + hass.data[DOMAIN][entry.entry_id][DATA_UPDATE_LISTENER]() + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/custom_components/ksenia_lares/alarm_control_panel.py b/custom_components/ksenia_lares/alarm_control_panel.py index 8271e29..0c11027 100644 --- a/custom_components/ksenia_lares/alarm_control_panel.py +++ b/custom_components/ksenia_lares/alarm_control_panel.py @@ -15,9 +15,23 @@ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMING, ) -from .const import DOMAIN +from .coordinator import DataUpdateCoordinator +from .const import ( + DOMAIN, + DATA_COORDINATOR, + DATA_PARTITIONS, + PARTITION_STATUS_ARMED, + PARTITION_STATUS_ARMED_IMMEDIATE, + PARTITION_STATUS_ARMING, + CONF_PARTITION_AWAY, + CONF_PARTITION_HOME, + CONF_PARTITION_NIGHT, +) _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) @@ -26,29 +40,50 @@ async def async_setup_entry(hass, config_entry, async_add_devices): """Set up alarm control panel of the Lares alarm device from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] device_info = await coordinator.client.device_info() + partition_descriptions = await coordinator.client.partition_descriptions() + + options = { + CONF_PARTITION_AWAY: config_entry.options.get(CONF_PARTITION_AWAY, []), + CONF_PARTITION_HOME: config_entry.options.get(CONF_PARTITION_HOME, []), + CONF_PARTITION_NIGHT: config_entry.options.get(CONF_PARTITION_NIGHT, []), + } # Fetch initial data so we have data when entities subscribe await coordinator.async_refresh() - async_add_devices([LaresAlarmControlPanel(coordinator, device_info)]) + async_add_devices( + [ + LaresAlarmControlPanel( + coordinator, device_info, partition_descriptions, options + ) + ] + ) class LaresAlarmControlPanel(CoordinatorEntity, AlarmControlPanelEntity): """An implementation of a Lares alarm control panel.""" TYPE = DOMAIN - - def __init__(self, coordinator, device_info) -> None: + ARMED_STATUS = [PARTITION_STATUS_ARMED, PARTITION_STATUS_ARMED_IMMEDIATE] + + def __init__( + self, + coordinator: DataUpdateCoordinator, + device_info: dict, + partition_descriptions: dict, + options: dict, + ) -> None: """Initialize a the switch.""" super().__init__(coordinator) - self._cordinator = coordinator + self._coordinator = coordinator + self._partition_descriptions = partition_descriptions + self._options = options self._attr_code_format = CodeFormat.NUMBER self._attr_device_info = device_info - self._attr_code_arm_required = False - self._attr_supported_features = () + self._attr_code_arm_required = True @property def unique_id(self) -> str: @@ -64,4 +99,55 @@ def name(self): @property def state(self) -> StateType: + """Return the state of this panel.""" + if self.has_partition_with_status(PARTITION_STATUS_ARMING): + return STATE_ALARM_ARMING + + if self.is_armed(CONF_PARTITION_AWAY): + return STATE_ALARM_ARMED_AWAY + + if self.is_armed(CONF_PARTITION_HOME): + return STATE_ALARM_ARMED_HOME + + if self.is_armed(CONF_PARTITION_NIGHT): + return STATE_ALARM_ARMED_NIGHT + + # If any of the not mapped partitions is armed, show custom as fallback + if self.has_partition_with_status(self.ARMED_STATUS): + return STATE_ALARM_ARMED_CUSTOM_BYPASS + return STATE_ALARM_DISARMED + + def has_partition_with_status(self, status_list: list[str]) -> bool: + """Return if any partitions is arming.""" + partitions = enumerate(self._coordinator.data[DATA_PARTITIONS]) + in_state = list( + idx for idx, partition in partitions if partition["status"] in status_list + ) + + _LOGGER.debug("%s in status %s", in_state, status_list) + + return len(in_state) > 0 + + def is_armed(self, key: str) -> bool: + """Return if all partitions linked to the configuration key are armed.""" + partition_names = self._options[key] + + # Skip the check if no partitions are linked + if len(partition_names) == 0: + _LOGGER.debug("Skipping %s armed check, no definition", key) + return False + + descriptions = enumerate(self._partition_descriptions) + to_check = (idx for idx, name in descriptions if name in partition_names) + + _LOGGER.debug("Checking %s (%s) for %s", partition_names, to_check, key) + + for idx in to_check: + if ( + self._coordinator.data[DATA_PARTITIONS][idx]["status"] + not in self.ARMED_STATUS + ): + return False + + return True diff --git a/custom_components/ksenia_lares/binary_sensor.py b/custom_components/ksenia_lares/binary_sensor.py index 82833f2..dbda1db 100644 --- a/custom_components/ksenia_lares/binary_sensor.py +++ b/custom_components/ksenia_lares/binary_sensor.py @@ -18,6 +18,7 @@ ZONE_BYPASS_ON, ZONE_STATUS_ALARM, ZONE_STATUS_NOT_USED, + DATA_COORDINATOR, ) _LOGGER = logging.getLogger(__name__) @@ -34,7 +35,7 @@ async def async_setup_entry( ) -> None: """Set up binary sensors attached to a Lares alarm device from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] device_info = await coordinator.client.device_info() zone_descriptions = await coordinator.client.zone_descriptions() diff --git a/custom_components/ksenia_lares/const.py b/custom_components/ksenia_lares/const.py index bcd595e..4667fcc 100644 --- a/custom_components/ksenia_lares/const.py +++ b/custom_components/ksenia_lares/const.py @@ -24,3 +24,6 @@ CONF_PARTITION_AWAY = "partition_away" CONF_PARTITION_HOME = "partition_home" CONF_PARTITION_NIGHT = "partition_night" + +DATA_COORDINATOR = "coordinator" +DATA_UPDATE_LISTENER = "update_listener" diff --git a/custom_components/ksenia_lares/sensor.py b/custom_components/ksenia_lares/sensor.py index a60674f..03a0cb8 100644 --- a/custom_components/ksenia_lares/sensor.py +++ b/custom_components/ksenia_lares/sensor.py @@ -19,6 +19,7 @@ PARTITION_STATUS_ARMING, PARTITION_STATUS_PENDING, PARTITION_STATUS_ALARM, + DATA_COORDINATOR, ) SCAN_INTERVAL = timedelta(seconds=10) @@ -32,7 +33,7 @@ async def async_setup_entry( ) -> None: """Set up sensors attached to a Lares alarm device from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] device_info = await coordinator.client.device_info() partition_descriptions = await coordinator.client.partition_descriptions() From a2b4b385141dce8e181ca5c6d50fd899167b3cd0 Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Wed, 2 Aug 2023 20:34:27 +0200 Subject: [PATCH 5/9] Arm/disarm support --- .gitignore | 1 + README.md | 52 ++++++++++- .../ksenia_lares/alarm_control_panel.py | 88 ++++++++++++++++--- custom_components/ksenia_lares/base.py | 71 ++++++++++++--- custom_components/ksenia_lares/config_flow.py | 23 +++++ custom_components/ksenia_lares/const.py | 5 ++ custom_components/ksenia_lares/strings.json | 14 ++- .../ksenia_lares/translations/en.json | 15 +++- 8 files changed, 237 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 94f1119..f617c43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store .vscode +captured \ No newline at end of file diff --git a/README.md b/README.md index 2d357ee..911b2cd 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,56 @@ # Home Assistant Ksenia Lares integration -[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]](LICENSE) -Ksenia Lares (pre 4.0) integration for home assistant. This is integration is in early stages, use at own risk. +[![hacs][hacsbadge]][hacs] +![Project Maintenance][maintenance-shield] + +Ksenia Lares 48IP integration for home assistant. This is integration is in early stages, use at own risk. + +**This integration will set up the following platforms.** + +Platform | Description +-- | -- +`binary_sensor` | For each zone, defaults to movement sensor. +`sensor` | For each partition, showing the ARM status. +`alarm_panel` | ARM and disarm based on partitions and scenarios + +## Installation +### Installation via HACS +1. Open HACS +2. Goto the menu in to top right corner +3. Goto custom respositories +4. Add this repositry `https://github.com/johnnybegood/ha-ksenia-lares` +5. Click _explore & download respositories_ +6. Search for and add "Ksenia Lares" +7. Restart Home Assistant +8. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Ksenia Lares" or click the button below. + +[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=ksenia_lares) + +### Installation directly + +1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +2. If you do not have a `custom_components` directory (folder) there, you need to create it. +3. In the `custom_components` directory (folder) create a new folder called `ksenia_lares`. +4. Download _all_ the files from the `custom_components/ksenia_lares/` directory (folder) in this repository. +5. Place the files you downloaded in the new directory (folder) you created. +6. Restart Home Assistant +7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Ksenia Lares" or click the button below. + +[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=ksenia_lares) + +## Quickstart +1. Check your Ksenia Lares has the web interface running +2. Enter the following information from Ksenia Lares 48IP web interface: + 1. Hostname or IP adress (port 4202 will be used) + 2. Username + 3. Password +3. To be able to use the alarm panel, click the 'configure' button on the Ksenia Lares [integration](https://my.home-assistant.io/redirect/integrations/) +4. Select the partitions and scenarios that match different ARM states. + 1. When the selected partitions are armed, the alarm panel will show the corresponding state. + 2. The selected scenario will be activated when changing arm state of the alarm panel ## WIP - [x] Detect zones from Lares alarm system and show as binary sensor diff --git a/custom_components/ksenia_lares/alarm_control_panel.py b/custom_components/ksenia_lares/alarm_control_panel.py index 0c11027..80eb583 100644 --- a/custom_components/ksenia_lares/alarm_control_panel.py +++ b/custom_components/ksenia_lares/alarm_control_panel.py @@ -20,7 +20,7 @@ STATE_ALARM_ARMING, ) -from .coordinator import DataUpdateCoordinator +from .coordinator import LaresDataUpdateCoordinator from .const import ( DOMAIN, DATA_COORDINATOR, @@ -31,6 +31,10 @@ CONF_PARTITION_AWAY, CONF_PARTITION_HOME, CONF_PARTITION_NIGHT, + CONF_SCENARIO_AWAY, + CONF_SCENARIO_HOME, + CONF_SCENARIO_NIGHT, + CONF_SCENARIO_DISARM, ) _LOGGER = logging.getLogger(__name__) @@ -43,11 +47,16 @@ async def async_setup_entry(hass, config_entry, async_add_devices): coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] device_info = await coordinator.client.device_info() partition_descriptions = await coordinator.client.partition_descriptions() + scenario_descriptions = await coordinator.client.scenario_descriptions() options = { CONF_PARTITION_AWAY: config_entry.options.get(CONF_PARTITION_AWAY, []), CONF_PARTITION_HOME: config_entry.options.get(CONF_PARTITION_HOME, []), CONF_PARTITION_NIGHT: config_entry.options.get(CONF_PARTITION_NIGHT, []), + CONF_SCENARIO_NIGHT: config_entry.options.get(CONF_SCENARIO_NIGHT, []), + CONF_SCENARIO_HOME: config_entry.options.get(CONF_SCENARIO_HOME, []), + CONF_SCENARIO_AWAY: config_entry.options.get(CONF_SCENARIO_AWAY, []), + CONF_SCENARIO_DISARM: config_entry.options.get(CONF_SCENARIO_DISARM, []), } # Fetch initial data so we have data when entities subscribe @@ -56,7 +65,11 @@ async def async_setup_entry(hass, config_entry, async_add_devices): async_add_devices( [ LaresAlarmControlPanel( - coordinator, device_info, partition_descriptions, options + coordinator, + device_info, + partition_descriptions, + scenario_descriptions, + options, ) ] ) @@ -70,9 +83,10 @@ class LaresAlarmControlPanel(CoordinatorEntity, AlarmControlPanelEntity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: LaresDataUpdateCoordinator, device_info: dict, partition_descriptions: dict, + scenario_descriptions: dict, options: dict, ) -> None: """Initialize a the switch.""" @@ -80,6 +94,7 @@ def __init__( self._coordinator = coordinator self._partition_descriptions = partition_descriptions + self._scenario_descriptions = scenario_descriptions self._options = options self._attr_code_format = CodeFormat.NUMBER self._attr_device_info = device_info @@ -97,28 +112,60 @@ def name(self): name = self._attr_device_info["name"] return f"Panel {name}" + @property + def supported_features(self) -> AlarmControlPanelEntityFeature: + """Return the list of supported features.""" + supported_features = AlarmControlPanelEntityFeature(0) + + if self._options[CONF_SCENARIO_AWAY] != "": + supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY + + if self._options[CONF_SCENARIO_HOME] != "": + supported_features |= AlarmControlPanelEntityFeature.ARM_HOME + + if self._options[CONF_SCENARIO_NIGHT] != "": + supported_features |= AlarmControlPanelEntityFeature.ARM_NIGHT + + return supported_features + @property def state(self) -> StateType: """Return the state of this panel.""" - if self.has_partition_with_status(PARTITION_STATUS_ARMING): + if self.__has_partition_with_status(PARTITION_STATUS_ARMING): return STATE_ALARM_ARMING - if self.is_armed(CONF_PARTITION_AWAY): + if self.__is_armed(CONF_PARTITION_AWAY): return STATE_ALARM_ARMED_AWAY - if self.is_armed(CONF_PARTITION_HOME): + if self.__is_armed(CONF_PARTITION_HOME): return STATE_ALARM_ARMED_HOME - if self.is_armed(CONF_PARTITION_NIGHT): + if self.__is_armed(CONF_PARTITION_NIGHT): return STATE_ALARM_ARMED_NIGHT # If any of the not mapped partitions is armed, show custom as fallback - if self.has_partition_with_status(self.ARMED_STATUS): + if self.__has_partition_with_status(self.ARMED_STATUS): return STATE_ALARM_ARMED_CUSTOM_BYPASS return STATE_ALARM_DISARMED - def has_partition_with_status(self, status_list: list[str]) -> bool: + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + await self.__command(CONF_SCENARIO_HOME, code) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm home command.""" + await self.__command(CONF_SCENARIO_AWAY, code) + + async def async_alarm_arm_night(self, code: str | None = None) -> None: + """Send arm home command.""" + await self.__command(CONF_SCENARIO_NIGHT, code) + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + await self.__command(CONF_SCENARIO_DISARM, code) + + def __has_partition_with_status(self, status_list: list[str]) -> bool: """Return if any partitions is arming.""" partitions = enumerate(self._coordinator.data[DATA_PARTITIONS]) in_state = list( @@ -129,7 +176,7 @@ def has_partition_with_status(self, status_list: list[str]) -> bool: return len(in_state) > 0 - def is_armed(self, key: str) -> bool: + def __is_armed(self, key: str) -> bool: """Return if all partitions linked to the configuration key are armed.""" partition_names = self._options[key] @@ -151,3 +198,24 @@ def is_armed(self, key: str) -> bool: return False return True + + async def __command(self, key: str, code: str | None = None) -> None: + """Send arm home command.""" + scenario_name = self._options[key] + + if scenario_name is None: + _LOGGER.warning("Skipping command, no definition for %s", key) + return + + descriptions = enumerate(self._scenario_descriptions) + match_gen = (idx for idx, name in descriptions if name == scenario_name) + matches = list(match_gen) + + if len(matches) != 1: + _LOGGER.error("No match for %s (%s found)", key, len(matches)) + return + + scenario = matches[0] + _LOGGER.debug("Activating scenario %s", scenario) + + await self._coordinator.client.activate_scenario(scenario, code) diff --git a/custom_components/ksenia_lares/base.py b/custom_components/ksenia_lares/base.py index 3250604..3d1acec 100644 --- a/custom_components/ksenia_lares/base.py +++ b/custom_components/ksenia_lares/base.py @@ -26,6 +26,7 @@ def __init__(self, data: dict) -> None: self._host = f"http://{host}:{self._port}" self._zone_descriptions = None self._partition_descriptions = None + self._scenario_descriptions = None async def info(self): """Get general info""" @@ -78,13 +79,9 @@ async def device_info(self): async def zone_descriptions(self): """Get available zones""" if self._zone_descriptions is None: - response = await self.get("zones/zonesDescription48IP.xml") - - if response is None: - return None - - zones = response.xpath("/zonesDescription/zone") - self._zone_descriptions = [zone.text for zone in zones] + self._zone_descriptions = await self.get_descriptions( + "zones/zonesDescription48IP.xml", "/zonesDescription/zone" + ) return self._zone_descriptions @@ -108,13 +105,10 @@ async def zones(self): async def partition_descriptions(self): """Get available partitions""" if self._partition_descriptions is None: - response = await self.get("partitions/partitionsDescription48IP.xml") - - if response is None: - return None - - partitions = response.xpath("/partitionsDescription/partition") - self._partition_descriptions = [partition.text for partition in partitions] + self._partition_descriptions = await self.get_descriptions( + "partitions/partitionsDescription48IP.xml", + "/partitionsDescription/partition", + ) return self._partition_descriptions @@ -134,6 +128,55 @@ async def paritions(self): for partition in partitions ] + async def scenarios(self): + """Get status of scenarios""" + response = await self.get("scenarios/scenariosOptions.xml") + + if response is None: + return None + + scenarios = response.xpath("/scenariosOptions/scenario") + + return [ + { + "id": idx, + "enabled": scenario.find("abil").text == "TRUE", + "noPin": scenario.find("nopin").text == "TRUE", + } + for idx, scenario in enumerate(scenarios) + ] + + async def scenario_descriptions(self): + """Get descriptions of scenarios""" + if self._scenario_descriptions is None: + self._scenario_descriptions = await self.get_descriptions( + "scenarios/scenariosDescription.xml", "/scenariosDescription/scenario" + ) + + return self._scenario_descriptions + + async def activate_scenario(self, scenario: int, code: str) -> bool: + """Activate the given scenarios, requires the alarm code""" + path = f"cmd/cmdOk.xml?cmd=setMacro&pin={code}¯oId={scenario}&redirectPage=/xml/cmd/cmdError.xml" + response = await self.get(path) + cmd = response.xpath("/cmd") + + if cmd is None or cmd[0].text != "cmdSent": + _LOGGER.error("Active scenario failed: %s", response) + return False + + return True + + async def get_descriptions(self, path: str, element: str) -> dict: + """Get descriptions""" + response = await self.get(path) + + if response is None: + return None + + content = response.xpath(element) + return [item.text for item in content] + async def get(self, path): """Generic send method.""" url = f"{self._host}/xml/{path}" diff --git a/custom_components/ksenia_lares/config_flow.py b/custom_components/ksenia_lares/config_flow.py index 45e8e43..63ad010 100644 --- a/custom_components/ksenia_lares/config_flow.py +++ b/custom_components/ksenia_lares/config_flow.py @@ -20,6 +20,10 @@ CONF_PARTITION_AWAY, CONF_PARTITION_HOME, CONF_PARTITION_NIGHT, + CONF_SCENARIO_HOME, + CONF_SCENARIO_AWAY, + CONF_SCENARIO_NIGHT, + CONF_SCENARIO_DISARM, ) _LOGGER = logging.getLogger(__name__) @@ -110,19 +114,38 @@ async def async_step_init( partitions = await self.client.partition_descriptions() select_partitions = {v: v for v in list(filter(None, partitions)) if v != ""} + scenarios = await self.client.scenario_descriptions() + scenarios_with_empty = [""] + scenarios + options = { + vol.Required( + CONF_SCENARIO_DISARM, + default=self.config_entry.options.get(CONF_SCENARIO_DISARM, ""), + ): vol.In(scenarios), vol.Required( CONF_PARTITION_AWAY, default=self.config_entry.options.get(CONF_PARTITION_AWAY, []), ): cv.multi_select(select_partitions), + vol.Required( + CONF_SCENARIO_AWAY, + default=self.config_entry.options.get(CONF_SCENARIO_AWAY, ""), + ): vol.In(scenarios), vol.Optional( CONF_PARTITION_HOME, default=self.config_entry.options.get(CONF_PARTITION_HOME, []), ): cv.multi_select(select_partitions), + vol.Optional( + CONF_SCENARIO_HOME, + default=self.config_entry.options.get(CONF_SCENARIO_HOME, ""), + ): vol.In(scenarios_with_empty), vol.Optional( CONF_PARTITION_NIGHT, default=self.config_entry.options.get(CONF_PARTITION_NIGHT, []), ): cv.multi_select(select_partitions), + vol.Optional( + CONF_SCENARIO_NIGHT, + default=self.config_entry.options.get(CONF_SCENARIO_NIGHT, ""), + ): vol.In(scenarios_with_empty), } return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/custom_components/ksenia_lares/const.py b/custom_components/ksenia_lares/const.py index 4667fcc..5d34a81 100644 --- a/custom_components/ksenia_lares/const.py +++ b/custom_components/ksenia_lares/const.py @@ -25,5 +25,10 @@ CONF_PARTITION_HOME = "partition_home" CONF_PARTITION_NIGHT = "partition_night" +CONF_SCENARIO_AWAY = "scenario_away" +CONF_SCENARIO_HOME = "scenario_home" +CONF_SCENARIO_NIGHT = "scenario_night" +CONF_SCENARIO_DISARM = "scenario_disarm" + DATA_COORDINATOR = "coordinator" DATA_UPDATE_LISTENER = "update_listener" diff --git a/custom_components/ksenia_lares/strings.json b/custom_components/ksenia_lares/strings.json index 45abae2..3856579 100644 --- a/custom_components/ksenia_lares/strings.json +++ b/custom_components/ksenia_lares/strings.json @@ -22,16 +22,24 @@ "options": { "step": { "init": { - "description": "Link partitions to states, the state will be set when the selected partitions are armed", + "description": "Link different alarm states to partition and scenarios.", "data": { + "scenario_disarm": "Disarm scenario", "partition_away": "Away paritions", + "scenario_away": "Away scenario", "partition_home": "Home partitions", - "partition_night": "Night partitions" + "scenario_home": "Home scenario", + "partition_night": "Night partitions", + "scenario_night": "Night scenario" }, "data_description": { + "scenario_disarm": "Select the scenario to activate to disarm", "partition_away": "Select all partitions that need to armed for away state", + "scenario_away": "Select the scenario to activate to arm away", "partition_home": "Select all partitions that need to armed for home state", - "partition_night": "Select all partitions that need to armed for night state" + "scenario_home": "Select the scenario to activate to arm home", + "partition_night": "Select all partitions that need to armed for night state", + "scenario_night": "Select the scenario to activate to arm night" } } } diff --git a/custom_components/ksenia_lares/translations/en.json b/custom_components/ksenia_lares/translations/en.json index ee0c07a..1221126 100644 --- a/custom_components/ksenia_lares/translations/en.json +++ b/custom_components/ksenia_lares/translations/en.json @@ -23,16 +23,25 @@ "init": { "description": "Link partitions to states, the state will be set when the selected partitions are armed", "data": { + "scenario_disarm": "Disarm scenario", "partition_away": "Away paritions", + "scenario_away": "Away scenario", "partition_home": "Home partitions", - "partition_night": "Night partitions" + "scenario_home": "Home scenario", + "partition_night": "Night partitions", + "scenario_night": "Night scenario" }, "data_description": { + "scenario_disarm": "Select the scenario to activate to disarm", "partition_away": "Select all partitions that need to armed for away state", + "scenario_away": "Select the scenario to activate to arm away", "partition_home": "Select all partitions that need to armed for home state", - "partition_night": "Select all partitions that need to armed for night state" + "scenario_home": "Select the scenario to activate to arm home", + "partition_night": "Select all partitions that need to armed for night state", + "scenario_night": "Select the scenario to activate to arm night" } } } - } + }, + "title": "Ksenia Lares Alarm" } \ No newline at end of file From 6853fe28a4538db69deba318d54f0c9c12709676 Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Wed, 2 Aug 2023 20:39:37 +0200 Subject: [PATCH 6/9] sort manifest keys (hassfest remark) --- custom_components/ksenia_lares/manifest.json | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/custom_components/ksenia_lares/manifest.json b/custom_components/ksenia_lares/manifest.json index d02a3a8..78583a2 100644 --- a/custom_components/ksenia_lares/manifest.json +++ b/custom_components/ksenia_lares/manifest.json @@ -1,19 +1,18 @@ { "domain": "ksenia_lares", "name": "Ksenia Lares Alarm", - "version": "1.1.0", + "codeowners": [ + "@johnnybegood" + ], "config_flow": true, - "iot_class": "local_polling", - "documentation": "https://www.home-assistant.io/integrations/ksenia_lares", + "dependencies": [], + "documentation": "https://github.com/johnnybegood/ha-ksenia-lares", + "homekit": {}, "requirements": [ "lxml==4.9.1", "getmac==0.8.2" ], "ssdp": [], - "zeroconf": [], - "homekit": {}, - "dependencies": [], - "codeowners": [ - "@johnnybegood" - ] -} + "version": "1.0.1", + "zeroconf": [] +} \ No newline at end of file From 5fd97d2003903be26857c54049195161e627b65d Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Wed, 2 Aug 2023 20:41:06 +0200 Subject: [PATCH 7/9] Remove title from translations (hassfest remark) --- custom_components/ksenia_lares/translations/en.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/ksenia_lares/translations/en.json b/custom_components/ksenia_lares/translations/en.json index 1221126..d9f3a55 100644 --- a/custom_components/ksenia_lares/translations/en.json +++ b/custom_components/ksenia_lares/translations/en.json @@ -42,6 +42,5 @@ } } } - }, - "title": "Ksenia Lares Alarm" + } } \ No newline at end of file From bb81cdf605b1dc38beafd8d2acc9637b6bbebc29 Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Wed, 2 Aug 2023 20:46:51 +0200 Subject: [PATCH 8/9] Add IoT class + update version --- custom_components/ksenia_lares/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/ksenia_lares/manifest.json b/custom_components/ksenia_lares/manifest.json index 78583a2..4969419 100644 --- a/custom_components/ksenia_lares/manifest.json +++ b/custom_components/ksenia_lares/manifest.json @@ -8,11 +8,12 @@ "dependencies": [], "documentation": "https://github.com/johnnybegood/ha-ksenia-lares", "homekit": {}, + "iot_class": "local_polling", "requirements": [ "lxml==4.9.1", "getmac==0.8.2" ], "ssdp": [], - "version": "1.0.1", + "version": "1.0.2", "zeroconf": [] } \ No newline at end of file From 976d43740c010187e4bf78caae0ffd4e336d47a0 Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Wed, 2 Aug 2023 20:49:42 +0200 Subject: [PATCH 9/9] Remove title from strings.json --- custom_components/ksenia_lares/strings.json | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/ksenia_lares/strings.json b/custom_components/ksenia_lares/strings.json index 3856579..8ba397f 100644 --- a/custom_components/ksenia_lares/strings.json +++ b/custom_components/ksenia_lares/strings.json @@ -1,5 +1,4 @@ { - "title": "Ksenia Lares Alarm", "config": { "step": { "user": {