eavanvalkenburg / sia

SIA alarm systems integration into Home Assistant
MIT License
48 stars 11 forks source link

Ajax alarm - individual binary sensors for each zone and night mode #27

Closed fireRoads closed 3 years ago

fireRoads commented 4 years ago

Is your feature request related to a problem? Please describe. As discussed here: https://community.home-assistant.io/t/ajax-alarm-system/62853/190?u=fireon The HA alarm component can only hold one state at a time, whereas the Ajax alarm can arm a zone and at the same time have the night mode turned on or off. This means that the HA alarm component does not truly reflect all the simultaneous state possibilities that can be seen in the Ajax app.

In Ajax, you can also choose to only have particular devices activated during night mode. For example, I have three zones, 1 for home, 2 for the storehouse/garage, and 3 for the patio. When I activate night mode it only spans across zone 2 and 3 but zone 2 is typically always armed. In HA I therefore want to be able to see if zone 2 is armed (by normal arming) and separately if the night mode is also armed. I can see that in the Ajax app but not with the alarm component in HA since it cannot be set to armed_away or armed_night at the same time.

I therefore risk to forget to properly arm zone 2 if I see that night mode is activated. Next morning when night mode is deactivated zone 2 will therefore be totally unarmed. Today I have a notification automation running when zone 2 has been unarmed for too long, just to remind me to arm it. That automation is not possible (or has to consider multiple states) with the alarm component since the night mode could interfere with the actual state of the zone, thereby tricking you into thinking that the zone is armed when it is only armed for night mode. This is a little bit hard to explain but I hope you understand what I am meaning.

Describe the solution you'd like The main part of the component could be kept as is but I would like to be able to include an individual binary sensor (with on as armed and off as unarmed) for each zone and for the night mode. This should not be too much of a change and it could also be an optional setting in the setup dialog if you want the additional binary sensors or not, In my case they would be named binary_sensor.ajax_zone1, binary_sensor.ajax_zone2, binary_sensor.ajax_zone3 and binary_sensor.ajax_night_mode. This also enables you to more easily write automations that listens to if the night mode or zone is armed or not.

Additional context I am still using CheaterDev's SIA component with some modifications to support multiple zones, additional SIA codes, and a change that actually resets the alarm state to "safe" upon disarming after a triggered alarm. I have attached the init.py I am using below for reference. Sorry for my coding skills, I am not a programmer :)

I do like the alarm component integration in this version and the fact that it is regularly maintained :) So I would really like to switch to this version but since I have a lot of automations listening to the different binary sensors I am hesitant on moving since I cannot separate the night mode state from the zoned states.

import asyncio
import logging
import json
import voluptuous as vol
import sseclient
import requests
import time
from collections import defaultdict
from requests_toolbelt.utils import dump
from homeassistant.core import callback
import voluptuous as vol
from datetime import timedelta
from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity, async_generate_entity_id
from homeassistant.helpers.event import async_track_state_change

from threading import Thread
from homeassistant.helpers import discovery
from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.helpers.restore_state import RestoreEntity
_LOGGER = logging.getLogger(__name__)
from homeassistant.const import (STATE_ON, STATE_OFF)

from homeassistant.const import (
    CONF_NAME, CONF_PORT, CONF_PASSWORD)
import socketserver 
from datetime import datetime
import time
import logging
import threading
import sys
import re

from Crypto.Cipher import AES
from binascii import unhexlify,hexlify
from Crypto import Random
import random, string, base64
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import utcnow

DOMAIN = 'sia'
CONF_HUBS = 'hubs'
CONF_ACCOUNT = 'account'

HUB_CONFIG = vol.Schema({
    vol.Required(CONF_NAME): cv.string,
    vol.Required(CONF_ACCOUNT): cv.string,
    vol.Optional(CONF_PASSWORD):cv.string,
})

CONFIG_SCHEMA = vol.Schema({
    DOMAIN: vol.Schema({
        vol.Required(CONF_PORT): cv.string,
        vol.Required(CONF_HUBS, default={}):
            vol.All(cv.ensure_list, [HUB_CONFIG]),
    }),
}, extra=vol.ALLOW_EXTRA)

ID_STRING = '"SIA-DCS"'.encode()
ID_STRING_ENCODED = '"*SIA-DCS"'.encode()

TIME_TILL_UNAVAILABLE = timedelta(minutes=3)

ID_R='\r'.encode()

hass_platform = None

