From 86381f70e28eb516db0f810175cd0179c88ebe97 Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Wed, 1 Nov 2023 16:39:05 +0100 Subject: [PATCH 1/5] Update manifest and gitignore (routine) --- .gitignore | 8 +++++++- custom_components/ksenia_lares/manifest.json | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index f617c43..066b902 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ .DS_Store .vscode -captured \ No newline at end of file +captured + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class \ No newline at end of file diff --git a/custom_components/ksenia_lares/manifest.json b/custom_components/ksenia_lares/manifest.json index 80d01f6..1b851cb 100644 --- a/custom_components/ksenia_lares/manifest.json +++ b/custom_components/ksenia_lares/manifest.json @@ -11,10 +11,10 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/johnnybegood/ha-ksenia-lares/issues", "requirements": [ - "lxml==4.9.1", + "lxml==4.9.3", "getmac==0.8.2" ], "ssdp": [], "version": "1.0.2", "zeroconf": [] -} +} \ No newline at end of file From bcfdd8cae625020821d83f63f3a2764413b80663 Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Wed, 1 Nov 2023 16:39:25 +0100 Subject: [PATCH 2/5] Add configuration URL to device --- custom_components/ksenia_lares/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/ksenia_lares/base.py b/custom_components/ksenia_lares/base.py index 4fdd01b..c2f5208 100644 --- a/custom_components/ksenia_lares/base.py +++ b/custom_components/ksenia_lares/base.py @@ -68,6 +68,7 @@ async def device_info(self): "manufacturer": MANUFACTURER, "model": device_info["name"], "sw_version": f'{device_info["version"]}.{device_info["revision"]}.{device_info["build"]}', + "configuration_url": self._host } mac = device_info["mac"] From eb1d776d69f3cff6072305f27e9ac0700d550a9d Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Wed, 1 Nov 2023 17:43:06 +0100 Subject: [PATCH 3/5] Show zone bypass switches --- custom_components/ksenia_lares/__init__.py | 2 +- custom_components/ksenia_lares/strings.json | 7 ++ custom_components/ksenia_lares/switch.py | 74 +++++++++++++++++++ .../ksenia_lares/translations/en.json | 7 ++ 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 custom_components/ksenia_lares/switch.py diff --git a/custom_components/ksenia_lares/__init__.py b/custom_components/ksenia_lares/__init__.py index d74a3cf..f990075 100644 --- a/custom_components/ksenia_lares/__init__.py +++ b/custom_components/ksenia_lares/__init__.py @@ -12,7 +12,7 @@ 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] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.ALARM_CONTROL_PANEL, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): diff --git a/custom_components/ksenia_lares/strings.json b/custom_components/ksenia_lares/strings.json index 3aa5cdd..066841f 100644 --- a/custom_components/ksenia_lares/strings.json +++ b/custom_components/ksenia_lares/strings.json @@ -43,5 +43,12 @@ } } } + }, + "entity": { + "switch": { + "bypass": { + "name": "Bypass" + } + } } } \ No newline at end of file diff --git a/custom_components/ksenia_lares/switch.py b/custom_components/ksenia_lares/switch.py new file mode 100644 index 0000000..cf4c4b4 --- /dev/null +++ b/custom_components/ksenia_lares/switch.py @@ -0,0 +1,74 @@ +"""This component provides support for Lares zone bypass.""" +from datetime import timedelta + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + DATA_ZONES, + ZONE_BYPASS_ON, + ZONE_STATUS_NOT_USED, + DATA_COORDINATOR, +) + +SCAN_INTERVAL = timedelta(seconds=10) + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up zone bypass switches for zones in the Lares alarm device from a config entry.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] + device_info = await coordinator.client.device_info() + zone_descriptions = await coordinator.client.zone_descriptions() + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + async_add_entities( + LaresBypassSwitch(coordinator, idx, zone_descriptions[idx], device_info) + for idx, zone in enumerate(coordinator.data[DATA_ZONES]) + ) + + +class LaresBypassSwitch(CoordinatorEntity, SwitchEntity): + """An implementation of a Lares zone bypass switch.""" + + _attr_translation_key = "bypass" + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_entity_category = EntityCategory.CONFIG + _attr_icon = "mdi:shield-off" + + def __init__(self, coordinator, idx, description, device_info) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + + self._coordinator = coordinator + self._description = description + self._idx = idx + + self._attr_unique_id = f"lares_bypass_{self._idx}" + self._attr_device_info = device_info + self._attr_name = description + + is_used = ( + self._coordinator.data[DATA_ZONES][self._idx]["status"] != ZONE_STATUS_NOT_USED + ) + + self._attr_entity_registry_enabled_default = is_used + self._attr_entity_registry_visible_default = is_used + + @property + def is_on(self) -> bool | None: + """Return true if the zone is bypassed.""" + status = self._coordinator.data[DATA_ZONES][self._idx]["status"] + return status == ZONE_BYPASS_ON diff --git a/custom_components/ksenia_lares/translations/en.json b/custom_components/ksenia_lares/translations/en.json index ee0ab25..f1fa8b9 100644 --- a/custom_components/ksenia_lares/translations/en.json +++ b/custom_components/ksenia_lares/translations/en.json @@ -43,5 +43,12 @@ } } } + }, + "entity": { + "switch": { + "bypass": { + "name": "Bypass" + } + } } } \ No newline at end of file From d23ebf362cd3caf257637e4366c7ee843ec2f766 Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Thu, 2 Nov 2023 11:20:20 +0100 Subject: [PATCH 4/5] Bypass zones --- custom_components/ksenia_lares/base.py | 37 +++++++++++++++---- .../ksenia_lares/binary_sensor.py | 2 +- custom_components/ksenia_lares/config_flow.py | 2 + custom_components/ksenia_lares/const.py | 2 + custom_components/ksenia_lares/strings.json | 2 + custom_components/ksenia_lares/switch.py | 29 +++++++++++++-- .../ksenia_lares/translations/en.json | 2 + 7 files changed, 64 insertions(+), 12 deletions(-) diff --git a/custom_components/ksenia_lares/base.py b/custom_components/ksenia_lares/base.py index c2f5208..5ff5707 100644 --- a/custom_components/ksenia_lares/base.py +++ b/custom_components/ksenia_lares/base.py @@ -1,5 +1,7 @@ """Base component for Lares""" import logging +from typing import Any +from xml.etree.ElementTree import Element import aiohttp from getmac import get_mac_address @@ -159,15 +161,20 @@ async def scenario_descriptions(self): 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") + params = { + "macroId": scenario + } - if cmd is None or cmd[0].text != "cmdSent": - _LOGGER.error("Active scenario failed: %s", response) - return False + return await self.send_command("setMacro", code, params) - return True + async def bypass_zone(self, zone: int, code: str, bypass: bool) -> bool: + """Activate the given scenarios, requires the alarm code""" + params = { + "zoneId": zone + 1, #Lares uses index starting with 1 + "zoneValue": 1 if bypass else 0 + } + + return await self.send_command("setByPassZone", code, params) async def get_descriptions(self, path: str, element: str) -> dict: """Get descriptions""" @@ -179,6 +186,22 @@ async def get_descriptions(self, path: str, element: str) -> dict: content = response.xpath(element) return [item.text for item in content] + async def send_command(self, command: str, code: str, params: dict[str, int]) -> bool: + """Send Command""" + urlparam = "".join(f'&{k}={v}' for k,v in params.items()) + path = f"cmd/cmdOk.xml?cmd={command}&pin={code}&redirectPage=/xml/cmd/cmdError.xml{urlparam}" + + _LOGGER.debug("Sending command %s", path) + + response = await self.get(path) + cmd = response.xpath("/cmd") + + if cmd is None or cmd[0].text != "cmdSent": + _LOGGER.error("Command send failed: %s", response) + return False + + return True + async def get(self, path): """Generic send method.""" url = f"{self._host}/xml/{path}" diff --git a/custom_components/ksenia_lares/binary_sensor.py b/custom_components/ksenia_lares/binary_sensor.py index dbda1db..e1dae96 100644 --- a/custom_components/ksenia_lares/binary_sensor.py +++ b/custom_components/ksenia_lares/binary_sensor.py @@ -93,4 +93,4 @@ def available(self): """Return True if entity is available.""" status = self._coordinator.data[DATA_ZONES][self._idx]["status"] - return status != ZONE_STATUS_NOT_USED or status == ZONE_BYPASS_ON + return status != ZONE_STATUS_NOT_USED diff --git a/custom_components/ksenia_lares/config_flow.py b/custom_components/ksenia_lares/config_flow.py index 5dc0024..f59acfd 100644 --- a/custom_components/ksenia_lares/config_flow.py +++ b/custom_components/ksenia_lares/config_flow.py @@ -24,6 +24,7 @@ CONF_SCENARIO_AWAY, CONF_SCENARIO_NIGHT, CONF_SCENARIO_DISARM, + CONF_PIN ) _LOGGER = logging.getLogger(__name__) @@ -118,6 +119,7 @@ async def async_step_init( scenarios_with_empty = [""] + scenarios options = { + vol.Optional(CONF_PIN): str, vol.Required( CONF_SCENARIO_DISARM, default=self.config_entry.options.get(CONF_SCENARIO_DISARM, ""), diff --git a/custom_components/ksenia_lares/const.py b/custom_components/ksenia_lares/const.py index 5d34a81..fa6c972 100644 --- a/custom_components/ksenia_lares/const.py +++ b/custom_components/ksenia_lares/const.py @@ -21,6 +21,8 @@ PARTITION_STATUS_PENDING = "PREALARM" PARTITION_STATUS_ALARM = "ALARM" +CONF_PIN = "pin" + 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 066841f..827f037 100644 --- a/custom_components/ksenia_lares/strings.json +++ b/custom_components/ksenia_lares/strings.json @@ -24,6 +24,7 @@ "init": { "description": "Link different alarm states to partition and scenarios.", "data": { + "pin": "PIN", "scenario_disarm": "Disarm scenario", "partition_away": "Away partitions", "scenario_away": "Away scenario", @@ -33,6 +34,7 @@ "scenario_night": "Night scenario" }, "data_description": { + "pin": "PIN to use for zone/partition bypass", "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", diff --git a/custom_components/ksenia_lares/switch.py b/custom_components/ksenia_lares/switch.py index cf4c4b4..9d564a8 100644 --- a/custom_components/ksenia_lares/switch.py +++ b/custom_components/ksenia_lares/switch.py @@ -1,4 +1,5 @@ """This component provides support for Lares zone bypass.""" +import logging from datetime import timedelta from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity @@ -10,14 +11,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .coordinator import LaresDataUpdateCoordinator from .const import ( DOMAIN, DATA_ZONES, ZONE_BYPASS_ON, ZONE_STATUS_NOT_USED, DATA_COORDINATOR, + CONF_PIN, ) +_LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) async def async_setup_entry( @@ -30,12 +34,13 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] device_info = await coordinator.client.device_info() zone_descriptions = await coordinator.client.zone_descriptions() + options = { CONF_PIN: config_entry.options.get(CONF_PIN)} # Fetch initial data so we have data when entities subscribe await coordinator.async_refresh() async_add_entities( - LaresBypassSwitch(coordinator, idx, zone_descriptions[idx], device_info) + LaresBypassSwitch(coordinator, idx, zone_descriptions[idx], device_info, options) for idx, zone in enumerate(coordinator.data[DATA_ZONES]) ) @@ -48,13 +53,13 @@ class LaresBypassSwitch(CoordinatorEntity, SwitchEntity): _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:shield-off" - def __init__(self, coordinator, idx, description, device_info) -> None: + def __init__(self, coordinator: LaresDataUpdateCoordinator, idx: int, description: str, device_info: dict, options: dict) -> None: """Initialize the switch.""" super().__init__(coordinator) self._coordinator = coordinator - self._description = description self._idx = idx + self._pin = options[CONF_PIN] self._attr_unique_id = f"lares_bypass_{self._idx}" self._attr_device_info = device_info @@ -70,5 +75,21 @@ def __init__(self, coordinator, idx, description, device_info) -> None: @property def is_on(self) -> bool | None: """Return true if the zone is bypassed.""" - status = self._coordinator.data[DATA_ZONES][self._idx]["status"] + status = self._coordinator.data[DATA_ZONES][self._idx]["bypass"] return status == ZONE_BYPASS_ON + + async def async_turn_on(self, **kwargs): + """Bypass the zone.""" + if self._pin is None: + _LOGGER.error("Pin needed for bypass zone") + return + + await self._coordinator.client.bypass_zone(self._idx, self._pin, True) + + async def async_turn_off(self, **kwargs): + """Unbypass the zone.""" + if self._pin is None: + _LOGGER.error("Pin needed for unbypass zone") + return + + await self._coordinator.client.bypass_zone(self._idx, self._pin, False) \ 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 f1fa8b9..1c86761 100644 --- a/custom_components/ksenia_lares/translations/en.json +++ b/custom_components/ksenia_lares/translations/en.json @@ -24,6 +24,7 @@ "init": { "description": "Link partitions to states, the state will be set when the selected partitions are armed", "data": { + "pin": "PIN", "scenario_disarm": "Disarm scenario", "partition_away": "Away partitions", "scenario_away": "Away scenario", @@ -33,6 +34,7 @@ "scenario_night": "Night scenario" }, "data_description": { + "pin": "PIN to use for zone/partition bypass", "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", From d37062f3b00cfeee8dcf27abf884c5e13a8ad254 Mon Sep 17 00:00:00 2001 From: Wouter Hemeryck Date: Thu, 2 Nov 2023 11:20:32 +0100 Subject: [PATCH 5/5] Updated README --- README.md | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b9df163..d99f182 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Home Assistant Ksenia Lares integration [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) -Ksenia Lares 48IP integration for home assistant. This is integration is in early stages, use at own risk. +Ksenia Lares 48IP integration for home assistant. Compatible with BTicino alarm systems. **This integration will set up the following platforms.** @@ -9,7 +9,11 @@ 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 +`alarm_control_panel` | ARM and disarm based on partitions and scenarios +`switch` | Bypass zones/partitions + +## Requirements +This integration relies on the web interface to be activated, this is not always the case. Please contact your alarm intaller for more information on activation. ## Installation ### Installation via HACS @@ -39,20 +43,15 @@ Platform | Description [![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 -- [ ] Integrate with alarm panel in Home Assistant -- [ ] Add logo -- [ ] Improve configuration -- [ ] Move SDK/API to seperate repo +## Configuration +### Mapping Alarm +The KSENIA Lares alarm uses more complex scenarios then the default states of the home assistant alarm (away, night, home). A mapping is needed between the Home Assistant states and the KSENIA Lares scenarios (for activation) and zones/partitions (for state). + +Go to [integration](https://my.home-assistant.io/redirect/integration/?domain=ksenia_lares) to setup the mapping. + +### Bypass zones +To be able to bypass zones, you will need to configure a PIN to be used. + +1. Go to [integration](https://my.home-assistant.io/redirect/integration/?domain=ksenia_lares) +2. Click 'Configure' +3. Enter the PIN code to use (it will need to be entered again each time the configuration screen is used). \ No newline at end of file