Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bypass zones #22

Merged
merged 5 commits into from
Nov 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
.DS_Store
.vscode
captured
captured

### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
37 changes: 18 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
# 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.**

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
Expand Down Expand Up @@ -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).
2 changes: 1 addition & 1 deletion custom_components/ksenia_lares/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
38 changes: 31 additions & 7 deletions custom_components/ksenia_lares/base.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -68,6 +70,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"]
Expand Down Expand Up @@ -158,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}&macroId={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"""
Expand All @@ -178,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}"
Expand Down
2 changes: 1 addition & 1 deletion custom_components/ksenia_lares/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions custom_components/ksenia_lares/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
CONF_SCENARIO_AWAY,
CONF_SCENARIO_NIGHT,
CONF_SCENARIO_DISARM,
CONF_PIN
)

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -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, ""),
Expand Down
2 changes: 2 additions & 0 deletions custom_components/ksenia_lares/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions custom_components/ksenia_lares/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
}
9 changes: 9 additions & 0 deletions custom_components/ksenia_lares/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -43,5 +45,12 @@
}
}
}
},
"entity": {
"switch": {
"bypass": {
"name": "Bypass"
}
}
}
}
95 changes: 95 additions & 0 deletions custom_components/ksenia_lares/switch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""This component provides support for Lares zone bypass."""
import logging
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 .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(
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()
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, options)
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: LaresDataUpdateCoordinator, idx: int, description: str, device_info: dict, options: dict) -> None:
"""Initialize the switch."""
super().__init__(coordinator)

self._coordinator = coordinator
self._idx = idx
self._pin = options[CONF_PIN]

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]["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)
9 changes: 9 additions & 0 deletions custom_components/ksenia_lares/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -43,5 +45,12 @@
}
}
}
},
"entity": {
"switch": {
"bypass": {
"name": "Bypass"
}
}
}
}