def setup(hass, config):
    global hass_platform
    socketserver.TCPServer.allow_reuse_address = True
    hass_platform = hass

    hass_platform.data[DOMAIN] = {}

    port = int(config[DOMAIN][CONF_PORT])

    for hub_config in config[DOMAIN][CONF_HUBS]:
        if CONF_PASSWORD in hub_config:
            hass.data[DOMAIN][hub_config[CONF_ACCOUNT]] = EncryptedHub(hass, hub_config)
        else:
            hass.data[DOMAIN][hub_config[CONF_ACCOUNT]] = Hub(hass, hub_config)

    for component in ['binary_sensor']:
       discovery.load_platform(hass, component, DOMAIN, {}, config)

    server = socketserver.TCPServer(("", port), AlarmTCPHandler)

    t = threading.Thread(target=server.serve_forever)
    t.start()

    return True

class Hub:
    reactions = {            

            # Zone 1 - Hem
            "Nri1/BA" : [{"state":"ALARM_ZONE1","value":True}],
            "Nri1/BR" : [{"state":"ALARM_ZONE1","value":False}],
            "Nri1/TA" : [{"state":"ALARM_ZONE1","value":True}],
            "Nri1/TR" : [{"state":"ALARM_ZONE1","value":False}],
            "Nri1/CG" : [{"state":"STATUS_ZONE1","value":False}],
            "Nri1/CF" : [{"state":"STATUS_ZONE1","value":False}],
            "Nri1/CL" : [{"state":"STATUS_ZONE1","value":False}],
            "Nri1/OG" : [{"state":"STATUS_ZONE1","value":True}],
            "Nri1/OP" : [{"state":"STATUS_ZONE1","value":True}],
            "Nri1/NF" : [{"state":"STATUS_NIGHT_MODE","value":False}],
            "Nri1/NL" : [{"state":"STATUS_NIGHT_MODE","value":False}],
            "Nri1/NP" : [{"state":"STATUS_NIGHT_MODE","value":True}],
            "Nri1/WA":  [{"state":"LEAK_ZONE1","value":True}],
            "Nri1/WH":  [{"state":"LEAK_ZONE1","value":False}],
            "Nri1/GA":  [{"state":"GAS_ZONE1","value":True}],
            "Nri1/GH":  [{"state":"GAS_ZONE1","value":False}],
            "Nri1/XC" : [],
            "Nri1/XL" : [],

            # Zone 2 - Förråd
            "Nri2/BA" : [{"state":"ALARM_ZONE2","value":True}],
            "Nri2/BR" : [{"state":"ALARM_ZONE2","value":False}],
            "Nri2/TA" : [{"state":"ALARM_ZONE2","value":True}],
            "Nri2/TR" : [{"state":"ALARM_ZONE2","value":False}],
            "Nri2/CG" : [{"state":"STATUS_ZONE2","value":False}],
            "Nri2/CF" : [{"state":"STATUS_ZONE2","value":False}],
            "Nri2/CL" : [{"state":"STATUS_ZONE2","value":False}],
            "Nri2/OG" : [{"state":"STATUS_ZONE2","value":True}],
            "Nri2/OP" : [{"state":"STATUS_ZONE2","value":True}],
            "Nri2/NF" : [],
            "Nri2/NL" : [],
            "Nri2/NP" : [],
            "Nri2/WA":  [],
            "Nri2/WH":  [],
            "Nri2/GA":  [],
            "Nri2/GH":  [],
            "Nri2/NF" : [],
            "Nri2/XC" : [],
            "Nri2/XL" : [],

            # Zone 3 - Uteplats
            "Nri3/BA" : [{"state":"ALARM_ZONE3","value":True}],
            "Nri3/BR" : [{"state":"ALARM_ZONE3","value":False}],
            "Nri3/TA" : [{"state":"ALARM_ZONE3","value":True}],
            "Nri3/TR" : [{"state":"ALARM_ZONE3","value":False}],
            "Nri3/CG" : [{"state":"STATUS_ZONE3","value":False}],
            "Nri3/CF" : [{"state":"STATUS_ZONE3","value":False}],
            "Nri3/CL" : [{"state":"STATUS_ZONE3","value":False}],
            "Nri3/OG" : [{"state":"STATUS_ZONE3","value":True}],
            "Nri3/OP" : [{"state":"STATUS_ZONE3","value":True}],
            "Nri3/NF" : [],
            "Nri3/NL" : [],
            "Nri3/NP" : [],
            "Nri3/WA":  [],
            "Nri3/WH":  [],
            "Nri3/GA":  [],
            "Nri3/GH":  [],
            "Nri3/NF" : [],
            "Nri3/XC" : [],
            "Nri3/XL" : [],

            # Other
            "Nri0/RP" : [],
            "Nri0/RS" : [],
            "Nri0/YG" : []
        }

    # -----------------------------------------------------------------------------------------------
    # Mod: reset alarm state when disarmed
    reactions_reset_alarm_state_on_disarm = {            

            # Zone 1 - Hem
            "Nri1/OG" : [{"state":"ALARM_ZONE1","value":False}],
            "Nri1/OP" : [{"state":"ALARM_ZONE1","value":False}],

            # Zone 2 - Förråd
            "Nri2/OG" : [{"state":"ALARM_ZONE2","value":False}],
            "Nri2/OP" : [{"state":"ALARM_ZONE2","value":False}],

            # Zone 3 - Uteplats
            "Nri3/OG" : [{"state":"ALARM_ZONE3","value":False}],
            "Nri3/OP" : [{"state":"ALARM_ZONE3","value":False}],
        }
    # -----------------------------------------------------------------------------------------------

    def __init__(self, hass, hub_config):
        self._name = hub_config[CONF_NAME]
        self._accountId = hub_config[CONF_ACCOUNT]
        self._hass = hass
        self._states = {}
        self._states["LEAK_ZONE1"] = SIABinarySensor("sia_leak_zone1_" + self._name,"moisture" , hass)
        self._states["GAS_ZONE1"] = SIABinarySensor("sia_gas_zone1_" + self._name,"smoke", hass)
        self._states["ALARM_ZONE1"]  = SIABinarySensor("sia_alarm_zone1_" + self._name,"safety", hass)
        self._states["STATUS_ZONE1"]  = SIABinarySensor("sia_status_zone1_" + self._name, "lock", hass)
        self._states["STATUS_NIGHT_MODE"]  = SIABinarySensor("sia_status_night_mode_" + self._name, "lock", hass)
        # self._states["LEAK_ZONE2"] = SIABinarySensor("sia_leak_zone2_" + self._name,"moisture" , hass)
        # self._states["GAS_ZONE2"] = SIABinarySensor("sia_gas_zone2_" + self._name,"smoke", hass)
        self._states["ALARM_ZONE2"]  = SIABinarySensor("sia_alarm_zone2_" + self._name,"safety", hass)
        self._states["STATUS_ZONE2"]  = SIABinarySensor("sia_status_zone2_" + self._name, "lock", hass)
        # self._states["LEAK_ZONE3"] = SIABinarySensor("sia_leak_zone3_" + self._name,"moisture" , hass)
        # self._states["GAS_ZONE3"] = SIABinarySensor("sia_gas_zone3_" + self._name,"smoke", hass)
        self._states["ALARM_ZONE3"]  = SIABinarySensor("sia_alarm_zone3_" + self._name,"safety", hass)
        self._states["STATUS_ZONE3"]  = SIABinarySensor("sia_status_zone3_" + self._name, "lock", hass)

    def manage_string(self, msg):
        _LOGGER.debug("manage_string: " + msg)

        pos = msg.find('/')        
        assert pos>=0, "Can't find '/', message is possibly encrypted"
        # tipo = msg[pos+1:pos+3]
        tipo = msg[pos-4:pos+3]

        if tipo in self.reactions:
            reactions = self.reactions[tipo]
            for reaction in reactions:
                state = reaction["state"]
                value = reaction["value"]

                self._states[state].new_state(value)

            # -----------------------------------------------------------------------------------------------
            # Mod: reset alarm state when disarmed
            if tipo in self.reactions_reset_alarm_state_on_disarm:
                reactions_reset_alarm_state_on_disarm = self.reactions_reset_alarm_state_on_disarm[tipo]
                for reaction in reactions_reset_alarm_state_on_disarm:
                    state = reaction["state"]
                    value = reaction["value"]

                    self._states[state].new_state(value)
            # -----------------------------------------------------------------------------------------------

        else:
            _LOGGER.error("unknown event: " + tipo )

        for device in self._states:
           self._states[device].assume_available()

    def process_line(self, line):
        _LOGGER.debug("Hub.process_line" + line.decode())
        pos = line.find(ID_STRING)
        assert pos>=0, "Can't find ID_STRING, check encryption configs"
        seq = line[pos+len(ID_STRING) : pos+len(ID_STRING)+4]
        data = line[line.index(b'[') :]
        _LOGGER.debug("Hub.process_line found data: " + data.decode())
        self.manage_string(data.decode())
        return '"ACK"'  + (seq.decode()) + 'L0#' + (self._accountId) + '[]'

