├── logs └── .gitkeep ├── .gitignore ├── samples ├── .bash_aliases ├── supervisor │ └── alarm.conf ├── crontab_sample ├── config_sample.ini └── rpi-alarm.service ├── README.md ├── cli.py ├── healthchecks.py ├── LICENSE.txt ├── battery.py ├── pushover.py ├── arduino.py ├── hass_discovery.py ├── hass_entities.py └── alarm.py /logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .idea/ 3 | config.ini 4 | config.backup 5 | *.log 6 | *_test.py 7 | -------------------------------------------------------------------------------- /samples/.bash_aliases: -------------------------------------------------------------------------------- 1 | alias alarm_log='sudo journalctl -u rpi-alarm -f --output cat --lines 25' 2 | alias alarm_log_info='sudo journalctl -u rpi-alarm -f --output cat --lines 25 --grep "INFO|WARNING|ERROR|CRITICAL"' 3 | -------------------------------------------------------------------------------- /samples/supervisor/alarm.conf: -------------------------------------------------------------------------------- 1 | [program:alarm] 2 | command=python3 alarm.py 3 | directory=/home/hebron/rpi-alarm 4 | autostart=true 5 | autorestart=unexpected 6 | user=hebron 7 | stderr_logfile=syslog 8 | stdout_logfile=syslog 9 | -------------------------------------------------------------------------------- /samples/crontab_sample: -------------------------------------------------------------------------------- 1 | # m h dom mon dow command 2 | 0 2 1 * * cd /home/user/rpi-alarm && python3 cli.py --action battery_test >> /dev/null 2>&1 3 | 0 4 15 * * cd /home/user/rpi-alarm && python3 cli.py --action water_valve_test >> /dev/null 2>&1 -------------------------------------------------------------------------------- /samples/config_sample.ini: -------------------------------------------------------------------------------- 1 | [system] 2 | state = disarmed 3 | 4 | [mqtt] 5 | host = 6 | client_id = 7 | 8 | [pushover] 9 | token = 10 | user = 11 | 12 | [healthchecks.uuid] 13 | heartbeat = 14 | 15 | [codes] 16 | 1234 = Test 17 | 18 | [times] 19 | delay = 30 20 | arming = 30 21 | trigger = 60 22 | 23 | [zone_timers] 24 | 25 | [config] 26 | -------------------------------------------------------------------------------- /samples/rpi-alarm.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Raspberry Pi security alarm 3 | Wants=network-online.target 4 | After=network-online.target 5 | 6 | [Service] 7 | WorkingDirectory=/home/hebron/rpi-alarm/ 8 | ExecStart=/usr/bin/python /home/hebron/rpi-alarm/alarm.py 9 | Restart=on-failure 10 | User=hebron 11 | Environment=PYTHONUNBUFFERED=1 12 | 13 | [Install] 14 | WantedBy=default.target 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Security alarm system (RPi and MQTT) 2 | 3 | > A DIY security alarm system — powered by a Raspberry Pi. Supports hardwired and MQTT sensors. 4 | 5 | Project documentation available at: https://blog.cavelab.dev/2022/12/rpi-security-alarm/ 6 | 7 | ![Security alarm system (RPi and MQTT)](https://i.logistics.cavelab.net/large/2655.jpeg) 8 | 9 | ## Author 10 | **Thomas Jensen** 11 | * Twitter: [@thomasjsn](https://twitter.com/thomasjsn) 12 | * Github: [@thomasjsn](https://github.com/thomasjsn) 13 | * Website: [cavelab.dev](https://cavelab.dev) 14 | 15 | ## License 16 | The MIT License (MIT). Please see [license file](LICENSE.txt) for more information. 17 | 18 | --- 19 | _This README was automatically generated using µLogistics_ (`projectid:225`) 20 | -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | import paho.mqtt.publish as publish 2 | import configparser 3 | import argparse 4 | import json 5 | 6 | config = configparser.ConfigParser() 7 | config.read('config.ini') 8 | 9 | parser = argparse.ArgumentParser() 10 | todo_cmd = parser.add_mutually_exclusive_group(required=True) 11 | todo_cmd.add_argument('--action', dest='user_action', action='store', 12 | choices=["battery_test", "water_valve_test"], 13 | help="Trigger action") 14 | args = parser.parse_args() 15 | 16 | if __name__ == "__main__": 17 | mqtt_host = config.get("mqtt", "host") 18 | 19 | if args.user_action: 20 | mqtt_payload = json.dumps({"option": args.user_action, "value": True}) 21 | 22 | publish.single("home/alarm_test/action", mqtt_payload, hostname=mqtt_host) 23 | -------------------------------------------------------------------------------- /healthchecks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | import urllib.request 4 | import threading 5 | 6 | 7 | class HealthChecks: 8 | def __init__(self, hc_uuid: str): 9 | self.hc_uuid = hc_uuid 10 | self.lock = threading.Lock() 11 | 12 | def ping(self, start: bool = False) -> bool: 13 | if self.hc_uuid is None: 14 | return False 15 | 16 | ping_url = f"https://hc-ping.com/{self.hc_uuid}" 17 | 18 | if start: 19 | ping_url += "/start" 20 | 21 | try: 22 | urllib.request.urlopen(ping_url, timeout=10) 23 | return True 24 | 25 | except socket.error as e: 26 | logging.error("Healthchecks returned error: %s", e) 27 | return False 28 | 29 | def start(self) -> bool: 30 | ping_result = self.ping(True) 31 | 32 | if ping_result: 33 | self.lock.acquire() 34 | 35 | return ping_result 36 | 37 | def stop(self) -> bool: 38 | ping_result = self.ping() 39 | 40 | if ping_result: 41 | self.lock.release() 42 | 43 | return ping_result 44 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Thomas Jensen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /battery.py: -------------------------------------------------------------------------------- 1 | from scipy.interpolate import interp1d 2 | 3 | 4 | class Battery: 5 | # Source: https://www.rebel-cell.com/knowledge-base/battery-capacity/ 6 | capacity_voltage = { 7 | 100: 12.7, 8 | 90: 12.5, 9 | 80: 12.42, 10 | 70: 12.32, 11 | 60: 12.2, 12 | 50: 12.06, 13 | 40: 11.9, 14 | 30: 11.75, 15 | 20: 11.58, 16 | 10: 11.31, 17 | 0: 10.5 18 | } 19 | 20 | interpolate_levels = interp1d(list(capacity_voltage.keys()), list(capacity_voltage.values()), 'cubic') 21 | 22 | def __init__(self): 23 | self.percentage = [] 24 | self.battery_levels = {k: round(float(self.interpolate_levels(k)), 3) for k in range(101)} 25 | 26 | def level(self, input_voltage: float) -> int: 27 | for percentage, voltage in reversed(self.battery_levels.items()): 28 | if input_voltage >= voltage: 29 | self.percentage.append(percentage) 30 | 31 | if len(self.percentage) > 30: 32 | self.percentage.pop(0) 33 | 34 | return int(round(sum(self.percentage) / len(self.percentage), 0)) 35 | -------------------------------------------------------------------------------- /pushover.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | import http.client 4 | import urllib.parse 5 | 6 | 7 | class Pushover: 8 | def __init__(self, token: str, user: str): 9 | self.token = token 10 | self.user = user 11 | 12 | def _push(self, title:str, message: str, priority: int, data: dict) -> None: 13 | if priority == 2: 14 | data = { 15 | "sound": "alien", 16 | "priority": 2, 17 | "retry": 30, 18 | "expire": 3600 19 | } 20 | 21 | conn = http.client.HTTPSConnection("api.pushover.net:443") 22 | conn.request("POST", "/1/messages.json", 23 | urllib.parse.urlencode({ 24 | "token": self.token, 25 | "user": self.user, 26 | "title": title, 27 | "message": message, 28 | "timestamp": time.time(), 29 | "sound": "gamelan" 30 | } | data), {"Content-type": "application/x-www-form-urlencoded"}) 31 | conn.getresponse() 32 | 33 | def push(self, title: str, message: str, priority: int = 0, data: dict = None) -> None: 34 | if data is None: 35 | data = {} 36 | 37 | threading.Thread(target=self._push, args=(title, message, priority, data,)).start() 38 | -------------------------------------------------------------------------------- /arduino.py: -------------------------------------------------------------------------------- 1 | import serial 2 | import time 3 | import queue 4 | import threading 5 | import logging 6 | import statistics 7 | from dataclasses import dataclass, field 8 | 9 | ''' 10 | Inputs: 11 | 1. N/C 12 | 2. Siren relay (used internally for siren block) 13 | 3. Siren actual (after indoor siren and block relays) 14 | 4. Water valve switch 15 | 5. Water alarm reset button 16 | 17 | Outputs: 18 | 1. Siren block relay 19 | 2. Charger relay (NC) 20 | 3. Water valve relay 21 | 4. Dish washer relay (NC) 22 | 5. Aux 1 (Outdoor lights relay 1) 23 | 6. Aux 2 (Outdoor lights relay 2) 24 | 7. N/C 25 | 26 | Note: 27 | Inputs and outputs are read starting at 0, while outputs are changed starting at 1. 28 | Meaning output 1 is read as output[0] but changed with "o,1,x". 29 | ''' 30 | 31 | 32 | @dataclass 33 | class ArduinoData: 34 | battery_voltage: float = None 35 | aux12_voltage: float = None 36 | system_voltage: float = None 37 | temperature: float = None 38 | inputs: list[bool] = field(default_factory=list) 39 | outputs: list[bool] = field(default_factory=list) 40 | 41 | 42 | class Arduino: 43 | def __init__(self): 44 | self.data: ArduinoData = ArduinoData() 45 | self.commands: queue.Queue = queue.Queue() 46 | self.voltage1: list[float] = [] 47 | self.voltage2: list[float] = [] 48 | self.voltage3: list[float] = [] 49 | self.temperature: list[float] = [] 50 | self.timestamp: float = time.time() 51 | self.data_ready: threading.Event = threading.Event() 52 | 53 | def get_data(self) -> None: 54 | with serial.Serial('/dev/ttyUSB0', 9600, timeout=1) as ser: 55 | while True: 56 | self.data_ready.clear() 57 | # start_time = time.time() 58 | self._handle_commands(ser) 59 | 60 | ser.write(str.encode("s\n")) 61 | line = ser.readline() # read a '\n' terminated line 62 | received = line.decode('utf-8').strip() 63 | if received == "": 64 | continue 65 | 66 | # print(received) 67 | received = received.split("|") 68 | 69 | # Factors is voltage before and after voltage divider 70 | # R1 = 100k, R2 = 33k 71 | # Vout = (Vs x R2) / (R1 + R2) 72 | # Ratio = R2 / (R1 + R2) 73 | 74 | ai_voltage = 4.096 / 1024 75 | # ai_factor = [12.004 / 2.975, 12.004 / 2.979, 12.002 / 2.986] 76 | ai_factor = [12.004 / 2.975, 12.004 / 2.979, 5.001 / 1.244] 77 | ai_samples = 3 78 | 79 | self.voltage1.append(int(received[0]) * ai_voltage * ai_factor[0]) 80 | self.voltage2.append(int(received[1]) * ai_voltage * ai_factor[1]) 81 | self.voltage3.append(int(received[2]) * ai_voltage * ai_factor[2] + 0.03) 82 | self.temperature.append(float(received[3])) 83 | 84 | if len(self.voltage1) > ai_samples: 85 | self.voltage1.pop(0) 86 | if len(self.voltage2) > ai_samples: 87 | self.voltage2.pop(0) 88 | if len(self.voltage3) > ai_samples: 89 | self.voltage3.pop(0) 90 | if len(self.temperature) > ai_samples: 91 | self.temperature.pop(0) 92 | 93 | self.data.battery_voltage = round(statistics.mean(self.voltage1), 2) 94 | self.data.aux12_voltage = round(statistics.mean(self.voltage2), 2) 95 | self.data.system_voltage = round(statistics.mean(self.voltage3), 2) 96 | self.data.temperature = round(statistics.mean(self.temperature), 1) 97 | self.data.inputs = [not bool(int(received[4]) & (1 << n)) for n in range(5)] 98 | self.data.outputs = [bool(int(received[5]) & (1 << n)) for n in range(7)] 99 | 100 | self.timestamp = time.time() 101 | self.data_ready.set() 102 | # print(time.time() - start_time) 103 | 104 | def _handle_commands(self, ser: serial.Serial) -> None: 105 | while not self.commands.empty(): 106 | idx, value = self.commands.get() 107 | value_int = int(value is True) 108 | 109 | ser.write(str.encode(f"o,{idx},{value_int}\n")) 110 | logging.info("Arduino output %d set to %s", idx, value) 111 | self.commands.task_done() 112 | -------------------------------------------------------------------------------- /hass_discovery.py: -------------------------------------------------------------------------------- 1 | import json 2 | import paho.mqtt.client as mqtt 3 | from hass_entities import entities 4 | 5 | 6 | def discovery(client: mqtt.Client, zones, zone_timers) -> None: 7 | payload_common = { 8 | "state_topic": "home/alarm_test", 9 | "enabled_by_default": True, 10 | "availability": { 11 | "topic": "home/alarm_test/availability" 12 | }, 13 | "device": { 14 | "name": "RPi security alarm", 15 | "identifiers": 202146225, 16 | "model": "Raspberry Pi security alarm", 17 | "manufacturer": "The Cavelab" 18 | } 19 | } 20 | 21 | for entity in entities: 22 | payload = payload_common | { 23 | "name": entity.label, 24 | "unique_id": "rpi_alarm_" + entity.id 25 | } 26 | 27 | if entity.data_key is not None: 28 | payload = payload | { 29 | "value_template": "{{ value_json." + entity.data_key + " }}" 30 | } 31 | 32 | if entity.component == "binary_sensor": 33 | payload = payload | { 34 | "payload_off": False, 35 | "payload_on": True 36 | } 37 | 38 | if entity.component == "switch": 39 | payload = payload | { 40 | "payload_off": json.dumps({"option": entity.id, "value": False}), 41 | "payload_on": json.dumps({"option": entity.id, "value": True}), 42 | "state_off": False, 43 | "state_on": True, 44 | "command_topic": "home/alarm_test/config" 45 | } 46 | 47 | if entity.component == "button": 48 | payload = payload | { 49 | "payload_press": json.dumps({"option": entity.id, "value": True}), 50 | "command_topic": "home/alarm_test/action" 51 | } 52 | 53 | if entity.component == "valve": 54 | payload = payload | { 55 | "payload_close": json.dumps({"option": "water_valve_set", "value": False}), 56 | "payload_open": json.dumps({"option": "water_valve_set", "value": True}), 57 | "state_closed": False, 58 | "state_open": True, 59 | "command_topic": "home/alarm_test/action" 60 | } 61 | 62 | if entity.dev_class is not None: 63 | payload = payload | { 64 | "device_class": entity.dev_class 65 | } 66 | 67 | if entity.state_class is not None: 68 | payload = payload | { 69 | "state_class": entity.state_class 70 | } 71 | 72 | if entity.category is not None: 73 | payload = payload | { 74 | "entity_category": entity.category 75 | } 76 | 77 | if entity.icon is not None: 78 | payload = payload | { 79 | "icon": "mdi:" + entity.icon 80 | } 81 | 82 | if entity.unit is not None: 83 | payload = payload | { 84 | "unit_of_measurement": entity.unit 85 | } 86 | 87 | client.publish(f'homeassistant/{entity.component}/rpi_alarm/{entity.id}/config', 88 | json.dumps(payload), retain=True) 89 | 90 | for key, zone in zones.items(): 91 | if zone.dev_class.value is None: 92 | continue 93 | payload = payload_common | { 94 | "name": zone.label, 95 | "unique_id": "rpi_alarm_" + key, 96 | "device_class": zone.dev_class.value, 97 | "value_template": "{{ value_json.zones." + key + " }}", 98 | "payload_off": False, 99 | "payload_on": True, 100 | } 101 | 102 | client.publish(f'homeassistant/binary_sensor/rpi_alarm/{key}/config', 103 | json.dumps(payload), retain=True) 104 | 105 | for key, timer in zone_timers.items(): 106 | payload_binary_sensor = payload_common | { 107 | "name": timer.label + " timer", 108 | "unique_id": "rpi_alarm_timer_" + key, 109 | "value_template": "{{ value_json.zone_timers." + key + ".value }}", 110 | "json_attributes_topic": "home/alarm_test", 111 | "json_attributes_template": "{{ value_json.zone_timers." + key + ".attributes | tojson }}", 112 | "payload_off": False, 113 | "payload_on": True, 114 | "icon": "mdi:timer" 115 | } 116 | client.publish(f'homeassistant/binary_sensor/rpi_alarm/timer_{key}/config', 117 | json.dumps(payload_binary_sensor), retain=True) 118 | 119 | payload_button = payload_common | { 120 | "name": timer.label + " timer cancel", 121 | "unique_id": "rpi_alarm_timer_cancel_" + key, 122 | "payload_press": json.dumps({"option": "zone_timer_cancel", "value": key}), 123 | "command_topic": "home/alarm_test/action", 124 | "icon": "mdi:timer-cancel" 125 | } 126 | client.publish(f'homeassistant/button/rpi_alarm/timer_cancel_{key}/config', 127 | json.dumps(payload_button), retain=True) 128 | 129 | alarm_control_panel = payload_common | { 130 | "name": "Panel", 131 | "unique_id": "rpi_alarm_panel", 132 | "value_template": "{{ value_json.state }}", 133 | "command_topic": "home/alarm_test/set", 134 | "code": "REMOTE_CODE", 135 | "command_template": "{ \"action\": \"{{ action }}\", \"code\": \"{{ code }}\" }" 136 | } 137 | 138 | client.publish(f'homeassistant/alarm_control_panel/rpi_alarm/alarm_panel/config', 139 | json.dumps(alarm_control_panel), retain=True) 140 | -------------------------------------------------------------------------------- /hass_entities.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | 5 | @dataclass 6 | class Entity: 7 | id: str 8 | data_key: Optional[str] 9 | component: str 10 | label: str 11 | dev_class: str = None 12 | state_class: str = None 13 | unit: str = None 14 | category: str = None 15 | icon: str = None 16 | 17 | def __str__(self): 18 | return self.label 19 | 20 | 21 | entities = [ 22 | Entity( 23 | id="triggered", 24 | data_key="triggered", 25 | component="sensor", 26 | dev_class="enum", 27 | label="Triggered alarm", 28 | icon="alarm-bell", 29 | category="diagnostic" 30 | ), 31 | Entity( 32 | id="safe_to_arm", 33 | data_key="arm_not_ready", 34 | component="binary_sensor", 35 | dev_class="safety", 36 | label="Ready to arm", 37 | category="diagnostic" 38 | ), 39 | Entity( 40 | id="system_fault", 41 | data_key="fault", 42 | component="binary_sensor", 43 | dev_class="problem", 44 | label="System status", 45 | category="diagnostic" 46 | ), 47 | Entity( 48 | id="system_tamper", 49 | data_key="tamper", 50 | component="binary_sensor", 51 | dev_class="tamper", 52 | label="System tamper", 53 | category="diagnostic" 54 | ), 55 | Entity( 56 | id="system_temperature", 57 | data_key="temperature", 58 | component="sensor", 59 | dev_class="temperature", 60 | state_class="measurement", 61 | unit="°C", 62 | label="System temperature", 63 | category="diagnostic" 64 | ), 65 | Entity( 66 | id="battery_voltage", 67 | data_key="battery_voltage", 68 | component="sensor", 69 | dev_class="voltage", 70 | state_class="measurement", 71 | unit="V", 72 | label="Battery voltage", 73 | category="diagnostic" 74 | ), 75 | Entity( 76 | id="battery_level", 77 | data_key="battery_level", 78 | component="sensor", 79 | dev_class="battery", 80 | state_class="measurement", 81 | unit="%", 82 | label="Battery", 83 | category="diagnostic" 84 | ), 85 | Entity( 86 | id="battery_low", 87 | data_key="battery_low", 88 | component="binary_sensor", 89 | dev_class="battery", 90 | label="Battery low", 91 | category="diagnostic" 92 | ), 93 | Entity( 94 | id="battery_charging", 95 | data_key="battery_charging", 96 | component="binary_sensor", 97 | dev_class="battery_charging", 98 | label="Battery charging", 99 | category="diagnostic" 100 | ), 101 | Entity( 102 | id="battery_test_running", 103 | data_key="battery_test_running", 104 | component="binary_sensor", 105 | dev_class="running", 106 | label="Battery test", 107 | category="diagnostic" 108 | ), 109 | Entity( 110 | id="auxiliary_voltage", 111 | data_key="auxiliary_voltage", 112 | component="sensor", 113 | dev_class="voltage", 114 | state_class="measurement", 115 | unit="V", 116 | label="Auxiliary voltage", 117 | category="diagnostic" 118 | ), 119 | Entity( 120 | id="system_voltage", 121 | data_key="system_voltage", 122 | component="sensor", 123 | dev_class="voltage", 124 | state_class="measurement", 125 | unit="V", 126 | label="System voltage", 127 | category="diagnostic" 128 | ), 129 | Entity( 130 | id="walk_test", 131 | data_key="config.walk_test", 132 | component="switch", 133 | label="Walk test", 134 | icon="walk", 135 | category="config" 136 | ), 137 | Entity( 138 | id="door_open_warning", 139 | data_key="config.door_open_warning", 140 | component="switch", 141 | label="Door open warning", 142 | icon="door-open", 143 | category="config" 144 | ), 145 | Entity( 146 | id="door_chime", 147 | data_key="config.door_chime", 148 | component="switch", 149 | label="Door chime", 150 | icon="door-open", 151 | category="config" 152 | ), 153 | Entity( 154 | id="siren_test", 155 | data_key=None, 156 | component="button", 157 | label="Siren test", 158 | icon="bullhorn", 159 | category="diagnostic" 160 | ), 161 | Entity( 162 | id="battery_test", 163 | data_key=None, 164 | component="button", 165 | label="Battery test", 166 | icon="battery-clock", 167 | category="diagnostic" 168 | ), 169 | Entity( 170 | id="water_alarm_test", 171 | data_key=None, 172 | component="button", 173 | label="Water alarm test", 174 | icon="water-alert", 175 | category="diagnostic" 176 | ), 177 | Entity( 178 | id="fire_alarm_test", 179 | data_key=None, 180 | component="button", 181 | label="Fire alarm test", 182 | icon="fire-alert", 183 | category="diagnostic" 184 | ), 185 | Entity( 186 | id="zigbee_bridge", 187 | data_key="zigbee_bridge", 188 | component="binary_sensor", 189 | dev_class="connectivity", 190 | label="Zigbee bridge", 191 | category="diagnostic" 192 | ), 193 | Entity( 194 | id="reboot_required", 195 | data_key="reboot_required", 196 | component="binary_sensor", 197 | dev_class="update", 198 | label="Reboot required", 199 | category="diagnostic" 200 | ), 201 | Entity( 202 | id="water_valve", 203 | data_key="water_valve", 204 | component="valve", 205 | dev_class="water", 206 | label="Water valve" 207 | ), 208 | Entity( 209 | id="aux_output1", 210 | data_key="config.aux_output1", 211 | component="switch", 212 | label="Auxiliary output 1" 213 | ), 214 | Entity( 215 | id="aux_output2", 216 | data_key="config.aux_output2", 217 | component="switch", 218 | label="Auxiliary output 2" 219 | ) 220 | ] 221 | -------------------------------------------------------------------------------- /alarm.py: -------------------------------------------------------------------------------- 1 | import time 2 | import json 3 | import threading 4 | import logging 5 | import logging.handlers 6 | import datetime 7 | import paho.mqtt.client as mqtt 8 | import RPi.GPIO as GPIO 9 | import configparser 10 | import argparse 11 | import atexit 12 | import os 13 | import math 14 | import random 15 | import statistics 16 | from itertools import chain 17 | from dataclasses import dataclass, field 18 | from enum import Enum, auto 19 | from typing import Optional 20 | 21 | from pushover import Pushover 22 | import hass_discovery as hass 23 | from healthchecks import HealthChecks 24 | from arduino import Arduino 25 | from battery import Battery 26 | 27 | GPIO.setmode(GPIO.BCM) # set board mode to Broadcom 28 | GPIO.setwarnings(False) # don't show warnings 29 | 30 | config = configparser.ConfigParser() 31 | config.read('config.ini') 32 | 33 | parser = argparse.ArgumentParser() 34 | parser.add_argument('--silent', dest='silent', action='store_true', 35 | help="suppress siren outputs") 36 | parser.add_argument('--siren-block', dest='siren_block_relay', action='store_true', 37 | help="activate siren block relay") 38 | parser.add_argument('--payload', dest='print_payload', action='store_true', 39 | help="print payload on publish") 40 | parser.add_argument('--status', dest='print_status', action='store_true', 41 | help="print status object on publish") 42 | parser.add_argument('--serial', dest='print_serial', action='store_true', 43 | help="print serial data on receive") 44 | parser.add_argument('--timers', dest='print_timers', action='store_true', 45 | help="print timers debug") 46 | parser.add_argument('--log', dest='log_level', action='store', choices=["DEBUG", "INFO", "WARNING"], 47 | help="set log level") 48 | # parser.set_defaults(feature=True) 49 | args = parser.parse_args() 50 | 51 | 52 | class ArmMode(Enum): 53 | Home = auto() 54 | Away = auto() 55 | AwayDelayed = auto() 56 | Water = auto() 57 | Direct = auto() 58 | Fire = auto() 59 | Notify = auto() 60 | 61 | 62 | class AlarmState(Enum): 63 | Disarmed = "disarmed" 64 | ArmedHome = "armed_home" 65 | ArmedAway = "armed_away" 66 | Triggered = "triggered" 67 | Pending = "pending" 68 | Arming = "arming" 69 | 70 | 71 | class AlarmPanelAction(Enum): 72 | Disarm = auto() 73 | ArmHome = auto() 74 | ArmAway = auto() 75 | InvalidCode = auto() 76 | NotReady = auto() 77 | AlreadyDisarmed = auto() 78 | 79 | 80 | class SensorValue(Enum): 81 | Truthy = True 82 | Falsy = False 83 | On = "on" 84 | # Panic = "panic" 85 | Emergency = "emergency" 86 | 87 | 88 | class DevClass(Enum): 89 | Generic = None 90 | Tamper = "tamper" 91 | Motion = "motion" 92 | Door = "door" 93 | Moisture = "moisture" 94 | 95 | 96 | class ZoneAttribute(Enum): 97 | Chime = auto() 98 | OpenWarning = auto() 99 | 100 | def __repr__(self): 101 | return self.name 102 | 103 | 104 | class Zone: 105 | def __init__(self, key: str, label: str, dev_class: DevClass, 106 | arm_modes: list[ArmMode], attributes: list[ZoneAttribute] = None): 107 | self.key = key 108 | self.label = label 109 | self.dev_class = dev_class 110 | self.arm_modes = arm_modes 111 | self.attributes = attributes if attributes is not None else [] 112 | 113 | 114 | class Input(Zone): 115 | def __init__(self, key: str, gpio: int, label: str, dev_class: DevClass, 116 | arm_modes: list[ArmMode], attributes: list[ZoneAttribute] = None): 117 | super().__init__(key, label, dev_class, arm_modes, attributes) 118 | self.gpio = gpio 119 | 120 | def __str__(self): 121 | return self.label 122 | 123 | def __repr__(self): 124 | return f"i{self.gpio}:{self.label} {self.attributes}" 125 | 126 | def get(self): 127 | return GPIO.input(self.gpio) == 1 128 | 129 | @property 130 | def is_true(self): 131 | return self.get() 132 | 133 | 134 | class Output: 135 | def __init__(self, gpio: int, label: str, debug: bool = False): 136 | self.gpio = gpio 137 | self.label = label 138 | self.debug = debug 139 | 140 | def __str__(self): 141 | return self.label 142 | 143 | def set(self, value): 144 | if self.get() != value: 145 | if self in [outputs["siren1"], outputs["siren2"]] and args.silent and value: 146 | logging.debug("Suppressing %s, because silent", self) 147 | return 148 | 149 | GPIO.output(self.gpio, value) 150 | if self.debug: 151 | logging.debug("Output: %s set to %s", self, value) 152 | 153 | def get(self): 154 | return GPIO.input(self.gpio) == 1 155 | 156 | @property 157 | def is_true(self): 158 | return self.get() 159 | 160 | 161 | class Sensor(Zone): 162 | def __init__(self, key: str, topic: str, field: str, value: SensorValue, label: str, dev_class: DevClass, 163 | arm_modes: list[ArmMode], timeout: int = 0, attributes: list[ZoneAttribute] = None): 164 | super().__init__(key, label, dev_class, arm_modes, attributes) 165 | self.topic = topic 166 | self.field = field 167 | self.value = value 168 | self.timeout = timeout 169 | self.timestamp = time.time() 170 | self.linkquality = [] 171 | 172 | def __str__(self): 173 | return self.label 174 | 175 | def __repr__(self): 176 | return f"s:{self.label} {self.attributes}" 177 | 178 | def get(self): 179 | return state.data["zones"][self.key] 180 | 181 | @property 182 | def is_true(self): 183 | return self.get() 184 | 185 | 186 | # @dataclass 187 | # class Zones: 188 | # inputs: dict[str, Input] 189 | # sensors: dict[str, Sensor] 190 | # 191 | # @property 192 | # def all(self) -> dict[str, Zone]: 193 | # return self.inputs | self.sensors 194 | # 195 | # def get(self, zone_key): 196 | # return next((zone for key, zone in self.all if key == zone_key), None) 197 | 198 | 199 | class ZoneTimer: 200 | def __init__(self, key: str, zones: list[str], label: str, blocked_state: list[str]): 201 | self.key = key 202 | self.zones = zones 203 | self.zone_value = True 204 | self.label = label 205 | self.blocked_state = blocked_state 206 | self.timestamp = time.time() 207 | 208 | def __str__(self): 209 | return self.label 210 | 211 | @property 212 | def seconds(self): 213 | return config.getint("zone_timers", self.key, fallback=300) 214 | 215 | def cancel(self): 216 | self.timestamp = time.time() - self.seconds 217 | 218 | 219 | class AlarmPanel: 220 | def __init__(self, topic: str, fields: dict[str, str], actions: dict[AlarmPanelAction, str], label: str, 221 | set_states: dict[AlarmState, str] = None, timeout: int = 0): 222 | self.topic = topic 223 | self.fields = fields 224 | self.actions = actions 225 | self.label = label 226 | self.set_states = set_states or {} 227 | self.timeout = timeout 228 | self.timestamp = time.time() 229 | self.linkquality = [] 230 | 231 | def __str__(self): 232 | return self.label 233 | 234 | def __repr__(self): 235 | return f"p:{self.label}" 236 | 237 | def set(self, alarm_state: AlarmState): 238 | if alarm_state not in self.set_states: 239 | return 240 | 241 | logging.debug("Sending state: %s to alarm panel %s", self.set_states[alarm_state], self.label) 242 | data = {"arm_mode": {"mode": self.set_states[alarm_state]}} 243 | mqtt_client.publish(f"{self.topic}/set", json.dumps(data), retain=False) 244 | 245 | def validate(self, transaction: str, alarm_action: AlarmPanelAction): 246 | if transaction is None or alarm_action not in self.actions: 247 | return 248 | 249 | logging.debug("Sending verification: %s to alarm panel %s", self.actions[alarm_action], self.label) 250 | data = {"arm_mode": {"transaction": int(transaction), "mode": self.actions[alarm_action]}} 251 | mqtt_client.publish(f"{self.topic}/set", json.dumps(data), retain=False) 252 | 253 | 254 | inputs = { 255 | "ext_tamper": Input( 256 | key="ext_tamper", 257 | gpio=2, 258 | label="External tamper", 259 | dev_class=DevClass.Tamper, 260 | arm_modes=[ArmMode.Home, ArmMode.Away] 261 | ), 262 | "zone01": Input( 263 | key="zone01", 264 | gpio=3, 265 | label="1st floor hallway motion", 266 | dev_class=DevClass.Motion, 267 | arm_modes=[ArmMode.Away], 268 | ), 269 | # "zone02": Input(4), 270 | # "zone03": Input(17), 271 | # "zone04": Input(27), 272 | # "zone05": Input(14), 273 | # "zone06": Input(15), 274 | # "zone07": Input(18), 275 | # "zone08": Input(22), 276 | # "zone09": Input(23), 277 | # "1st_floor_tamper": Input( 278 | # gpio=24, 279 | # label="1st floor tamper", 280 | # dev_class="tamper", 281 | # arm_modes=[] 282 | # ), 283 | # "zone11": None, 284 | # "zone12": None, 285 | } 286 | 287 | outputs = { 288 | "led_red": Output( 289 | gpio=5, 290 | label="Red LED" 291 | ), 292 | "led_green": Output( 293 | gpio=6, 294 | label="Green LED" 295 | ), 296 | "buzzer": Output( 297 | gpio=16, 298 | label="Buzzer" 299 | ), 300 | "siren1": Output( 301 | gpio=19, 302 | label="Siren indoor", 303 | debug=True 304 | ), 305 | "siren2": Output( 306 | gpio=26, 307 | label="Siren outdoor", 308 | debug=True 309 | ), 310 | "door_chime": Output( 311 | gpio=13, 312 | label="Door chime" 313 | ), 314 | # "aux1": Output(20), 315 | # "aux2": Output(21) 316 | } 317 | 318 | sensors = { 319 | "door1": Sensor( 320 | key="door1", 321 | topic="zigbee2mqtt/Door front", 322 | field="contact", 323 | value=SensorValue.Falsy, 324 | label="Front door", 325 | dev_class=DevClass.Door, 326 | arm_modes=[ArmMode.Home, ArmMode.AwayDelayed], 327 | attributes=[ZoneAttribute.Chime, ZoneAttribute.OpenWarning], 328 | timeout=3600 329 | ), 330 | "door2": Sensor( 331 | key="door2", 332 | topic="zigbee2mqtt/Door back", 333 | field="contact", 334 | value=SensorValue.Falsy, 335 | label="Back door", 336 | dev_class=DevClass.Door, 337 | arm_modes=[ArmMode.Home, ArmMode.Away], 338 | attributes=[ZoneAttribute.Chime], 339 | timeout=3600 340 | ), 341 | "door3": Sensor( 342 | key="door3", 343 | topic="zigbee2mqtt/Door 2nd floor", 344 | field="contact", 345 | value=SensorValue.Falsy, 346 | label="2nd floor door", 347 | dev_class=DevClass.Door, 348 | arm_modes=[ArmMode.Home, ArmMode.Away], 349 | attributes=[ZoneAttribute.Chime], 350 | timeout=3600 351 | ), 352 | "motion1": Sensor( 353 | key="motion1", 354 | topic="hass2mqtt/binary_sensor/hue_motion_sensor_3_motion/state", 355 | field="value", 356 | value=SensorValue.On, 357 | label="Kitchen motion", 358 | dev_class=DevClass.Motion, 359 | arm_modes=[ArmMode.Away] 360 | ), 361 | "motion2": Sensor( 362 | key="motion2", 363 | topic="zigbee2mqtt/Motion living room", 364 | field="occupancy", 365 | value=SensorValue.Truthy, 366 | label="Living room motion", 367 | dev_class=DevClass.Motion, 368 | arm_modes=[ArmMode.Away], 369 | timeout=3600 370 | ), 371 | "motion3": Sensor( 372 | key="motion3", 373 | topic="hass2mqtt/binary_sensor/hue_motion_sensor_2_motion/state", 374 | field="value", 375 | value=SensorValue.On, 376 | label="Entryway motion", 377 | dev_class=DevClass.Motion, 378 | arm_modes=[ArmMode.AwayDelayed] 379 | ), 380 | "motion4": Sensor( 381 | key="motion4", 382 | topic="zigbee2mqtt/Motion 2nd floor hallway", 383 | field="occupancy", 384 | value=SensorValue.Truthy, 385 | label="2nd floor hallway motion", 386 | dev_class=DevClass.Motion, 387 | arm_modes=[ArmMode.Away], 388 | timeout=3600 389 | ), 390 | "motion5": Sensor( 391 | key="motion5", 392 | topic="hass2mqtt/binary_sensor/hue_motion_sensor_1_motion/state", 393 | field="value", 394 | value=SensorValue.On, 395 | label="Bathroom motion", 396 | dev_class=DevClass.Motion, 397 | arm_modes=[] 398 | ), 399 | "motion6": Sensor( 400 | key="motion6", 401 | topic="zigbee2mqtt/Motion master bedroom", 402 | field="occupancy", 403 | value=SensorValue.Truthy, 404 | label="Master bedroom motion", 405 | dev_class=DevClass.Motion, 406 | arm_modes=[ArmMode.Away], 407 | timeout=3600 408 | ), 409 | "motion7": Sensor( 410 | key="motion7", 411 | topic="zigbee2mqtt/Motion 2nd floor den", 412 | field="occupancy", 413 | value=SensorValue.Truthy, 414 | label="Motion 2nd floor den", 415 | dev_class=DevClass.Motion, 416 | arm_modes=[ArmMode.Away], 417 | timeout=3600 418 | ), 419 | "garage_motion1": Sensor( 420 | key="garage_motion1", 421 | topic="hass2mqtt/binary_sensor/garasje_pir_motion/state", 422 | field="value", 423 | value=SensorValue.On, 424 | label="Garage motion", 425 | dev_class=DevClass.Motion, 426 | arm_modes=[ArmMode.Notify] 427 | ), 428 | "garage_door1": Sensor( 429 | key="garage_door1", 430 | topic="zigbee2mqtt/Door garage side", 431 | field="contact", 432 | value=SensorValue.Falsy, 433 | label="Garage side door", 434 | dev_class=DevClass.Door, 435 | arm_modes=[ArmMode.Notify], 436 | timeout=3600 437 | ), 438 | "water_leak1": Sensor( 439 | key="water_leak1", 440 | topic="zigbee2mqtt/Water kitchen dishwasher", 441 | field="water_leak", 442 | value=SensorValue.Truthy, 443 | label="Kitchen dishwasher leak", 444 | dev_class=DevClass.Moisture, 445 | arm_modes=[ArmMode.Water], 446 | timeout=3600 447 | ), 448 | "water_leak2": Sensor( 449 | key="water_leak2", 450 | topic="zigbee2mqtt/Water kitchen sink", 451 | field="water_leak", 452 | value=SensorValue.Truthy, 453 | label="Kitchen sink leak", 454 | dev_class=DevClass.Moisture, 455 | arm_modes=[ArmMode.Water], 456 | timeout=3600 457 | ), 458 | "water_leak3": Sensor( 459 | key="water_leak3", 460 | topic="zigbee2mqtt/Water tap hatch", 461 | field="water_leak", 462 | value=SensorValue.Truthy, 463 | label="Outdoor tap hatch leak", 464 | dev_class=DevClass.Moisture, 465 | arm_modes=[ArmMode.Water], 466 | timeout=3600 467 | ), 468 | "water_leak4": Sensor( 469 | key="water_leak4", 470 | topic="zigbee2mqtt/Water home office", 471 | field="water_leak", 472 | value=SensorValue.Truthy, 473 | label="Home office drain leak", 474 | dev_class=DevClass.Moisture, 475 | arm_modes=[ArmMode.Water], 476 | timeout=3600 477 | ), 478 | "emergency1": Sensor( 479 | key="emergency1", 480 | topic="zigbee2mqtt/Panel entrance", 481 | field="action", 482 | value=SensorValue.Emergency, 483 | label="Emergency button entrance", 484 | dev_class=DevClass.Generic, 485 | arm_modes=[ArmMode.Direct] 486 | ), 487 | "emergency2": Sensor( 488 | key="emergency2", 489 | topic="zigbee2mqtt/Panel master bedroom", 490 | field="action", 491 | value=SensorValue.Emergency, 492 | label="Emergency button bedroom", 493 | dev_class=DevClass.Generic, 494 | arm_modes=[ArmMode.Direct] 495 | ), 496 | "fire_test": Sensor( 497 | key="fire_test", 498 | topic="home/alarm_test/test/fire", 499 | field="value", 500 | value=SensorValue.On, 501 | label="Fire test", 502 | dev_class=DevClass.Generic, 503 | arm_modes=[ArmMode.Fire] 504 | ) 505 | } 506 | 507 | zones = inputs | sensors 508 | # zones = Zones(inputs, sensors) 509 | 510 | home_zones = [v for k, v in zones.items() if ArmMode.Home in v.arm_modes] 511 | away_zones = [v for k, v in zones.items() if ArmMode.Away in v.arm_modes or ArmMode.AwayDelayed in v.arm_modes] 512 | water_zones = [v for k, v in zones.items() if ArmMode.Water in v.arm_modes] 513 | direct_zones = [v for k, v in zones.items() if ArmMode.Direct in v.arm_modes] 514 | fire_zones = [v for k, v in zones.items() if ArmMode.Fire in v.arm_modes] 515 | notify_zones = [v for k, v in zones.items() if ArmMode.Notify in v.arm_modes] 516 | 517 | codes = dict(config.items("codes")) 518 | 519 | 520 | zone_timers = { 521 | "hallway_motion": ZoneTimer( 522 | key="hallway_motion", 523 | zones=["zone01", "motion4"], 524 | # zone_value=True, 525 | label="Hallway motion", 526 | blocked_state=["armed_away"] 527 | ), 528 | "kitchen_motion": ZoneTimer( 529 | key="kitchen_motion", 530 | zones=["motion1"], 531 | # zone_value=True, 532 | label="Kitchen motion", 533 | blocked_state=["armed_away", "armed_home"] 534 | ) 535 | } 536 | 537 | alarm_panels = { 538 | "home_assistant": AlarmPanel( 539 | topic="home/alarm_test/set", 540 | fields={"action": "action", "code": "code"}, 541 | actions={ 542 | AlarmPanelAction.Disarm: "DISARM", 543 | AlarmPanelAction.ArmAway: "ARM_AWAY", 544 | AlarmPanelAction.ArmHome: "ARM_HOME" 545 | }, 546 | label="Home Assistant" 547 | ), 548 | "develco1": AlarmPanel( 549 | topic="zigbee2mqtt/Panel entrance", 550 | fields={"action": "action", "code": "action_code"}, 551 | actions={ 552 | AlarmPanelAction.Disarm: "disarm", 553 | AlarmPanelAction.ArmAway: "arm_all_zones", 554 | AlarmPanelAction.ArmHome: "arm_day_zones", 555 | AlarmPanelAction.InvalidCode: "invalid_code", 556 | AlarmPanelAction.NotReady: "not_ready", 557 | AlarmPanelAction.AlreadyDisarmed: "not_ready" 558 | }, 559 | label="Entrance alarm panel", 560 | set_states={ 561 | AlarmState.Disarmed: "disarm", 562 | AlarmState.ArmedHome: "arm_day_zones", 563 | AlarmState.ArmedAway: "arm_all_zones", 564 | AlarmState.Triggered: "in_alarm", 565 | AlarmState.Pending: "entry_delay", 566 | AlarmState.Arming: "exit_delay" 567 | }, 568 | timeout=3600 569 | ), 570 | "develco2": AlarmPanel( 571 | topic="zigbee2mqtt/Panel master bedroom", 572 | fields={"action": "action", "code": "action_code"}, 573 | actions={ 574 | AlarmPanelAction.Disarm: "disarm", 575 | AlarmPanelAction.ArmAway: "arm_all_zones", 576 | AlarmPanelAction.ArmHome: "arm_day_zones", 577 | AlarmPanelAction.InvalidCode: "invalid_code", 578 | AlarmPanelAction.NotReady: "not_ready", 579 | AlarmPanelAction.AlreadyDisarmed: "not_ready" 580 | }, 581 | label="Master bedroom alarm panel", 582 | set_states={ 583 | AlarmState.Disarmed: "disarm", 584 | AlarmState.ArmedHome: "arm_day_zones", 585 | AlarmState.ArmedAway: "arm_all_zones", 586 | AlarmState.Triggered: "in_alarm", 587 | AlarmState.Pending: "entry_delay", 588 | AlarmState.Arming: "exit_delay" 589 | }, 590 | timeout=3600 591 | ) 592 | } 593 | 594 | logging_format = "%(asctime)s - %(levelname)s: %(message)s" 595 | logging.basicConfig(format=logging_format, level=logging.DEBUG, datefmt="%H:%M:%S") 596 | 597 | battery_log = logging.getLogger("battery") 598 | battery_log_handler = logging.FileHandler('logs/battery.log') 599 | battery_log_handler.setFormatter(logging.Formatter(logging_format)) 600 | battery_log.addHandler(battery_log_handler) 601 | 602 | # rpi_gpio_log = logging.getLogger("rpi_gpio") 603 | # rpi_gpio_log_file_handler = logging.handlers.RotatingFileHandler('logs/rpi_gpio.log', 604 | # maxBytes=200*1000, backupCount=5) 605 | # rpi_gpio_log_file_handler.setFormatter(logging.Formatter(logging_format)) 606 | # rpi_gpio_log_mem_handler = logging.handlers.MemoryHandler(50, target=rpi_gpio_log_file_handler) 607 | # rpi_gpio_log_mem_handler.setFormatter(logging.Formatter(logging_format)) 608 | # rpi_gpio_log.addHandler(rpi_gpio_log_mem_handler) 609 | 610 | if args.log_level: 611 | logging.getLogger().setLevel(args.log_level) 612 | logging.info("Log level set to %s", args.log_level) 613 | 614 | for gpio_input in inputs.values(): 615 | GPIO.setup(gpio_input.gpio, GPIO.IN) 616 | 617 | for gpio_output in outputs.values(): 618 | GPIO.setup(gpio_output.gpio, GPIO.OUT) 619 | gpio_output.set(False) 620 | 621 | 622 | def wrapping_up() -> None: 623 | for output in outputs.values(): 624 | output.set(False) 625 | 626 | logging.info("All outputs set to False") 627 | 628 | 629 | atexit.register(wrapping_up) 630 | 631 | 632 | @dataclass 633 | class StateData: 634 | arm_not_ready: bool = None 635 | auxiliary_voltage: float = None 636 | battery_charging: bool = None 637 | battery_level: int = None 638 | battery_low: bool = None 639 | battery_test_running: bool = None 640 | battery_voltage: float = None 641 | system_voltage: float = None 642 | config: dict[str, bool] = field(default_factory=dict) 643 | fault: bool = None 644 | reboot_required: bool = None 645 | state: str = None 646 | tamper: bool = None 647 | temperature: float = None 648 | triggered: Optional[str] = None 649 | water_valve: bool = None 650 | zigbee_bridge: bool = None 651 | zone_timers: dict[str, dict] = field(default_factory=dict) 652 | zones: dict[str, Optional[bool]] = field(default_factory=dict) 653 | 654 | def __getitem__(self, item): 655 | return getattr(self, item) 656 | 657 | def __setitem__(self, item, value): 658 | if not hasattr(self, item): 659 | logging.warning("Setting undefined attribute on state data object: %s", item) 660 | setattr(self, item, value) 661 | 662 | 663 | class State: 664 | def __init__(self): 665 | self.data: StateData = StateData( 666 | state=config.get("system", "state"), 667 | config={ 668 | "walk_test": config.getboolean("config", "walk_test", fallback=False), 669 | "door_open_warning": config.getboolean("config", "door_open_warning", fallback=True), 670 | "door_chime": config.getboolean("config", "door_chime", fallback=False), 671 | "aux_output1": config.getboolean("config", "aux_output1", fallback=False), 672 | "aux_output2": config.getboolean("config", "aux_output2", fallback=False) 673 | }, 674 | zones={k: None for k, v in zones.items()}, 675 | zone_timers={k: {"value": None, "attributes": {"seconds": v.seconds}} for k, v in zone_timers.items()}, 676 | ) 677 | self._lock: threading.Lock = threading.Lock() 678 | self._faults: list[str] = ["mqtt_connected"] 679 | self.blocked: set[Zone] = set() 680 | self.status: dict[str, bool] = {} 681 | self.code_attempts: int = 0 682 | self.zones_open: set[Zone] = set() 683 | self.notify_timestamps: dict[Zone, time] = {v: time.time() for v in notify_zones} 684 | 685 | def json(self) -> str: 686 | return json.dumps(self.data.__dict__) 687 | 688 | def publish(self) -> None: 689 | mqtt_client.publish("home/alarm_test/availability", "online", retain=True) 690 | mqtt_client.publish('home/alarm_test', self.json(), retain=True) 691 | 692 | if args.print_payload: 693 | print(json.dumps(self.data.__dict__, indent=2, sort_keys=True)) 694 | 695 | if args.print_status: 696 | print(json.dumps(self.status, indent=2, sort_keys=True)) 697 | 698 | @property 699 | def system(self) -> str: 700 | return self.data["state"] 701 | 702 | @system.setter 703 | def system(self, alarm_state: str) -> None: 704 | if alarm_state not in [e.value for e in AlarmState]: 705 | raise ValueError(f"State: {alarm_state} is not valid") 706 | 707 | with self._lock: 708 | logging.warning("System state changed to: %s", alarm_state) 709 | 710 | # if (state == "armed_away" and self.data["state"] == "triggered") or state == "disarmed": 711 | if alarm_state in ["disarmed", "armed_home", "armed_away"]: 712 | self.code_attempts = 0 713 | self.data["triggered"] = None 714 | 715 | if len(self.zones_open) > 0: 716 | logging.info("Clearing open zones: %s", self.zones_open) 717 | self.zones_open.clear() 718 | 719 | self.data["state"] = alarm_state 720 | self.publish() 721 | 722 | if alarm_state in ["disarmed", "armed_home", "armed_away"]: 723 | with open('config.ini', 'w') as configfile: 724 | config.set("system", "state", alarm_state) 725 | config.write(configfile) 726 | 727 | for panel in [v for k, v in alarm_panels.items() if v.set_states]: 728 | panel.set(AlarmState(alarm_state)) 729 | 730 | def zone(self, zone_key: str, value: bool) -> None: 731 | zone = zones[zone_key] 732 | 733 | if self.data["zones"][zone_key] != value: 734 | self.data["zones"][zone_key] = value 735 | logging.info("Zone: %s changed to %s", zone, value) 736 | 737 | for timer_key, timer in zone_timers.items(): 738 | if zone_key in timer.zones: 739 | # logging.debug("Zone: %s found in timer %s", zone, timer_key) 740 | self.zone_timer(timer_key) 741 | 742 | if value and state.data["config"]["walk_test"]: 743 | threading.Thread(target=buzzer_signal, args=(2, [0.2, 0.2])).start() 744 | 745 | if (value and state.data["config"]["door_chime"] and ZoneAttribute.Chime in zone.attributes 746 | and not state.data["config"]["walk_test"] and self.system == "disarmed" 747 | and not door_chime_lock.locked()): 748 | threading.Thread(target=door_chime, args=()).start() 749 | 750 | if value and self.system in ["triggered", "armed_home", "armed_away"]: 751 | if zone in notify_zones and (time.time() - self.notify_timestamps[zone] > 180): 752 | pushover.push("Notify zone is open", str(zone), 1) 753 | self.notify_timestamps[zone] = time.time() 754 | 755 | tamper_zones = {k: v.get() for k, v in zones.items() if v.dev_class == DevClass.Tamper} 756 | state.data["tamper"] = any(tamper_zones.values()) 757 | 758 | for tamper_key, tamper_status in tamper_zones.items(): 759 | state.status[f"{tamper_key}"] = not tamper_status 760 | 761 | clear = not any([o.get() for o in away_zones]) 762 | self.data["arm_not_ready"] = not clear 763 | 764 | self.publish() 765 | 766 | if zone in self.blocked and value is False: 767 | self.blocked.remove(zone) 768 | logging.debug("Blocked zones: %s", self.blocked) 769 | 770 | def fault(self) -> None: 771 | faults = [k for k, v in self.status.items() if not v] 772 | 773 | if self._faults != faults: 774 | self.data["fault"] = bool(faults) 775 | self._faults = faults 776 | self.publish() 777 | 778 | if faults: 779 | faulted_status = ", ".join(faults).upper() 780 | logging.error("System check(s) failed: %s", faulted_status) 781 | pushover.push("System check(s) failed", faulted_status) 782 | else: 783 | logging.info("System status restored") 784 | pushover.push("System status restored", "All checks are OK") 785 | 786 | def zone_timer(self, timer_key: str) -> None: 787 | timer = zone_timers[timer_key] 788 | timer_zones = [v for k, v in self.data["zones"].items() if k in timer.zones] 789 | # print(json.dumps(timer_zones, indent=2, sort_keys=True)) 790 | 791 | # if timer.zone_value: 792 | # zone_state = any(timer_zones) 793 | # else: 794 | # zone_state = not any(timer_zones) 795 | 796 | zone_state = any(timer_zones) 797 | 798 | if zone_state: 799 | timer.timestamp = time.time() 800 | 801 | if state.system in timer.blocked_state: 802 | timer.cancel() 803 | 804 | last_msg_s = round(time.time() - timer.timestamp) 805 | value = last_msg_s < timer.seconds 806 | 807 | # if not timer.zone_value: 808 | # value = not value 809 | 810 | if self.data["zone_timers"][timer_key]["value"] != value: 811 | self.data["zone_timers"][timer_key]["value"] = value 812 | logging.info("Zone timer: %s changed to %s", timer, value) 813 | self.publish() 814 | 815 | if args.print_timers and value: 816 | print(f"{timer}: {datetime.timedelta(seconds=timer.seconds-last_msg_s)}") 817 | 818 | 819 | def buzzer(seconds: int, current_state: str) -> bool: 820 | logging.info("Buzzer loop started (%d seconds)", seconds) 821 | start_time = time.time() 822 | 823 | while (start_time + seconds) > time.time(): 824 | if current_state == "arming": 825 | if any([o.get() for o in home_zones]): 826 | buzzer_signal(1, [0.2, 0.8]) 827 | else: 828 | buzzer_signal(1, [0.05, 0.95]) 829 | 830 | if current_state == "pending": 831 | if (start_time + (seconds/2)) > time.time(): 832 | buzzer_signal(1, [0.05, 0.95]) 833 | else: 834 | buzzer_signal(2, [0.05, 0.45]) 835 | 836 | if state.system != current_state: 837 | logging.info("Buzzer loop aborted") 838 | return False 839 | 840 | logging.info("Buzzer loop completed") 841 | return True 842 | 843 | 844 | def buzzer_signal(repeat: int, duration: list[float]) -> None: 845 | with buzzer_lock: 846 | if len(duration) > 2: 847 | time.sleep(duration[2]) 848 | for _ in range(repeat): 849 | outputs["buzzer"].set(True) 850 | time.sleep(duration[0]) 851 | outputs["buzzer"].set(False) 852 | time.sleep(duration[1]) 853 | 854 | 855 | def siren(seconds: int, zone: Zone, current_state: str) -> bool: 856 | logging.info("Siren loop started (%d seconds, %s, %s)", 857 | seconds, zone, current_state) 858 | start_time = time.time() 859 | # zones_open = len(state.zones_open) 860 | 861 | while (start_time + seconds) > time.time(): 862 | # ANSI S3.41-1990; Temporal Three or T3 pattern 863 | # Indoor siren uses about 0.2 seconds to react 864 | if zone in fire_zones: 865 | for _ in range(3): 866 | outputs["siren1"].set(True) 867 | time.sleep(0.7) 868 | outputs["siren1"].set(False) 869 | time.sleep(0.3) 870 | time.sleep(1) 871 | 872 | elif zone in water_zones: 873 | outputs["siren1"].set(True) 874 | time.sleep(0.5) 875 | outputs["siren1"].set(False) 876 | time.sleep(10) 877 | 878 | else: 879 | outputs["siren1"].set(True) 880 | # outputs["beacon"].set(True) 881 | 882 | if ((time.time()-start_time) > (seconds/3) and len(state.zones_open) > 1) or zone in direct_zones: 883 | outputs["siren2"].set(True) 884 | time.sleep(1) 885 | 886 | if state.system != current_state: 887 | outputs["siren1"].set(False) 888 | outputs["siren2"].set(False) 889 | # outputs["beacon"].set(False) 890 | logging.info("Siren loop aborted") 891 | 892 | return False 893 | 894 | # if len(state.zones_open) > zones_open: 895 | # logging.warning("Open triggered zones increased, extending trigger time") 896 | # logging.debug("Trigger time increased by: %d seconds", time.time() - start_time) 897 | # start_time = time.time() 898 | # zones_open = len(state.zones_open) 899 | 900 | outputs["siren1"].set(False) 901 | outputs["siren2"].set(False) 902 | # outputs["beacon"].set(False) 903 | logging.info("Siren loop completed") 904 | 905 | return True 906 | 907 | 908 | def arming(user: str) -> None: 909 | state.system = "arming" 910 | arming_time = config.getint("times", "arming") 911 | 912 | if args.silent: 913 | arming_time = 10 914 | 915 | if buzzer(arming_time, "arming"): 916 | active_away_zones1 = [o.label for o in away_zones if o.get() and o.dev_class != DevClass.Motion] 917 | active_away_zones2 = [o for o in away_zones if o.get() and o.dev_class == DevClass.Motion] 918 | 919 | if active_away_zones1: 920 | logging.error("Arm away failed, not clear: %s", active_away_zones1) 921 | 922 | active_away_zones1_str = ", ".join(active_away_zones1) 923 | pushover.push("Arm away failed", f"Not clear: {active_away_zones1_str}", 1, {"sound": "siren"}) 924 | 925 | state.system = "disarmed" 926 | buzzer_signal(1, [1, 0]) 927 | return 928 | 929 | if active_away_zones2: 930 | state.blocked.update(active_away_zones2) 931 | logging.warning("Suppressed zones: %s", state.blocked) 932 | 933 | active_away_zones2_str = ", ".join([o.label for o in active_away_zones2]) 934 | pushover.push("Away zone(s) not clear", f"Suppressed: {active_away_zones2_str}") 935 | 936 | state.system = "armed_away" 937 | pushover.push("System armed away", f"User: {user}") 938 | 939 | 940 | def pending(current_state: str, zone: Zone) -> None: 941 | delay_time = config.getint("times", "delay") 942 | 943 | if args.silent: 944 | delay_time = 10 945 | 946 | with pending_lock: 947 | state.system = "pending" 948 | logging.info("Pending because of zone: %s", zone) 949 | 950 | if buzzer(delay_time, "pending"): 951 | triggered(current_state, zone) 952 | 953 | 954 | def triggered(current_state: str, zone: Zone) -> None: 955 | trigger_time = config.getint("times", "trigger") 956 | 957 | if args.silent: 958 | trigger_time = 30 959 | 960 | with triggered_lock: 961 | if zone in fire_zones: 962 | state.data["triggered"] = "Fire" 963 | elif zone in water_zones: 964 | state.data["triggered"] = "Water leak" 965 | elif zone in direct_zones: 966 | state.data["triggered"] = "Emergency" 967 | else: 968 | state.data["triggered"] = "Intrusion" 969 | 970 | state.system = "triggered" 971 | logging.warning("Triggered because of %s, zone: %s", state.data.triggered, zone) 972 | pushover.push(state.data.triggered, str(zone), 2) 973 | 974 | state.blocked.add(zone) 975 | logging.debug("Blocked zones: %s", state.blocked) 976 | 977 | if siren(trigger_time, zone, "triggered"): 978 | state.system = current_state 979 | 980 | 981 | def disarmed(user: str) -> None: 982 | state.system = "disarmed" 983 | pushover.push("System disarmed", f"User: {user}") 984 | buzzer_signal(2, [0.05, 0.15]) 985 | 986 | 987 | def armed_home(user: str) -> None: 988 | active_home_zones = [o.label for o in home_zones if o.get()] 989 | 990 | if active_home_zones: 991 | logging.error("Arm home failed, not clear: %s", active_home_zones) 992 | 993 | active_home_zones_str = ", ".join(active_home_zones) 994 | pushover.push("Arm home failed", f"Not clear: {active_home_zones_str}", 1, {"sound": "siren"}) 995 | 996 | state.system = "disarmed" 997 | buzzer_signal(1, [1, 0]) 998 | return 999 | 1000 | state.system = "armed_home" 1001 | pushover.push("System armed home", f"User: {user}") 1002 | buzzer_signal(1, [0.05, 0.05]) 1003 | 1004 | 1005 | def water_alarm() -> None: 1006 | with water_alarm_lock: 1007 | water_alarm_time = time.time() 1008 | logging.warning("Entered water alarm lock!") 1009 | 1010 | arduino.commands.put([3, True]) # Water valve relay 1011 | arduino.commands.put([4, True]) # Dishwasher relay (NC) 1012 | 1013 | # Keep in loop until manually reset 1014 | while not arduino.data.inputs[4]: 1015 | if math.floor(time.time() - water_alarm_time) % 30 == 0: 1016 | buzzer_signal(1, [0.5, 0.5]) 1017 | buzzer_signal(2, [0.1, 0.2]) 1018 | else: 1019 | time.sleep(1) 1020 | 1021 | logging.info("Leaving water alarm lock.") 1022 | 1023 | # Turn water back on if manual switch enabled 1024 | if arduino.data.inputs[3]: 1025 | arduino.commands.put([3, False]) # Water valve relay 1026 | 1027 | arduino.commands.put([4, False]) # Dishwasher relay (NC) 1028 | 1029 | 1030 | def run_led() -> None: 1031 | while True: 1032 | run_led_output = "led_red" if state.data["fault"] else "led_green" 1033 | 1034 | if state.system == "disarmed": 1035 | time.sleep(1.5) 1036 | else: 1037 | time.sleep(0.5) 1038 | 1039 | outputs[run_led_output].set(True) 1040 | time.sleep(0.5) 1041 | outputs[run_led_output].set(False) 1042 | 1043 | 1044 | def check_zone(zone: Zone) -> None: 1045 | if zone in fire_zones or (state.system != "armed_away" and zone in direct_zones): 1046 | if not triggered_lock.locked(): 1047 | threading.Thread(target=triggered, args=(state.system, zone,)).start() 1048 | 1049 | if zone in water_zones: 1050 | if not triggered_lock.locked(): 1051 | threading.Thread(target=water_alarm, args=()).start() 1052 | threading.Thread(target=triggered, args=(state.system, zone,)).start() 1053 | 1054 | if zone in state.blocked: 1055 | return 1056 | 1057 | if state.system in ["armed_away", "pending"] and zone in away_zones: 1058 | if ArmMode.AwayDelayed in zone.arm_modes and not pending_lock.locked(): 1059 | threading.Thread(target=pending, args=("armed_away", zone,)).start() 1060 | if ArmMode.Away in zone.arm_modes and not triggered_lock.locked(): 1061 | threading.Thread(target=triggered, args=("armed_away", zone,)).start() 1062 | 1063 | if state.system == "armed_home" and zone in home_zones: 1064 | if not triggered_lock.locked(): 1065 | threading.Thread(target=triggered, args=("armed_home", zone,)).start() 1066 | 1067 | if state.system in ["armed_away", "pending", "triggered"] and zone in away_zones: 1068 | zones_open_count = len(state.zones_open) 1069 | state.zones_open.add(zone) 1070 | 1071 | if len(state.zones_open) > zones_open_count: 1072 | logging.info("Added zone to list of open zones: %s", zone) 1073 | if len(state.zones_open) > 1 and state.system == "triggered": 1074 | zones_open_str = ", ".join([o.label for o in state.zones_open]) 1075 | pushover.push("Multiple zones triggered", zones_open_str, 1) 1076 | 1077 | 1078 | # The callback for when the client receives a CONNACK response from the server. 1079 | def on_connect(client: mqtt.Client, userdata, flags: dict[str, int], rc: int) -> None: 1080 | logging.info("Connected to MQTT broker with result code %s", rc) 1081 | 1082 | # Subscribing in on_connect() means that if we lose the connection and 1083 | # reconnect then subscriptions will be renewed. 1084 | 1085 | topics = set() 1086 | topics.add("zigbee2mqtt/bridge/state") 1087 | 1088 | for option in ["config", "action"]: 1089 | topics.add(f"home/alarm_test/{option}") 1090 | 1091 | for panel in alarm_panels.values(): 1092 | topics.add(panel.topic) 1093 | 1094 | for sensor in sensors.values(): 1095 | topics.add(sensor.topic) 1096 | 1097 | topic_tuples = [(topic, 0) for topic in topics] 1098 | logging.debug("Topics: %s", topic_tuples) 1099 | 1100 | client.subscribe(topic_tuples) 1101 | 1102 | if rc == 0: 1103 | client.connected_flag = True 1104 | state.status["mqtt_connected"] = True 1105 | hass.discovery(client, zones, zone_timers) 1106 | else: 1107 | client.bad_connection_flag = True 1108 | print("Bad connection, returned code: ", str(rc)) 1109 | 1110 | 1111 | def on_disconnect(client: mqtt.Client, userdata, rc: int) -> None: 1112 | logging.warning("Disconnecting reason %s", rc) 1113 | client.connected_flag = False 1114 | state.status["mqtt_connected"] = False 1115 | client.disconnect_flag = True 1116 | 1117 | 1118 | # The callback for when a PUBLISH message is received from the server. 1119 | def on_message(client: mqtt.Client, userdata, msg: mqtt.MQTTMessage) -> None: 1120 | logging.debug("Received message: %s %s", msg.topic, msg.payload.decode('utf-8')) 1121 | 1122 | if msg.payload.decode('utf-8') == "": 1123 | logging.warning("Received empty payload, discarded") 1124 | return 1125 | 1126 | try: 1127 | y = json.loads(str(msg.payload.decode('utf-8'))) 1128 | except json.JSONDecodeError: 1129 | y = {"value": msg.payload.decode('utf-8')} 1130 | logging.debug("Unable to decode JSON, created object %s", y) 1131 | 1132 | if msg.topic == "zigbee2mqtt/bridge/state" and "state" in y: 1133 | state.status["zigbee_bridge"] = y["state"] == "online" 1134 | state.data["zigbee_bridge"] = state.status["zigbee_bridge"] 1135 | return 1136 | 1137 | if msg.topic == "home/alarm_test/config" and all(k in y for k in ("option", "value")): 1138 | cfg_option = y["option"] 1139 | cfg_value = y["value"] 1140 | 1141 | with open('config.ini', 'w') as configfile: 1142 | config.set('config', cfg_option, str(cfg_value)) 1143 | config.write(configfile) 1144 | 1145 | logging.info("Config option: %s changed to %s", cfg_option, cfg_value) 1146 | state.data["config"][cfg_option] = cfg_value 1147 | state.publish() 1148 | return 1149 | 1150 | if msg.topic == "home/alarm_test/action" and all(k in y for k in ("option", "value")): 1151 | act_option = y["option"] 1152 | act_value = y["value"] 1153 | 1154 | logging.info("Action triggered: %s, with value: %s", act_option, act_value) 1155 | 1156 | if act_option == "siren_test" and act_value: 1157 | # arduino.commands.put([1, True]) # Siren block relay 1158 | with pending_lock: 1159 | buzzer_signal(7, [0.1, 0.9]) 1160 | buzzer_signal(1, [2.5, 0.5]) 1161 | with triggered_lock: 1162 | siren_test_zones = [v for k, v in zones.items() if v.dev_class == DevClass.Tamper] 1163 | if siren_test_zones and len(zones) > 2: 1164 | state.zones_open.update(list(zones.values())[:2]) 1165 | siren(3, siren_test_zones[0], "disarmed") # use first tamper zone to test 1166 | # state.zones_open.clear() 1167 | else: 1168 | logging.error("Not enough zones defined, unable to run siren test!") 1169 | # arduino.commands.put([1, False]) # Siren block relay 1170 | 1171 | if act_option == "zone_timer_cancel" and act_value in zone_timers: 1172 | timer = zone_timers[act_value] 1173 | timer.cancel() 1174 | 1175 | if act_option == "battery_test" and act_value: 1176 | if not battery_test_lock.locked(): 1177 | threading.Thread(target=battery_test, args=(), daemon=True).start() 1178 | else: 1179 | logging.error("Battery test already running!") 1180 | 1181 | if act_option == "water_valve_test" and act_value: 1182 | if not water_valve_test_lock.locked(): 1183 | threading.Thread(target=water_valve_test, args=()).start() 1184 | else: 1185 | logging.error("Water valve test already running!") 1186 | 1187 | if act_option == "water_alarm_test" and act_value: 1188 | with pending_lock: 1189 | buzzer_signal(7, [0.1, 0.9]) 1190 | buzzer_signal(1, [2.5, 0.5]) 1191 | if water_zones: 1192 | check_zone(random.choice(water_zones)) # use random water sensor to test 1193 | else: 1194 | logging.error("No water zones defined, unable to run water alarm test!") 1195 | 1196 | if act_option == "fire_alarm_test" and act_value: 1197 | with pending_lock: 1198 | buzzer_signal(7, [0.1, 0.9]) 1199 | buzzer_signal(1, [2.5, 0.5]) 1200 | if water_zones: 1201 | check_zone(random.choice(fire_zones)) # use random fire sensor to test 1202 | else: 1203 | logging.error("No fire zones defined, unable to run fire alarm test!") 1204 | 1205 | if act_option == "water_valve_set": 1206 | arduino.commands.put([3, not act_value]) 1207 | # logging.info("Water valve action: %s", act_value) 1208 | 1209 | return 1210 | 1211 | for key, panel in alarm_panels.items(): 1212 | if msg.topic == panel.topic: 1213 | panel.timestamp = time.time() 1214 | 1215 | if "battery" in y: 1216 | if isinstance(y["battery"], (int, float)): 1217 | # logging.debug("Found battery level %s on panel %s", y["battery"], panel) 1218 | state.status[f"{panel.label.replace(' ', '_')}_bat"] = int(y["battery"]) > 20 1219 | 1220 | if "linkquality" in y: 1221 | if isinstance(y["linkquality"], (int, float)): 1222 | # logging.debug("Found link quality %s on panel %s", y["linkquality"], panel) 1223 | panel.linkquality.append(int(y["linkquality"])) 1224 | 1225 | if len(panel.linkquality) > 10: 1226 | panel.linkquality.pop(0) 1227 | 1228 | if len(panel.linkquality) > 1: 1229 | state.status[f"{panel.label.replace(' ', '_')}_lqi"] = statistics.median(panel.linkquality) > 0 1230 | # print(panel.linkquality, statistics.median(panel.linkquality), statistics.stdev(panel.linkquality)) 1231 | 1232 | if msg.topic == panel.topic and panel.fields["action"] in y: 1233 | action = y[panel.fields["action"]] 1234 | code = y.get(panel.fields["code"]) 1235 | code_str = str(code).lower() 1236 | action_transaction = y.get("action_transaction") 1237 | 1238 | if msg.retain == 1: 1239 | logging.warning("Discarding action: %s, in retained message from alarm panel: %s", action, panel) 1240 | continue 1241 | 1242 | # if panel.emergency and action == panel.emergency: 1243 | # logging.warning(f"Emergency from panel: {panel.label}") 1244 | 1245 | if code_str in codes: 1246 | user = codes[code_str] 1247 | logging.info("Panel action, %s: %s by %s (%s)", panel, action, user, action_transaction) 1248 | 1249 | if action == panel.actions[AlarmPanelAction.Disarm]: 1250 | if state.system == "disarmed": 1251 | panel.validate(action_transaction, AlarmPanelAction.AlreadyDisarmed) 1252 | else: 1253 | panel.validate(action_transaction, AlarmPanelAction.Disarm) 1254 | threading.Thread(target=disarmed, args=(user,)).start() 1255 | 1256 | elif action == panel.actions[AlarmPanelAction.ArmAway]: 1257 | panel.validate(action_transaction, AlarmPanelAction.ArmAway) 1258 | threading.Thread(target=arming, args=(user,)).start() 1259 | 1260 | elif action == panel.actions[AlarmPanelAction.ArmHome]: 1261 | if any([o.get() for o in home_zones]): 1262 | panel.validate(action_transaction, AlarmPanelAction.NotReady) 1263 | else: 1264 | panel.validate(action_transaction, AlarmPanelAction.ArmHome) 1265 | threading.Thread(target=armed_home, args=(user,)).start() 1266 | 1267 | else: 1268 | logging.warning("Unknown action: %s, from alarm panel: %s", action, panel) 1269 | 1270 | elif code is not None: 1271 | state.code_attempts += 1 1272 | logging.warning("Invalid code: %s, attempt: %d", code, state.code_attempts) 1273 | # buzzer_signal(1, [1, 0]) 1274 | panel.validate(action_transaction, AlarmPanelAction.InvalidCode) 1275 | pushover.push("Invalid code entered", f"Panel: {panel}") 1276 | 1277 | for key, sensor in sensors.items(): 1278 | if msg.topic == sensor.topic and sensor.field in y: 1279 | sensor.timestamp = time.time() 1280 | 1281 | state.zone(key, y[sensor.field] == sensor.value.value) 1282 | 1283 | if y[sensor.field] == sensor.value.value: 1284 | if msg.retain == 1 and sensor in chain(direct_zones, fire_zones): 1285 | logging.warning("Discarding active sensor: %s, in retained message", sensor) 1286 | continue 1287 | 1288 | check_zone(sensor) 1289 | 1290 | if "battery" in y: 1291 | if isinstance(y["battery"], (int, float)): 1292 | # logging.debug("Found battery level %s on sensor %s", y["battery"], sensor) 1293 | state.status[f"{sensor.label.replace(' ', '_')}_bat"] = int(y["battery"]) > 20 1294 | 1295 | if "linkquality" in y: 1296 | if isinstance(y["linkquality"], (int, float)): 1297 | # logging.debug("Found link quality %s on sensor %s", y["linkquality"], sensor) 1298 | sensor.linkquality.append(int(y["linkquality"])) 1299 | 1300 | if len(sensor.linkquality) > 10: 1301 | sensor.linkquality.pop(0) 1302 | 1303 | # if len(sensor.linkquality) > 1: 1304 | # state.status[f"{sensor.label.replace(' ', '_')}_lqi"] = statistics.median(sensor.linkquality) > 0 1305 | # # print(sensor.linkquality, statistics.median(sensor.linkquality), statistics.stdev(sensor.linkquality)) 1306 | 1307 | 1308 | def status_check() -> None: 1309 | while True: 1310 | for key, device in (sensors.items() | alarm_panels.items()): 1311 | if device.timeout == 0: 1312 | continue 1313 | 1314 | last_msg_s = round(time.time() - device.timestamp) 1315 | state.status[f"{device.label.replace(' ', '_')}_timeout"] = last_msg_s < (device.timeout * 1.1) 1316 | state.status[f"{device.label.replace(' ', '_')}_lost"] = last_msg_s < (device.timeout * 5) 1317 | 1318 | state.status["code_attempts"] = state.code_attempts < 3 1319 | state.status["arduino_data"] = round(time.time() - arduino.timestamp) < 10 1320 | 1321 | for key, timer in zone_timers.items(): 1322 | state.zone_timer(key) 1323 | 1324 | state.fault() 1325 | time.sleep(1) 1326 | 1327 | 1328 | def heartbeat_ping() -> None: 1329 | hc_uuid = config.get("healthchecks.uuid", "heartbeat", fallback=None) 1330 | hc_heartbeat = HealthChecks(hc_uuid) 1331 | 1332 | if not hc_uuid: 1333 | logging.debug("Healthchecks UUID not found, aborting ping.") 1334 | return 1335 | 1336 | logging.info("Starting Healthchecks ping with UUID %s", hc_uuid) 1337 | 1338 | while True: 1339 | hc_status = hc_heartbeat.ping() 1340 | state.status["healthchecks"] = hc_status 1341 | 1342 | time.sleep(60) 1343 | 1344 | 1345 | def serial_data() -> None: 1346 | water_valve_switch = True 1347 | 1348 | while True: 1349 | arduino.data_ready.wait() 1350 | data = arduino.data 1351 | 1352 | if args.print_serial: 1353 | print(arduino.timestamp) 1354 | print(json.dumps(data.__dict__, indent=2, sort_keys=True)) 1355 | 1356 | try: 1357 | state.data["temperature"] = data.temperature 1358 | state.data["auxiliary_voltage"] = data.aux12_voltage 1359 | state.data["system_voltage"] = data.system_voltage 1360 | 1361 | state.data["battery_voltage"] = data.battery_voltage 1362 | state.data["battery_level"] = battery.level(data.battery_voltage) 1363 | state.data["battery_low"] = data.battery_voltage < 12 1364 | state.data["battery_charging"] = data.battery_voltage > 13 and not data.outputs[1] 1365 | 1366 | state.status["auxiliary_voltage"] = 12 < data.aux12_voltage < 12.5 1367 | state.status["battery_voltage"] = 12 < data.battery_voltage < 15 1368 | state.status["system_voltage"] = 4.9 < data.system_voltage < 5.2 1369 | state.status["cabinet_temp"] = data.temperature < 30 1370 | 1371 | state.data["water_valve"] = not data.outputs[2] 1372 | 1373 | except ValueError: 1374 | logging.error("ValueError on data from Arduino device") 1375 | 1376 | # state.status["siren1_output"] = outputs["siren1"].get() == data["inputs"][1] 1377 | # state.status["siren2_output"] = outputs["siren2"].get() == data["inputs"][2] 1378 | state.status["siren_block"] = data.outputs[0] is False 1379 | 1380 | state.data["battery_test_running"] = battery_test_lock.locked() 1381 | 1382 | if data.outputs[4] != state.data["config"]["aux_output1"]: 1383 | arduino.commands.put([5, state.data["config"]["aux_output1"]]) 1384 | if data.outputs[5] != state.data["config"]["aux_output2"]: 1385 | arduino.commands.put([6, state.data["config"]["aux_output2"]]) 1386 | 1387 | if data.inputs[3] != water_valve_switch and not water_alarm_lock.locked(): 1388 | arduino.commands.put([3, not data.inputs[3]]) 1389 | water_valve_switch = data.inputs[3] 1390 | logging.info("Water valve switch changed state: %s", data.inputs[3]) 1391 | 1392 | arduino.data_ready.clear() 1393 | 1394 | if round(time.time(), 0) % 10 == 0: 1395 | state.publish() 1396 | 1397 | 1398 | def door_open_warning() -> None: 1399 | zone_closed_time: dict[str, float] = {} 1400 | seconds_open_dict: dict[str, int] = {} 1401 | 1402 | while True: 1403 | # De Morgan's laws: 1404 | # not (A or B) = (not A) and (not B) 1405 | # not (A and B) = (not A) or (not B) 1406 | # If door is closed or warning is disabled 1407 | for zone in [z for z in zones.values() if ZoneAttribute.OpenWarning in z.attributes]: 1408 | if not (zone.get() and state.data["config"]["door_open_warning"]): 1409 | zone_closed_time[zone.key] = time.time() 1410 | 1411 | seconds_open_dict[zone.key] = math.floor(time.time() - zone_closed_time.get(zone.key, time.time())) 1412 | 1413 | #print(seconds_open_dict, zone_closed_time) 1414 | seconds_open = max(seconds_open_dict.values()) 1415 | 1416 | interval = 20 1417 | if seconds_open > 180: 1418 | interval = 1 1419 | elif seconds_open > 150: 1420 | interval = 5 1421 | elif seconds_open > 120: 1422 | interval = 10 1423 | elif seconds_open > 90: 1424 | interval = 15 1425 | 1426 | if state.system == "disarmed" and seconds_open > 30 and seconds_open % interval == 0: 1427 | buzzer_signal(1, [0.05, 0.95]) 1428 | else: 1429 | time.sleep(1) 1430 | 1431 | 1432 | def battery_test() -> None: 1433 | with battery_test_lock: 1434 | hc_battery_test = HealthChecks(config.get("healthchecks.uuid", "battery_test", fallback=None)) 1435 | 1436 | arduino.commands.put([2, True]) # Disable charger 1437 | arduino.commands.join() 1438 | 1439 | hc_battery_test.start() 1440 | start_time = time.time() 1441 | battery_log.info("Battery test started at %s V", arduino.data.battery_voltage) 1442 | 1443 | while state.data["battery_level"] >= 50: 1444 | time.sleep(1) 1445 | 1446 | hc_battery_test.stop() 1447 | test_time = round(time.time() - start_time, 0) 1448 | battery_log.info("Battery test completed at %s V and %s %%, took: %s", 1449 | arduino.data.battery_voltage, state.data["battery_level"], 1450 | datetime.timedelta(seconds=test_time)) 1451 | pushover.push("Battery test completed", f"Time: {datetime.timedelta(seconds=test_time)}") 1452 | arduino.commands.put([2, False]) # Re-enable charger 1453 | arduino.commands.join() 1454 | 1455 | 1456 | def water_valve_test() -> None: 1457 | with water_valve_test_lock: 1458 | hc_water_valve = HealthChecks(config.get("healthchecks.uuid", "water_valve_test", fallback=None)) 1459 | 1460 | if arduino.data.outputs[2] or water_alarm_lock.locked(): 1461 | logging.error("Can not run water valve test if valve is already active or water alarm is triggered") 1462 | return 1463 | 1464 | hc_water_valve.start() 1465 | logging.info("Water valve test started") 1466 | 1467 | for valve_state in [True, False]: 1468 | arduino.commands.put([3, valve_state]) # Water valve relay 1469 | arduino.commands.join() 1470 | time.sleep(1) 1471 | 1472 | hc_water_valve.stop() 1473 | logging.info("Water valve test completed") 1474 | 1475 | 1476 | def door_chime() -> None: 1477 | with door_chime_lock: 1478 | outputs["door_chime"].set(True) 1479 | time.sleep(1) 1480 | outputs["door_chime"].set(False) 1481 | time.sleep(30) 1482 | 1483 | 1484 | def check_reboot_required() -> None: 1485 | while True: 1486 | reboot_is_required = os.path.isfile("/var/run/reboot-required") 1487 | state.data["reboot_required"] = reboot_is_required 1488 | 1489 | if reboot_is_required: 1490 | logging.warning("Reboot required!") 1491 | 1492 | time.sleep(60*60) 1493 | 1494 | 1495 | mqtt_client = mqtt.Client(config.get("mqtt", "client_id")) 1496 | mqtt_client.on_connect = on_connect 1497 | mqtt_client.on_disconnect = on_disconnect 1498 | mqtt_client.on_message = on_message 1499 | mqtt_client.will_set("home/alarm_test/availability", "offline") 1500 | 1501 | for attempt in range(5): 1502 | try: 1503 | mqtt_client.connect(config.get("mqtt", "host")) 1504 | mqtt_client.loop_start() 1505 | except OSError: 1506 | logging.error("Unable to connect MQTT, retry... (%d)", attempt) 1507 | time.sleep(attempt*3) 1508 | else: 1509 | break 1510 | else: 1511 | logging.error("Unable to connect MQTT, giving up!") 1512 | 1513 | state = State() 1514 | pushover = Pushover( 1515 | config.get("pushover", "token"), 1516 | config.get("pushover", "user") 1517 | ) 1518 | 1519 | arduino = Arduino() 1520 | battery = Battery() 1521 | 1522 | # Since the Arduino resets when DTR is pulled low, the 1523 | # siren block is removed when starting up. 1524 | if args.siren_block_relay: 1525 | arduino.commands.put([1, True]) # Siren block relay 1526 | logging.warning("Sirens blocked, siren block active!") 1527 | 1528 | if args.silent: 1529 | logging.warning("Sirens suppressed, silent mode active!") 1530 | 1531 | # for zone_key, zone in zones.items(): 1532 | # zone.key = zone_key 1533 | # state.data["zones"][zone_key] = None 1534 | # 1535 | # for timer_key, timer in zone_timers.items(): 1536 | # timer.key = timer_key 1537 | # state.data["zone_timers"][timer_key] = { 1538 | # "value": None, 1539 | # "attributes": { 1540 | # "seconds": timer.seconds 1541 | # } 1542 | # } 1543 | 1544 | pending_lock = threading.Lock() 1545 | triggered_lock = threading.Lock() 1546 | buzzer_lock = threading.Lock() 1547 | water_alarm_lock = threading.Lock() 1548 | 1549 | battery_test_lock = threading.Lock() 1550 | water_valve_test_lock = threading.Lock() 1551 | door_chime_lock = threading.Lock() 1552 | 1553 | logging.info("Arm home zones: %s", home_zones) 1554 | logging.info("Arm away zones: %s", away_zones) 1555 | logging.info("Water alarm zones: %s", water_zones) 1556 | logging.info("Direct alarm zones: %s", direct_zones) 1557 | logging.info("Fire alarm zones: %s", fire_zones) 1558 | logging.info("Notify zones: %s", notify_zones) 1559 | 1560 | # for notify in notify_zones: 1561 | # state.notify_timestamps[notify] = time.time() 1562 | 1563 | passive_zones = [v for k, v in zones.items() if not v.arm_modes] 1564 | logging.info("Passive zones: %s", passive_zones) 1565 | 1566 | if __name__ == "__main__": 1567 | threading.Thread(target=run_led, args=(), daemon=True).start() 1568 | 1569 | threading.Thread(target=status_check, args=(), daemon=True).start() 1570 | 1571 | threading.Thread(target=heartbeat_ping, args=(), daemon=True).start() 1572 | 1573 | threading.Thread(target=arduino.get_data, args=(), daemon=True).start() 1574 | threading.Thread(target=serial_data, args=(), daemon=True).start() 1575 | 1576 | threading.Thread(target=door_open_warning, args=(), daemon=True).start() 1577 | 1578 | threading.Thread(target=check_reboot_required, args=(), daemon=True).start() 1579 | 1580 | input_active_counter: dict[str, int] = {} 1581 | 1582 | while True: 1583 | time.sleep(0.01) # Wait 10 ms 1584 | 1585 | # This loop takes less than 100 micro seconds to complete 1586 | for input_key, gpio_input in inputs.items(): 1587 | state.zone(input_key, gpio_input.get()) 1588 | 1589 | if input_key not in input_active_counter: 1590 | input_active_counter[input_key] = 0 1591 | 1592 | if gpio_input.is_true: 1593 | input_active_counter[input_key] += 1 1594 | 1595 | # Debounce zone inputs, must be active for 5 cycles = 50 ms 1596 | if input_active_counter[input_key] > 5: 1597 | check_zone(gpio_input) 1598 | else: 1599 | # if input_active_counter[input_key] > 0: 1600 | # rpi_gpio_log.debug("Zone: %s was active for %s cycles", gpio_input, input_active_counter[input_key]) 1601 | input_active_counter[input_key] = 0 1602 | 1603 | if not triggered_lock.locked() and (outputs["siren1"].is_true or outputs["siren2"].is_true): 1604 | logging.critical("Siren(s) on outside lock!") 1605 | wrapping_up() 1606 | 1607 | raise SystemError("Siren(s) on outside lock!") 1608 | --------------------------------------------------------------------------------