class EncryptedHub(Hub):
    def __init__(self, hass, hub_config):
        self._key = hub_config[CONF_PASSWORD].encode("utf8")
        iv = Random.new().read(AES.block_size)
        _cipher = AES.new(self._key, AES.MODE_CBC, iv)
        self.iv2 = None
        self._ending = hexlify(_cipher.encrypt( "00000000000000|]".encode("utf8") )).decode(encoding='UTF-8').upper()
        Hub.__init__(self, hass, hub_config)

    def manage_string(self, msg):
        iv = unhexlify("00000000000000000000000000000000") #where i need to find proper IV ? Only this works good.
        _cipher = AES.new(self._key, AES.MODE_CBC, iv)
        data = _cipher.decrypt(unhexlify(msg[1:]))
        _LOGGER.debug("EncryptedHub.manage_string data: " + data.decode(encoding='UTF-8',errors='replace'))

        data = data[data.index(b'|'):]
        resmsg = data.decode(encoding='UTF-8',errors='replace')

        Hub.manage_string(self, resmsg)

    def process_line(self, line):
        _LOGGER.debug("EncryptedHub.process_line" + line.decode())
        pos = line.find(ID_STRING_ENCODED)
        assert pos>=0, "Can't find ID_STRING_ENCODED, is SIA encryption enabled?"
        seq = line[pos+len(ID_STRING_ENCODED) : pos+len(ID_STRING_ENCODED)+4]
        data = line[line.index(b'[') :]
        _LOGGER.debug("EncryptedHub.process_line found data: " + data.decode())
        self.manage_string(data.decode())
        return '"*ACK"'  + (seq.decode()) + 'L0#' + (self._accountId) + '[' + self._ending

class SIABinarySensor( RestoreEntity):
    def __init__(self,  name, device_class, hass):
        self._device_class = device_class
        self._should_poll = False
        self._name = name
        self.hass = hass
        self._is_available = True
        self._remove_unavailability_tracker = None

    async def async_added_to_hass(self):
        await super().async_added_to_hass()
        state = await self.async_get_last_state()        
        if state is not None and state.state is not None:
            self._state = state.state == STATE_ON
        else:
            self._state = None
        self._async_track_unavailable()

    @property
    def name(self):
        return self._name

    @property
    def state(self):
        return STATE_ON if self.is_on else STATE_OFF

    @property
    def unique_id(self) -> str:
        return self._name

    @property
    def available(self):
        return self._is_available

    @property
    def device_state_attributes(self):
        attrs = {}
        return attrs

    @property
    def device_class(self):
        return self._device_class

    @property
    def is_on(self):
        return self._state

    def new_state(self, state):   
        self._state = state
        self.async_schedule_update_ha_state()

    def assume_available(self):
        self._async_track_unavailable()

    @callback
    def _async_track_unavailable(self):
        if self._remove_unavailability_tracker:
            self._remove_unavailability_tracker()
        self._remove_unavailability_tracker = async_track_point_in_utc_time(
            self.hass, self._async_set_unavailable,
            utcnow() + TIME_TILL_UNAVAILABLE)
        if not self._is_available:
            self._is_available = True
            return True
        return False

    @callback
    def _async_set_unavailable(self, now):
        self._remove_unavailability_tracker = None
        self._is_available = False
        self.async_schedule_update_ha_state()

class AlarmTCPHandler(socketserver.BaseRequestHandler):
    _received_data = "".encode()

    def handle_line(self, line):   
        _LOGGER.debug("Income raw string: " + line.decode())     
        accountId = line[line.index(b'#') +1: line.index(b'[')].decode()

        pos = line.find(b'"')
        assert pos>=0, "Can't find message beginning"
        inputMessage=line[pos:]
        msgcrc = line[0:4] 
        codecrc = str.encode(AlarmTCPHandler.CRCCalc(inputMessage))   
        try:
            if msgcrc != codecrc:
                raise Exception('CRC mismatch')            
            if(accountId not in hass_platform.data[DOMAIN]):
                raise Exception('Not supported account ' + accountId)
            response = hass_platform.data[DOMAIN][accountId].process_line(line)
        except Exception as e:
            _LOGGER.error(str(e))
            timestamp = datetime.fromtimestamp(time.time()).strftime('_%H:%M:%S,%m-%d-%Y')
            response = '"NAK"0000L0R0A0[]' + timestamp

        header = ('%04x' % len(response)).upper()
        CRC = AlarmTCPHandler.CRCCalc2(response)
        response="\n" + CRC + header + response + "\r"

        byte_response = str.encode(response)
        self.request.sendall(byte_response)

    def handle(self):
        line = b''
        try:
            while True:
                raw = self.request.recv(1024)
                if (not raw) or (len(raw) == 0):
                    return
                raw = bytearray(raw)
                while True:
                    splitter = raw.find(b'\r')
                    if splitter> -1:
                        line = raw[1:splitter]
                        raw = raw[splitter+1:]
                    else:
                        break

                    self.handle_line(line)
        except Exception as e: 
            _LOGGER.error(str(e)+" last line: " + line.decode())
            return

    @staticmethod
    def CRCCalc(msg):
        CRC=0
        for letter in msg:
            temp=(letter)
            for j in range(0,8):  # @UnusedVariable
                temp ^= CRC & 1
                CRC >>= 1
                if (temp & 1) != 0:
                    CRC ^= 0xA001
                temp >>= 1

        return ('%x' % CRC).upper().zfill(4)

    @staticmethod
    def CRCCalc2(msg):
        CRC=0
        for letter in msg:
            temp=ord(letter)
            for j in range(0,8):  # @UnusedVariable
                temp ^= CRC & 1
                CRC >>= 1
                if (temp & 1) != 0:
                    CRC ^= 0xA001
                temp >>= 1

        return ('%x' % CRC).upper().zfill(4)
eavanvalkenburg commented 4 years ago

@fireRoads thanks for all the details here, unfortunately the Alarm control panel entity in HA does not allow that and I also don't know how universal this kind of behaviour is across all alarm systems. If we want to have this included natively for all alarm_control_panels we would have to create a architecture issue here. In order to support this behaviour by adding additional binary sensors like you have done here, will make the config a lot more complex and will def not be in the first official version (because the first submission of a new integration is only allowed to have 1 platform, so alarm_control_panel, but no binary sensors yet). Will give it some thought!

eavanvalkenburg commented 4 years ago

I've been thinking about this, what if the NP code resets the alarm to it's previous state (so whatever it was before night_mode), and I put the previous_state in the attributes, that would work for people like me, but might also be good enough for you? What do you think @fireRoads ?

fireRoads commented 4 years ago

Hi, yeah, I guess that would work for me as well. Or maybe if you could create a separate alarm panel "zone" for the night mode. That way you could read the status from all zones and the night mode independently.

eavanvalkenburg commented 4 years ago

I don't like that, because for me there is 1 alarm and it has 1 state (armed, disarmed, night) and if I then have to check two entities to see if it is in night mode or armed, that makes it needlessly complex for a simple install, while a more advanced setup can create a template binary_sensor or template alarm_control_panel combining the state and the previous_state attribute to reflect it the way you want. Let me see if I have time to set this up today!

fireRoads commented 4 years ago

Yeah I get that it is not perfect. The problem here really is the HA alarm component. It is not a great fit with the armed home and armed away states since the Ajax alarm only has one armed state = armed away and a complimentary night mode. Your suggestion would work and separate binary sensors could be setup from that manually. Thanks.

eavanvalkenburg commented 3 years ago

the current version uses the previous state when disabling night mode, have you had a chance to test that @fireRoads ?

eavanvalkenburg commented 3 years ago

Hi @fireRoads, I have just created a new beta release and this one uses events under the covers, this allows you to build automations just based off of the codes you want to listen to, so you have a lot more freedom, haven't updated the docs yet, but see here for the event_type: https://github.com/eavanvalkenburg/sia/releases/tag/v0.4.0-beta.1

fireRoads commented 3 years ago

Hi! That sounds really great. Good job! Could be really powerful for automations and even building custom sensors based on the events.

I currently have limited free time to play with this. Nice to see things going forward with the integration though. I will check it out when I have time.

I am closing this issue 👍

eavanvalkenburg commented 3 years ago

Thanks! Let me know once you find some time!