diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..d6ce18b --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,18 @@ +name: pre-commit + +on: + pull_request: + push: + branches: [master] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions/setup-python@v1 + - uses: actions/cache@v1 + with: + path: ~/.cache/pre-commit + key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} + - uses: pre-commit/action@v1.0.1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b9b8a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ + +*.pyc + +.idea/* +venv/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..346b867 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v2.1.0 + hooks: + - id: pyupgrade + args: [--py37-plus] + - repo: https://github.com/psf/black + rev: 19.10b0 + hooks: + - id: black + args: + - --safe + - --quiet + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.7.9 + hooks: + - id: flake8 + #additional_dependencies: + # - flake8-docstrings==1.5.0 + # - pydocstyle==5.0.2 + - repo: https://github.com/pre-commit/mirrors-isort + rev: v4.3.21 + hooks: + - id: isort \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d351642 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.9-alpine + +ENV LOG_LEVEL "INFO" +ENV API_SSL_CERT "" +ENV API_SSL_KEY "" +ENV API_PORT 4693 +ENV API_KEY "" +ENV PIMA_LOGIN "" +ENV PIMA_ZONES 32 +ENV PIMA_SERIAL_PORT "" +ENV PIMA_HOST "" +ENV PIMA_PORT "" +ENV MQTT_HOST "" +ENV MQTT_PORT "" +ENV MQTT_CLIENT_ID "pima-server" +ENV MQTT_USER "" +ENV MQTT_PASSWORD "" +ENV MQTT_TOPIC "pima-server" +ENV API_MODE "Docker" + +RUN apk update && \ + apk upgrade && \ + apk add --no-cache gcc libressl-dev musl-dev libffi-dev nano && \ + pip install flask pyopenssl crcmod pyserial paho-mqtt + +COPY . /app/ + +EXPOSE 4693 + +ENTRYPOINT ["python3", "/app/entrypoint.py"] \ No newline at end of file diff --git a/README.md b/README.md index ae078b2..3bbdd1d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PIMA Alarms +# PIMA2MQTT This program implements an interface for negotiation with [PIMA Hunter Pro alarms](https://www.pima-alarms.com/our-products/hunter-pro-series/). It was built based on PIMA™'s General Specification for Home Automation & @@ -10,10 +10,10 @@ This program was built with no affiliation of PIMA Electronic Systems Ltd. 1. PIMA Hunter Pro alarm™, with 32, 96 or 144 zones. 1. PIMA Home Automation kit™ (`SA-232`, `LCL-11A` and Serial-to-USB cable), or `net4pro` ethernet connection. Diagram by PIMA™ ©: - ![Diagram by PIMA™ ©](home_automation_kit.png) + ![Diagram by PIMA™ ©](docs/home_automation_kit.png) - According to various users, the alarm can be alternatively connected using a `PL2303TA` USB-to-TTL cable, like [this one](https://www.aliexpress.com/item/32345829369.html). - Yet another option is to connect directly to Raspberry pi, as specified here: - ![Diagram by @maorcc](rpi_connection.png) + ![Diagram by @maorcc](docs/rpi_connection.png) 1. Raspberry Pi or similar, connected to the alarm through the Home Automation kit. - Tested on [Raspbian](https://www.raspberrypi.org/downloads/raspbian/). Other operating systems may use different path structure for the serial ports. @@ -42,91 +42,109 @@ This program was built with no affiliation of PIMA Electronic Systems Ltd. - `ENTR` - `END` to exit -## Setup -1. Create an SSL certificate, if you wish to access the server through HTTPS: - ```bash - openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365 - ``` -1. Install additional Python libraries: - ```bash - pip3 install crcmod paho-mqtt pyserial - ``` -1. Download [pima.py](pima.py) and [pima_server.py](pima_server.py), and put them in the same directory. -1. Set run permissions to [pima_server.py](pima_server.py): - ```bash - chmod a+x pima_server.py - ``` -## Run for testing -1. Test out that you can run the server, e.g.: - ```bash - ./pima_server.py --ssl_cert cert.pem --ssl_key key.pem --port 7777 --key my_random_key --login 000000 --mqtt_host localhost - ``` - Parameters: - - `--ssl_cert` - Path to the SSL certificate file. If not set, will run a non-encrypted web server. - - `--ssl_key` - Path to the SSL private key file. If not set, will get the key from the certificate file. - - `--port` or `-p` - Port for the web server. - - `--key` or `-k` - An arbitrary string key to authenticate the server calls. - Consider generating a random key using `uuid -v4`. - - `--login` or `-l` - The technician login code to the alarm. - - `--zones` or `-z` - Number of zones supported by the alarm, one of 32, 96 or 144. Default is 32. - - `--serialport` - Serial port, e.g. `/dev/serial0`. Needed if connected directly through GPIO serial. - - `--pima_host` - Pima alarm hostname or IP address. Must be set if connected by ethernet. - - `--pima_port` - Pima alarm port. Must be set if connected by ethernet. - - `--mqtt_host` - The MQTT broker hostname or IP address. Must be set to enable MQTT. - - `--mqtt_port` - The MQTT broker port. Default is 1883. - - `--mqtt_client_id` - The MQTT client ID. If not set, a random client ID will be generated. - - `--mqtt_user` - <user:password> for the MQTT channel. If not set, no authentication is used. - - `--mqtt_topic` - The MQTT root topic. Default is "pima_alarm". The server will listen on topic - <{mqtt_topic}/command> and publish to <{mqtt_topic}/status>. - - `--log_level` - The minimal log level to send to syslog. Default is WARNING. -1. Access e.g. using curl: - ```bash - curl -ik 'http://localhost:7777/pima?key=my_random_key&command=status' - curl -ik 'http://localhost:7777/pima?key=my_random_key&command=arm&mode=home1&partitions=1' - ``` - CGI Arguments: - - `key` - The key specified on the web server startup. - - `command` - Either `status` or `arm`. - When `arm` is specified: - - `mode` - Either `full_arm`, `home1`, `home2` or `disarm`. - - `partitions` Comma separated list of partitions. Default is `1`. -## Run as a service -1. Create a dedicated directory for the script files, and move the files to it. - Pass the ownership to root. e.g.: - ```bash - sudo mkdir /usr/lib/pima - sudo mv pima_server.py pima.py key.pem cert.pem /usr/lib/pima - sudo chown root:root /usr/lib/pima/* - sudo pip3 install crcmod paho-mqtt pyserial - ``` -1. Create a service configuration file (as root), e.g. `/lib/systemd/system/pima.service`: - ```INI - [Unit] - Description=PIMA alarm server - After=network.target - - [Service] - ExecStart=/usr/bin/python3 -u pima_server.py --ssl_cert cert.pem --ssl_key key.pem --port 7777 --key my_random_key --login 000000 --mqtt_host localhost - WorkingDirectory=/usr/lib/pima - StandardOutput=inherit - StandardError=inherit - Restart=always - - [Install] - WantedBy=multi-user.target - ``` -1. Link to it from `/etc/systemd/system/`: - ```bash - sudo ln -s /lib/systemd/system/pima.service /etc/systemd/system/multi-user.target.wants/pima.service - ``` -1. Enable and start the new service: - ```bash - sudo systemctl enable pima.service - sudo systemctl start pima.service - ``` -1. If you use [MQTT](http://en.wikipedia.org/wiki/Mqtt) for [HomeAssistant](https://www.home-assistant.io/) or - [openHAB](https://www.openhab.org/), the broker should now provide the updated status of the alarm, and accepts commands. - Basic yaml config files for HomeAssistant are available under the [hass](hass/) directory. +## How to run + +- [Command Line](docs/command_line.md) +- [Docker](docs/docker.md) +- [Home Assistant](docs/homeassistant.md) + +## MQTT + +#### Publishes + +##### Status: pima_alarm/status +```json +{ + "partitions": { + "1": "home1" + }, + "open zones": [], + "alarmed zone": [], + "bypassed zones": [], + "failed zones": [], + "failures": [] +} +``` + +##### Last Will and Testament: pima_alarm/LWT +- online +- offline + +#### Subscribes + +##### pima_alarm/arm +Each of the following commands can receive which partitions to execute the command, +By default `partitions: [ "1" ]` + + +Arm +```json +{ + "mode": "full_arm" +} +``` + +Arm Home1 +```json +{ + "mode": "home1" +} +``` + +Arm Home2 +```json +{ + "mode": "home2" +} +``` + +Disarm +```json +{ + "mode": "disarm" +} +``` + +## Web Server endpoints + +Each request to the server must include in the URL (query string) the api_key, e.g. ```/pima/status?api_key=test``` + +#### GET /pima/status +Returns status + +##### Http Codes + +Code | Reason | Description +--- | --- | --- | +200 | OK | Set arm state changed successfully +401 | Unauthorized request | api_key doesn't match to the defined key + + +#### POST /pima/arm +##### Body +Name | Type | Required | Default | Description +--- | --- | --- | --- | --- | +mode | str | True | None | Which command to run, supported options - arm, disarm, home1, home2 +partitions | array of int | False | `[ "1" ]` | Which partition to use, accept array of int + +Returns status + +##### Http Codes + +Code | Reason | Description +--- | --- | --- | +200 | OK | Set arm state changed successfully +400 | Invalid request data | Empty payload sent within the request +401 | Unauthorized request | api_key doesn't match to the defined key +403 | More details below | The request contained valid data and was understood by the server, but the server is refusing action, more details available in the error message and logs +501 | Invalid arm mode, must be one of `full_arm`, `home1`, `home2`, `disarm` | received mode is not supported + +##### Status Code 403 available errors +Message | Description +| --- | --- | +No Server | Server is unreachable, check if serial port / IP:Port are correct +Invalid arm mode [mode] | provided mode is not supported + ## Next steps 1. [Groovy](http://groovy-lang.org/) [Device Type Handlers](https://docs.smartthings.com/en/latest/device-type-developers-guide/) for [SmartThings](https://www.smartthings.com/) integration. 1. Support further functionality, e.g. change user codes. diff --git a/api/pima.py b/api/pima.py new file mode 100644 index 0000000..c370938 --- /dev/null +++ b/api/pima.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +""" +This module implements an interface for negotiation with PIMA Hunter Pro alarms. +It was built based on PIMA's General Specification for Home Automation & +Building Management protocol Ver. 1.15. +PIMA is a trademark of PIMA Electronic Systems Ltd, http://www.pima-alarms.com. +This module was built with no affiliation of PIMA Electronic Systems Ltd. + +Copyright © 2019 Dror Eiger + +This module is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This module is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +__author__ = "droreiger@gmail.com (Dror Eiger)" + +import enum +import logging +import socket +import termios +import time +import typing + +import crcmod +import serial + + +class Error(Exception): + """Error class for PIMA alarm handling.""" + + pass + + +class GarbageInputError(Error): + """Error class when PIMA alarm reports garbage.""" + + pass + + +class Arm(enum.Enum): + """Arming mode for the PIMA alarm.""" + + FULL_ARM = b"\x01" + HOME1 = b"\x02" + HOME2 = b"\x03" + DISARM = b"\x00" + + +Status = typing.NewType("Status", typing.Dict[str, typing.Any]) +Partitions = typing.NewType("Partitions", typing.Set[int]) + + +class Alarm: + """Class wrapping the protocol for PIMA alarm.""" + + _ZONES_TO_MODULE_ID = {32: b"\x0d", 96: b"\x0d", 144: b"\x13"} + _ZONES_TO_ZONE_BYTES = {32: 12, 96: 12, 144: 18} + + class _Message(enum.Enum): + WRITE = b"\x0f" + READ = b"\x0e" + OPEN = b"\x01" + CLOSE = b"\x19" + STATUS = b"\x05" + + class _Channel(enum.Enum): + IDLE = b"\x00" + SYSTEM = b"\x01" + ZONES = b"\x02" + OUTPUTS = b"\x03" + LOGIN = b"\x04" + PARAMETER = b"\x05" + + _DISCRETE_FAILURES = { + 1: "System Low Power", + 2: "Unknown (2)", + 3: "System Error", + 4: "Zone Failure", + 5: "Unknown (5)", + 6: "Auxiliary Voltage Failure (Fuse short)", + 7: "W/L Zone Low Battery", + 8: "Wireless Receiver Failure", + 9: "Low Battery", + 10: "Telephone Line Failure", + 11: "MAINS Failure (220V)", + 12: "Tamper 1 Open", + 13: "Tamper 2 Open", + 14: "Clock Not Set", + 15: "RAM Error", + 16: "Station Commuincation Failure", + 17: "Siren 1 Failure", + 18: "Siren 2 Failure", + 19: "SMS Communication", + 20: "SMS Card", + 21: "GSM200 Error", + 22: "Network Comm. Fault", + 23: "Radio Fault", + 24: "Keyfob Rec. Fault", + 25: "Wireless Receiver Tamper Open", + 26: "Wireless Jamming", + 27: "GSM-200 Failure", + 28: "GSM Communication Failure", + 29: "GSM-SIM Failure", + 30: "GSM Link Failure", + 31: "GSM Comm. Fault 2nd station", + 32: "W/L Zone Supervision", + 33: "Unknown (33)", + 34: "Network fault Station 2", + 35: "Net4Pro Fault", + 36: "VVR 1 Fault", + 37: "VVR 2 Fault", + 38: "VVR 3 Fault", + 39: "VVR 4 Fault", + 40: "VVR 1 Power Fault", + 41: "VVR 2 Power Fault", + 42: "VVR 3 Power Fault", + 43: "VVR 4 Power Fault", + 44: "Unknown (44)", + 45: "Unknown (45)", + 46: "Unknown (46)", + 47: "Unknown (47)", + 48: "Unknown (48)", + } + + def __init__( + self, zones: int, serialport: str = None, ipaddr: str = None, ipport: int = None + ) -> None: + if serialport is not None: + try: + self._channel = serial.Serial( + port=serialport, + baudrate=2400, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + timeout=1, + ) + except (termios.error, serial.serialutil.SerialException) as e: + self._channel = None + raise Error("Failed to connect to serial port.") from e + else: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((ipaddr, ipport)) + self._channel = socket.SocketIO(sock, "rwb") + except (OSError, socket.gaierror) as e: + self._channel = None + raise Error("Error creating socket.") from e + self._crc = crcmod.mkCrcFun(0x18005, rev=True, initCrc=0x0000, xorOut=0x0000) + self._zones = zones # type: int + self._module_id = self._ZONES_TO_MODULE_ID[self._zones] # type: bytes + + def __del__(self) -> None: + self._close() + + def __enter__(self): + return self + + def __exit__(self, unused_type, unused_value, unused_traceback) -> None: + self._close() + + def login(self, code: str) -> Status: + data = bytes([int(digit) for digit in code]).ljust(6, b"\xff") + self._read_message() + self._send_message(self._Message.WRITE, self._Channel.LOGIN, data=data) + return self.get_status() + + def get_status(self) -> Status: + """Returns the current alarm status.""" + try: + response = self._read_message() + except GarbageInputError as ex: + logging.info("Exception: %r.", ex) + # Clear up a messy channel (sometime happens on startup). + d = b"\xf3" + while d == d[:1] * len(d): + d = self._channel.readline() + logging.debug("Read message: %r.", d) + response = self._read_message() + self._send_message(self._Message.STATUS, self._Channel.IDLE) + data = Status({"logged in": False}) + if not response: + return data + if response[2:3] != self._Message.STATUS.value: + raise Error("Invalid message {}.".format(self._make_hex(response[2:3]))) + if response[3:4] == self._Channel.IDLE.value: + return data + if response[3:4] != self._Channel.SYSTEM.value: + raise Error("Invalid status {}.".format(self._make_hex(response[3:4]))) + if response[4:7] != b"\x02\x00\x00": + raise Error("Invalid address {}.".format(self._make_hex(response[4:7]))) + # Calculate the break points. + zone_bytes = range(7, len(response), self._ZONES_TO_ZONE_BYTES[self._zones])[:5] + # Get the data chuncks. + # HP32 zones us using only the first bytes. + zone_data = [response[i : i + self._zones // 8] for i in zone_bytes[:-1]] + data["open zones"] = self._parse_bytes(zone_data[0]) + data["alarmed zones"] = self._parse_bytes(zone_data[1]) + data["bypassed zones"] = self._parse_bytes(zone_data[2]) + data["failed zones"] = self._parse_bytes(zone_data[3]) + index = zone_bytes[-1] + data["partitions"] = {} + for partition, value in enumerate(response[index : index + 16], 1): + data["partitions"][partition] = Arm(bytes([value])).name.lower() + index += 16 + failures = self._parse_bytes(response[index : index + 6]) + failures = {self._DISCRETE_FAILURES[failure] for failure in failures} + index += 6 + for fail_type, count in ( + ("Keypad %d Failure", 1), + ("Keypad %d Tamper", 1), + ("Zone Expander %d Failure", 2), + ("Zone Expander %d Tamper", 2), + ("Zone Expander %d Low Voltage", 2), + ("Zone Expander %d AC Failure", 2), + ("Zone Expander %d Low Battery", 2), + ("Out Expander %d Failure", 1), + ("Out Expander %d Tamper", 1), + ("Out Expander %d Low Voltage", 1), + ("Out Expander %d AC Failure", 1), + ("Out Expander %d Low Battery", 1), + ): + clustered_failures = self._parse_bytes(response[index : index + count]) + for failure in clustered_failures: + failures.add(fail_type % failure) + index += count + if failures: + data["failures"] = failures + # Skip ID Account + index += 4 + flags = response[index] + data["logged in"] = bool(flags & 1 << 0) + data["command ack"] = bool(flags & 1 << 1) + return data + + def arm(self, mode: Arm, partitions: Partitions) -> Status: + """Arms (or disarms) the provided alarm partitions.""" + self._read_message() + address = sum(1 << (p - 1) for p in partitions).to_bytes(2, byteorder="little") + self._send_message( + self._Message.OPEN if mode == Arm.DISARM else self._Message.CLOSE, + self._Channel.SYSTEM, + address=address, + data=mode.value, + ) + return self.get_status() + + def zones(self) -> Status: + raise NotImplementedError("No support yet for zones.") + + def outputs(self) -> Status: + raise NotImplementedError("No support yet for outputs.") + + def parameters(self) -> Status: + raise NotImplementedError("No support yet for parameters.") + + def _read_message(self) -> bytes: + data = None + while not data: + data = self._channel.read(1) + length = ord(data) + data = bytes([length]) + self._channel.read(length + 2) + if data == bytes([length]) * len(data): + raise GarbageInputError(f"Garbage ({length})!") + if len(data) != length + 3: + raise Error( + "Not enough data in channel: {} should have {} bytes.".format( + self._make_hex(data), length + 3 + ) + ) + logging.debug(">>> " + self._make_hex(data)) + data, crc = data[:-2], int.from_bytes(data[-2:], byteorder="big") + if crc != self._crc(data): + raise Error( + "Invalid input on channel, CRC for {} is {}, not {}!".format( + self._make_hex(data), self._crc(data), crc + ) + ) + if self._module_id != data[1:2]: + raise Error( + "Invalid module ID. Expected {}, got {}".format( + self._make_hex(self._module_id), self._make_hex(data[1:2]) + ) + ) + return data + + def _send_message( + self, + message: _Message, + channel: _Channel, + address: bytes = b"", + data: bytes = b"", + ) -> None: + output = b"".join( + ( + self._module_id, + message.value, + channel.value, + bytes([len(address)]), + address, + data, + ) + ) + output = bytes([len(output)]) + output + output += self._crc(output).to_bytes(2, byteorder="big") + logging.debug("<<< " + self._make_hex(output)) + self._channel.write(output) + if message != self._Message.STATUS: + time.sleep(1) + + @staticmethod + def _parse_bytes(data: bytes) -> typing.Set[int]: + bits = int.from_bytes(data, byteorder="little") + return {i + 1 for i in range(bits.bit_length()) if bits & 1 << i} + + @staticmethod + def _make_hex(data: bytes) -> str: + return " ".join("%02x" % d for d in data) + + def _close(self) -> None: + if self._channel: + self._channel.close() + self._channel = None diff --git a/docs/command_line.md b/docs/command_line.md new file mode 100644 index 0000000..b979cdb --- /dev/null +++ b/docs/command_line.md @@ -0,0 +1,79 @@ +# PIMA2MQTT + +## Setup as Command Line + +### Setup +1. Create an SSL certificate, if you wish to access the server through HTTPS: + ```bash + openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365 + ``` +1. Install additional Python libraries: + ```bash + pip3 install flask pyopenssl crcmod paho-mqtt pyserial + ``` +1. Download files, and put them in the same directory. +1. Set run permissions to [entrypoint.py](entrypoint.py): + ```bash + chmod a+x entrypoint.py + ``` + +### Run for testing +Test out that you can run the server, e.g.: +```bash +./pima_server.py --ssl_cert cert.pem --ssl_key key.pem --port 7777 --key my_random_key --login 000000 --mqtt_host localhost +``` + +### Arguments + +Name | Type | Required | Default | Description +--- | --- | --- | --- | --- | +--log_level | str | True | INFO | Minimal log level +--ssl_cert | str | False | None | Path to SSL certificate file +--ssl_key | str | False | None | Path to SSL key file +-p / --port | int | True | 4693 | Port for the server +-k / --key | str | True | None | URL key to authenticate calls +-l / --login | str | True | None | Login code to the PIMA alarm +-z / --zones | int | True | 32 | Alarm supported zones, supported values - 32, 96, 144 +--serialport | str | False | None | Serial port, e.g. /dev/serial0. Needed if connected directly through GPIO serial +--pima_host | str | False | None | Pima alarm hostname or IP address. if connected by ethernet (net4pro) +--pima_port | int | False | None | Pima alarm port. if connected by ethernet (net4pro) +--mqtt_host | str | False | None | MQTT broker hostname or IP address +--mqtt_port | int | False | None | MQTT broker port +--mqtt_client_id | str | False | pima-server | MQTT client id +--mqtt_user | str | False | None | `` for the MQTT channel +--mqtt_topic | str | False | pima_server | MQTT topic + +### Run as a service +1. Create a dedicated directory for the script files, and move the files to it. + Pass the ownership to root. e.g.: + ```bash + sudo mkdir /usr/lib/pima + sudo mv pima_server.py pima.py key.pem cert.pem /usr/lib/pima + sudo chown root:root /usr/lib/pima/* + sudo pip3 install flask pyopenssl crcmod paho-mqtt pyserial + ``` +1. Create a service configuration file (as root), e.g. `/lib/systemd/system/pima.service`: + ```INI + [Unit] + Description=PIMA alarm server + After=network.target + + [Service] + ExecStart=/usr/bin/python3 -u endpoint.py --ssl_cert cert.pem --ssl_key key.pem --port 7777 --key my_random_key --login 000000 --mqtt_host localhost + WorkingDirectory=/usr/lib/pima + StandardOutput=inherit + StandardError=inherit + Restart=always + + [Install] + WantedBy=multi-user.target + ``` +1. Link to it from `/etc/systemd/system/`: + ```bash + sudo ln -s /lib/systemd/system/pima.service /etc/systemd/system/multi-user.target.wants/pima.service + ``` +1. Enable and start the new service: + ```bash + sudo systemctl enable pima.service + sudo systemctl start pima.service + ``` \ No newline at end of file diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..e9f6d47 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,58 @@ +# PIMA2MQTT + +## Setup as Docker + +### Environment Variables + +Name | Type | Required | Default | Description +--- | --- | --- | --- | --- | +LOG_LEVEL | str | True | INFO | Minimal log level +API_SSL_CERT | str | False | None | Path to SSL certificate file +API_SSL_KEY | str | False | None | Path to SSL key file +API_PORT | int | True | 4693 | Port for the server +API_KEY | str | True | None | URL key to authenticate calls +PIMA_LOGIN | str | True | None | Login code to the PIMA alarm +PIMA_ZONES | int | True | 32 | Alarm supported zones, supported values - 32, 96, 144 +PIMA_SERIAL_PORT | str | False | None | Serial port, e.g. /dev/serial0. Needed if connected directly through GPIO serial +PIMA_HOST | str | False | None | Pima alarm hostname or IP address. if connected by ethernet (net4pro) +PIMA_PORT | int | False | None | Pima alarm port. if connected by ethernet (net4pro) +MQTT_HOST | str | False | None | MQTT broker hostname or IP address +MQTT_PORT | int | False | None | MQTT broker port +MQTT_CLIENT_ID | str | False | pima-server | MQTT client id +MQTT_USERNAME | str | False | None | MQTT username +MQTT_PASSWORD | str | False | None | MQTT password +MQTT_TOPIC | str | False | pima_server | MQTT topic + +### Compose + +```yaml +version: '2' +services: + pima2mqtt: + image: "eladbar/pima2mqtt:latest" + container_name: "pima2mqtt" + hostname: "pima2mqtt" + restart: unless-stopped + ports: + - 4693:4693 + environment: + - LOG_LEVEL=DEBUG + - API_SSL_CERT=/ssl/ssl.cert + - API_SSL_KEY=/ssl/ssl.key + - API_PORT=4693 + - API_KEY=SecretKey + - PIMA_LOGIN=123456 + - PIMA_ZONES=32 + - PIMA_SERIAL_PORT=/dev/serial/by-path # Relevant for SA-232, LCL-11A and Serial-to-USB cable only + - PIMA_HOST=127.0.0.1 # Relevant for net4pro only + - PIMA_PORT=123456 # Relevant for net4pro only + - MQTT_HOST=127.0.0.1 + - MQTT_PORT=1883 + - MQTT_CLIENT_ID=pima-server + - MQTT_USER=user + - MQTT_PASSWORD=pass + - MQTT_TOPIC=pima_server + volumes: + - /ssl/ssl.key:/ssl/ssl.key:ro + - /ssl/ssl.cert:/ssl/ssl.cert:ro +``` diff --git a/home_automation_kit.png b/docs/home_automation_kit.png similarity index 100% rename from home_automation_kit.png rename to docs/home_automation_kit.png diff --git a/docs/homeassistant.md b/docs/homeassistant.md new file mode 100644 index 0000000..c85d977 --- /dev/null +++ b/docs/homeassistant.md @@ -0,0 +1,75 @@ +# PIMA2MQTT + +## Setup MQTT Alarm and Sensors for Home Assistant + +### Components +#### Alarm Control Panel +```yaml +alarm_control_panel: + - platform: mqtt + state_topic: "pima_alarm/status" + command_topic: "pima_alarm/command" + availability_topic: "pima_alarm/LWT" + code_arm_required: false + code_disarm_required: false + value_template: >- + {% if value_json['partitions']['1'] == 'home1' %} + armed_home + {% elif value_json['partitions']['1'] == 'full_arm' %} + armed_away + {% else %} + disarmed + {% endif %} + payload_disarm: '{"mode": "disarm"}' + payload_arm_home: '{"mode": "home1"}' + payload_arm_away: '{"mode": "full_arm"}' +``` + +#### Alarm Open Zones + +```yaml +sensor: + - name: "Alarm Open Zones" + platform: mqtt + state_topic: "pima_alarm/status" + availability_topic: "pima_alarm/LWT" + value_template: "{{ value_json['open zones'] }}" +``` + +#### Alarm Alarmed Zones +```yaml +sensor: + - name: "Alarm Alarmed Zones" + platform: mqtt + state_topic: "pima_alarm/status" + availability_topic: "pima_alarm/LWT" + value_template: "{{ value_json['alarmed zones'] }}" +``` + +#### Alarm Arm State +```yaml +sensor: + - name: "Alarm Arm State" + platform: mqtt + state_topic: "pima_alarm/status" + availability_topic: "pima_alarm/LWT" + value_template: "{{ value_json['partitions']['1'] }}" +``` + +### Lovelace +```yaml +cards: + - entity: alarm_control_panel.mqtt_alarm + name: PIMA Alarm + states: + - arm_home + - arm_away + type: alarm-panel + - entities: + - entity: sensor.alarm_arm_state + - entity: sensor.alarm_open_zones + - entity: sensor.alarm_alarmed_zones + show_header_toggle: false + type: entities +type: vertical-stack +``` \ No newline at end of file diff --git a/rpi_connection.png b/docs/rpi_connection.png similarity index 100% rename from rpi_connection.png rename to docs/rpi_connection.png diff --git a/entrypoint.py b/entrypoint.py new file mode 100644 index 0000000..c406bd4 --- /dev/null +++ b/entrypoint.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +JSON server module for PIMA alarms. +Uses pima.py to connect and control the alarm. + +PIMA is a trademark of PIMA Electronic Systems Ltd, http://www.pima-alarms.com. +This module was built with no affiliation of PIMA Electronic Systems Ltd. + +Copyright © 2019 Dror Eiger + +This module is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This module is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +__author__ = "droreiger@gmail.com (Dror Eiger)" + +import logging +from threading import Thread + +from flask import Flask, abort, jsonify, request +from helpers.const import ( + ARM_MODE, + CMD_ARM, + CMD_STATUS, + PIMA_ARM_URL, + PIMA_STATUS_URL, + SUPPORTED_ARM_MODES, +) +from managers.alarm_manager import AlarmManager +from managers.configuration_manager import ConfigurationManager + +_LOGGER = logging.getLogger(__name__) + +app = Flask(__name__) +configuration_manager = ConfigurationManager() + +manager = AlarmManager(configuration_manager) +initialize_thread = Thread(target=manager.initialize) +initialize_thread.start() + + +def validate_key(): + key = request.args.get("api_key") + is_valid = key == configuration_manager.api_key + + if not is_valid: + _LOGGER.warning(f"Unauthorized request using key '{key}'") + + return is_valid + + +@app.route(PIMA_STATUS_URL, methods=["GET"]) +def pima_get_status_handler(): + is_valid_request = validate_key() + + if is_valid_request: + result = manager.execute(CMD_STATUS) + + content = jsonify(result) + + return content + + else: + _LOGGER.error(f"Unauthorized request") + + abort(401, description="Unauthorized request") + + +@app.route(PIMA_ARM_URL, methods=["POST"]) +def pima_post_arm_handler(): + is_valid_request = validate_key() + + if is_valid_request: + if request.data: + data = request.get_json(force=True) + + arm_mode = data.get(ARM_MODE) + + if arm_mode is None or arm_mode not in SUPPORTED_ARM_MODES: + _LOGGER.error(f"Invalid arm mode, must be one of {SUPPORTED_ARM_MODES}") + + abort(501, f"Invalid arm mode, must be one of {SUPPORTED_ARM_MODES}") + + else: + result = manager.execute(CMD_ARM, data) + + error = result.get("error") + + if error is None: + content = jsonify(result) + + return content + + else: + _LOGGER.error(f"{error}") + + abort(403, f"{error}") + + else: + _LOGGER.error(f"Invalid request data") + + abort(400, "Invalid request data") + + else: + _LOGGER.error(f"Unauthorized request") + + abort(401, description="Unauthorized request") + + +app.run( + host=configuration_manager.api_binds, + port=configuration_manager.api_port, + debug=configuration_manager.is_debug, + ssl_context=configuration_manager.ssl_context, +) diff --git a/hass/alarm.yaml b/hass/alarm.yaml deleted file mode 100644 index cc5c4eb..0000000 --- a/hass/alarm.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Pima Alarm -alarm_control_panel: - - platform: mqtt - state_topic: "pima_alarm/status" - command_topic: "pima_alarm/command" - availability_topic: "pima_alarm/LWT" - code_arm_required: false - code_disarm_required: false - value_template: >- - {% if value_json['partitions']['1'] == 'home1' %} - armed_home - {% elif value_json['partitions']['1'] == 'full_arm' %} - armed_away - {% else %} - disarmed - {% endif %} - payload_disarm: '{"command": "arm", "mode": "disarm"}' - payload_arm_home: '{"command": "arm", "mode": "home1"}' - payload_arm_away: '{"command": "arm", "mode": "full_arm"}' diff --git a/hass/alarm_sensors.yaml b/hass/alarm_sensors.yaml deleted file mode 100644 index 14d19b1..0000000 --- a/hass/alarm_sensors.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Pima Alarm Sensors -sensor: - - name: "Alarm Open Zones" - platform: mqtt - state_topic: "pima_alarm/status" - availability_topic: "pima_alarm/LWT" - value_template: "{{ value_json['open zones'] }}" - - name: "Alarm Alarmed Zones" - platform: mqtt - state_topic: "pima_alarm/status" - availability_topic: "pima_alarm/LWT" - value_template: "{{ value_json['alarmed zones'] }}" - - name: "Alarm Arm State" - platform: mqtt - state_topic: "pima_alarm/status" - availability_topic: "pima_alarm/LWT" - value_template: "{{ value_json['partitions']['1'] }}" diff --git a/hass/ui-lovelace.yaml b/hass/ui-lovelace.yaml deleted file mode 100644 index aa1ab6e..0000000 --- a/hass/ui-lovelace.yaml +++ /dev/null @@ -1,14 +0,0 @@ -cards: - - entity: alarm_control_panel.mqtt_alarm - name: PIMA Alarm - states: - - arm_home - - arm_away - type: alarm-panel - - entities: - - entity: sensor.alarm_arm_state - - entity: sensor.alarm_open_zones - - entity: sensor.alarm_alarmed_zones - show_header_toggle: false - type: entities -type: vertical-stack diff --git a/helpers/const.py b/helpers/const.py new file mode 100644 index 0000000..a9435c7 --- /dev/null +++ b/helpers/const.py @@ -0,0 +1,98 @@ +from helpers.login_codes import LoginCodes + +CMD_STATUS = "status" +CMD_ARM = "arm" +SERIAL_BASE = "/dev/serial/by-path" +SERVER_BIND = "0.0.0.0" +PIMA_URL = "/pima" +PIMA_STATUS_URL = f"{PIMA_URL}/{CMD_STATUS}" +PIMA_ARM_URL = f"{PIMA_URL}/{CMD_ARM}" + +ARM_MODE = "mode" +ARM_FULL = "full_arm" +ARM_HOME1 = "home1" +ARM_HOME2 = "home2" +ARM_DISARM = "disarm" +SUPPORTED_ARM_MODES = [ARM_FULL, ARM_HOME1, ARM_HOME2, ARM_DISARM] + +LOG_LEVEL_DEBUG = "DEBUG" +LOG_LEVEL_INFO = "INFO" +LOG_LEVEL_WARNING = "WARNING" +LOG_LEVEL_ERROR = "ERROR" +LOG_LEVEL_CRITICAL = "CRITICAL" + +ARG_LOG_LEVEL_KEY = "--log_level" +ARG_LOG_LEVEL_DEFAULT = LOG_LEVEL_INFO +ARG_LOG_LEVEL_CHOICES = { + LOG_LEVEL_DEBUG, + LOG_LEVEL_INFO, + LOG_LEVEL_WARNING, + LOG_LEVEL_ERROR, + LOG_LEVEL_CRITICAL, +} +ARG_LOG_LEVEL_HELP = "Minimal log level" + +ARG_SHORT_KEY = "short-key" +ARG_KEY = "key" +ARG_HELP = "help" +ARG_TYPE = "type" +ARG_REQUIRED = "required" +ARG_DEFAULT = "default" +ARG_CHOICES = "choices" + +ARGS = [ + {ARG_KEY: "ssl_cert", ARG_HELP: "Path to SSL certificate file"}, + {ARG_KEY: "ssl_key", ARG_HELP: "Path to SSL key file"}, + { + ARG_SHORT_KEY: "p", + ARG_KEY: "port", + ARG_HELP: "Port for the server", + ARG_REQUIRED: True, + ARG_TYPE: int, + }, + { + ARG_SHORT_KEY: "k", + ARG_KEY: "key", + ARG_HELP: "URL key to authenticate calls", + ARG_REQUIRED: True, + }, + { + ARG_SHORT_KEY: "l", + ARG_KEY: "login", + ARG_HELP: "Login code to the PIMA alarm", + ARG_CHOICES: LoginCodes(), + ARG_REQUIRED: True, + }, + { + ARG_SHORT_KEY: "z", + ARG_KEY: "zones", + ARG_HELP: "Alarm supported zones", + ARG_TYPE: int, + ARG_DEFAULT: 32, + ARG_CHOICES: {32, 96, 144}, + }, + { + ARG_KEY: "serialport", + ARG_HELP: "Serial port, e.g. /dev/serial0. Needed if connected directly through GPIO serial", + }, + { + ARG_KEY: "pima_host", + ARG_HELP: "Pima alarm hostname or IP address. if connected by ethernet", + }, + { + ARG_KEY: "pima_port", + ARG_HELP: "Pima alarm port. if connected by ethernet", + ARG_TYPE: int, + }, + {ARG_KEY: "mqtt_host", ARG_HELP: "MQTT broker hostname or IP address"}, + {ARG_KEY: "mqtt_port", ARG_HELP: "MQTT broker port", ARG_TYPE: int}, + {ARG_KEY: "mqtt_client_id", ARG_HELP: "MQTT client id"}, + {ARG_KEY: "mqtt_user", ARG_HELP: " for the MQTT channel"}, + {ARG_KEY: "mqtt_topic", ARG_HELP: "MQTT topic", ARG_DEFAULT: "pima_alarm"}, + { + ARG_KEY: ARG_LOG_LEVEL_KEY, + ARG_HELP: ARG_LOG_LEVEL_HELP, + ARG_CHOICES: ARG_LOG_LEVEL_CHOICES, + ARG_DEFAULT: ARG_LOG_LEVEL_DEFAULT, + }, +] diff --git a/helpers/json_helper.py b/helpers/json_helper.py new file mode 100644 index 0000000..3638739 --- /dev/null +++ b/helpers/json_helper.py @@ -0,0 +1,16 @@ +import json + + +class JsonEncoder(json.JSONEncoder): + """Class for JSON encoding.""" + + def default(self, obj): + if isinstance(obj, set): + return list(obj) + + return json.JSONEncoder.default(self, obj) + + +def to_json(data: dict) -> bytes: + """Encode the provided dictionary as JSON.""" + return bytes(json.dumps(data, cls=JsonEncoder), "utf-8") diff --git a/helpers/login_codes.py b/helpers/login_codes.py new file mode 100644 index 0000000..cc2cbec --- /dev/null +++ b/helpers/login_codes.py @@ -0,0 +1,13 @@ +import re + + +class LoginCodes: + """'Container' for all valid login codes.""" + + def __contains__(self, value) -> bool: + if not isinstance(value, str): + return False + return bool(re.fullmatch(r"\d{4,6}", value)) + + def __iter__(self): + yield "000000" diff --git a/managers/alarm_manager.py b/managers/alarm_manager.py new file mode 100644 index 0000000..30890ca --- /dev/null +++ b/managers/alarm_manager.py @@ -0,0 +1,182 @@ +from http.server import HTTPServer +import json +import logging +import threading +from time import sleep +from typing import Optional + +import _thread +from api import pima +from helpers.const import CMD_ARM, CMD_STATUS +from managers.configuration_manager import ConfigurationManager +from managers.mqtt_manager import MQTTManager + +_LOGGER = logging.getLogger(__name__) + + +class AlarmManager(threading.Thread): + """Class maintaining the current status and sends commands to the alarm.""" + + def __init__(self, configuration_manager: ConfigurationManager) -> None: + self._mqtt_manager: MQTTManager = MQTTManager( + configuration_manager, self._callback + ) + self._alarm: Optional[pima.Alarm] = None + self._httpd: Optional[HTTPServer] = None + self._configuration_manager = configuration_manager + self._alarm_args = None + self._status_lock = None + self._alarm_lock = None + self._status = None + self._is_ready = False + + super().__init__(name="PIMA Alarm Server") + + def initialize(self): + try: + if self._configuration_manager.can_connect: + self._create_alarm() + + self._status_lock = threading.Lock() + self._alarm_lock = threading.Lock() + + self._is_ready = True + except pima.Error: + _LOGGER.exception("Failed to create alarm object.") + + def __del__(self) -> None: + if self._alarm: + del self._alarm + + def run(self) -> None: + """Continuously query the alarm for status.""" + while True: + try: + with self._alarm_lock: + status = self._alarm.get_status() # type: pima.Status + while not status["logged in"]: + # Re-login if previous session ended. + status = self._alarm.login( + self._configuration_manager.pima_login + ) + + self._set_status(status) + sleep(1) + + except Exception as ex: + logging.exception(f"Exception raised by Alarm, Error: {ex}") + try: + with self._alarm_lock: + _LOGGER.info("Trying to create the Alarm anew.") + self._create_alarm() + + except pima.Error: + _LOGGER.exception( + "Failed to recreate Alarm object. Exit for a clean restart." + ) + _thread.interrupt_main() + + def get_status(self) -> pima.Status: + """Gets the internally stored alarm status.""" + with self._status_lock: + return self._status + + def arm(self, mode: pima.Arm, partitions: pima.Partitions) -> pima.Status: + """Arms (or disarms) the alarm, returning the status.""" + with self._alarm_lock: + status = self._alarm.arm(mode, partitions) # type: pima.Status + self._set_status(status) + return status + + def _set_status(self, status: pima.Status) -> None: + with self._status_lock: + if self._status == status: + return # No update, ignore. + + self._status = status + + _LOGGER.info(f"Status: self._status.") + + self._mqtt_manager.publish_status(status) + + def _create_alarm(self) -> None: + configuration_manager = self._configuration_manager + + self._alarm = pima.Alarm( + configuration_manager.pima_zones, + configuration_manager.pima_serial_port, + configuration_manager.pima_host, + configuration_manager.pima_port, + ) # type: pima.Alarm + + self._status = self._alarm.get_status() # type: pima.Status + + _LOGGER.info(f"Status: {self._status}.") + + while not self._status["logged in"]: + self._status = self._alarm.login(self._configuration_manager.pima_login) + + _LOGGER.info(f"Status: {self._status}.") + + def _callback(self, payload: bytes): + payload_str = payload.decode("utf-8") + data = json.loads(payload_str) + + result = self.execute(CMD_ARM, data) + + error = result.get("error") + + if error is not None: + _LOGGER.error(f"Failed to run arm, Error: {error}, data: {data}") + + self._mqtt_manager.publish_status(result) + + def execute(self, command: str, data: Optional[dict] = None): + message = {} + + try: + handled = False + + if command is None: + message["error"] = "Missing command" + + handled = True + else: + if isinstance(command, list): + command = command[0] + + if not self._is_ready: + message["error"] = "No Server" + handled = True + + if not handled and command == CMD_STATUS: + message = self.get_status() + handled = True + + if not handled and command == CMD_ARM: + mode = data.get("mode") + + try: + if isinstance(mode, list): + mode = mode[0] + + mode = pima.Arm[mode.upper()] + + partitions = pima.Partitions( + {int(p) for p in data.get("partitions", ["1"])} + ) + + message = self.arm(mode, partitions) + + except KeyError: + message["error"] = f"Invalid arm mode [{mode}]" + + handled = True + + if not handled: + message["error"] = f"Invalid command" + + except Exception as ex: + _LOGGER.error(f"Failed to run command due to error: {ex}, data: {data}") + + return message diff --git a/managers/configuration_manager.py b/managers/configuration_manager.py new file mode 100644 index 0000000..f6a8205 --- /dev/null +++ b/managers/configuration_manager.py @@ -0,0 +1,155 @@ +import argparse +import logging +import os +from typing import List, Optional + +from helpers.const import * + +_LOGGER = logging.getLogger(__name__) + + +class ConfigurationManager: + api_web_ssl_cert: Optional[str] + api_web_ssl_key: Optional[str] + api_web_port: Optional[int] + api_key: Optional[str] + pima_login: Optional[str] + pima_zones: Optional[int] + pima_serial_port: Optional[str] + pima_host: Optional[str] + pima_port: Optional[int] + mqtt_host: Optional[str] + mqtt_port: Optional[int] + mqtt_client_id: Optional[str] + mqtt_user: Optional[str] + mqtt_password: Optional[str] + mqtt_topic: Optional[str] + log_level: Optional[str] + is_ssl: Optional[bool] + can_connect: Optional[bool] + + def __init__(self): + api_mode = os.getenv("API_MODE", "CMD") + + self.api_binds = SERVER_BIND + + if api_mode == "CMD": + args = self.get_args() + + self.api_ssl_cert = args.ssl_cert + self.api_ssl_key = args.ssl_key + self.api_port = args.port + self.api_key = args.key + self.login = args.login + self.pima_zones = args.zones + self.pima_serial_port = args.serialport + self.pima_host = args.pima_host + self.pima_port = args.pima_port + self.mqtt_host = args.mqtt_host + self.mqtt_port = args.mqtt_port + self.mqtt_client_id = args.mqtt_client_id + self.mqtt_topic = args.mqtt_topic + self.log_level = args.log_level + + mqtt_user_parts = args.mqtt_user.split(":", 1) + if len(mqtt_user_parts) > 1: + self.mqtt_username = mqtt_user_parts[0] + self.mqtt_password = mqtt_user_parts[0] + + else: + self.api_ssl_cert = os.getenv("API_SSL_CERT") + self.api_ssl_key = os.getenv("API_SSL_KEY") + self.api_port = os.getenv("API_PORT") + self.api_key = os.getenv("API_KEY") + self.pima_login = os.getenv("PIMA_LOGIN") + self.pima_zones = os.getenv("PIMA_ZONES") + self.pima_serial_port = os.getenv("PIMA_SERIAL_PORT") + self.pima_host = os.getenv("PIMA_HOST") + self.pima_port = os.getenv("PIMA_PORT") + self.mqtt_host = os.getenv("MQTT_HOST") + self.mqtt_port = os.getenv("MQTT_PORT") + self.mqtt_client_id = os.getenv("MQTT_CLIENT_ID") + self.mqtt_username = os.getenv("MQTT_USERNAME") + self.mqtt_password = os.getenv("MQTT_PASSWORD") + self.mqtt_topic = os.getenv("MQTT_TOPIC", "pima_alarm") + self.log_level = os.getenv("LOG_LEVEL", LOG_LEVEL_INFO) + + self.is_ssl = self._has_valid_content( + self.api_ssl_key + ) and self._has_valid_content(self.api_ssl_cert) + self.ssl_context = None + self.is_debug = self.log_level == "DEBUG" + + if self.is_ssl: + self.ssl_context = (self.api_ssl_cert, self.api_ssl_key) + + self.can_connect = False + + if self.pima_host and self.pima_port: + # Connected by ethernet + _LOGGER.debug(f"IP Address: {self.pima_host}:{self.pima_port}.") + + self.can_connect = True + + elif self.pima_serial_port: + # Connected by Serial + _LOGGER.debug(f"Port: {self.pima_serial_port}.") + + self.can_connect = True + + else: + # Connected by serial port + try: + ports = os.listdir(SERIAL_BASE) # type: List[str] + + if ports: + self.can_connect = True + + self.pima_serial_port = os.path.join(SERIAL_BASE, ports[0]) + + _LOGGER.debug(f"Port: {self.pima_serial_port}.") + + else: + _LOGGER.error("Serial port is missing!") + + except OSError: + _LOGGER.exception("Failed to lookup serial port.") + + @staticmethod + def get_args() -> argparse.Namespace: + """Parse command line arguments.""" + arg_parser = argparse.ArgumentParser( + description="JSON server for PIMA alarms.", allow_abbrev=False + ) + + for item in ARGS: + short_key = item.get(ARG_SHORT_KEY) + + if short_key is None: + arg_parser.add_argument( + f"--{item.get(ARG_KEY)}", + help=item.get(ARG_HELP), + default=item.get(ARG_DEFAULT), + required=item.get(ARG_REQUIRED, False), + type=item.get(ARG_TYPE), + choices=item.get(ARG_CHOICES), + ) + + else: + arg_parser.add_argument( + f"-{short_key}", + f"--{item.get(ARG_KEY)}", + help=item.get(ARG_HELP), + default=item.get(ARG_DEFAULT), + required=item.get(ARG_REQUIRED, False), + type=item.get(ARG_TYPE), + choices=item.get(ARG_CHOICES), + ) + + parsed_args = arg_parser.parse_args() + + return parsed_args + + @staticmethod + def _has_valid_content(data): + return data is not None and data != "" diff --git a/managers/mqtt_manager.py b/managers/mqtt_manager.py new file mode 100644 index 0000000..de1e3db --- /dev/null +++ b/managers/mqtt_manager.py @@ -0,0 +1,104 @@ +import logging +from os import path +import socket +from time import sleep +from typing import Optional + +from helpers.const import CMD_ARM, CMD_STATUS +from helpers.json_helper import to_json +from managers.configuration_manager import ConfigurationManager +from paho.mqtt.client import Client, MQTTMessage + +_LOGGER = logging.getLogger(__name__) + + +class MQTTManager: + def __init__(self, configuration_manager: ConfigurationManager, callback): + self._mqtt_client = None # type: Optional[Client] + self._configuration_manager = configuration_manager + self._topic_subscribe = None + self._topic_publish = None + self._topic_lwt = None + self._callback = callback + + self._is_ready = False + + this = self + + def mqtt_on_connect(client: Client, userdata, flags, rc): + _LOGGER.error("MQTT Client connected") + + client.subscribe(this._topic_subscribe) + + def mqtt_on_disconnect(client: Client, userdata, flags, rc): + _LOGGER.error("MQTT Client disconnected") + + this._is_ready = False + + this.connect() + + def mqtt_on_message(client: Client, userdata, message: MQTTMessage): + result = self._callback(message.payload) + + this.publish_status(result) + + self._mqtt_on_connect = mqtt_on_connect + self._mqtt_on_disconnect = mqtt_on_disconnect + self._mqtt_on_message = mqtt_on_message + + def _get_topic(self, key): + topic = path.join(self._configuration_manager.mqtt_topic, key) + + return topic + + def connect(self): + if self._configuration_manager.mqtt_host: + self._topic_publish = self._get_topic(CMD_STATUS) + self._topic_subscribe = self._get_topic(CMD_ARM) + self._topic_lwt = self._get_topic("LWT") + + self._mqtt_client = Client( + client_id=self._configuration_manager.mqtt_client_id, clean_session=True + ) + + self._mqtt_client.on_connect = self._mqtt_on_connect + self._mqtt_client.on_disconnect = self._mqtt_on_disconnect + self._mqtt_client.on_message = self._mqtt_on_message + + if ( + self._configuration_manager.mqtt_username + and self._configuration_manager.mqtt_password + ): + self._mqtt_client.username_pw_set( + self._configuration_manager.mqtt_username, + self._configuration_manager.mqtt_password, + ) + + self._mqtt_client.will_set(self._topic_lwt, payload="offline", retain=True) + + while True: + try: + self._mqtt_client.connect( + self._configuration_manager.mqtt_host, + self._configuration_manager.mqtt_port, + ) + + self._is_ready = True + except (socket.timeout, OSError): + _LOGGER.exception( + "Failed to connect to MQTT broker. Retrying in 5 seconds..." + ) + sleep(5) + else: + break + + self._mqtt_publish_lwt_online() + self._mqtt_client.loop_start() + + def publish_status(self, status: dict) -> None: + if self._is_ready: + self._mqtt_client.publish(self._topic_publish, payload=to_json(status)) + + def _mqtt_publish_lwt_online(self) -> None: + if self._is_ready: + self._mqtt_client.publish(self._topic_lwt, payload="online", retain=True) diff --git a/pima.py b/pima.py deleted file mode 100644 index 2839d21..0000000 --- a/pima.py +++ /dev/null @@ -1,298 +0,0 @@ -#!/usr/bin/env python3 -""" -This module implements an interface for negotiation with PIMA Hunter Pro alarms. -It was built based on PIMA's General Specification for Home Automation & -Building Management protocol Ver. 1.15. -PIMA is a trademark of PIMA Electronic Systems Ltd, http://www.pima-alarms.com. -This module was built with no affiliation of PIMA Electronic Systems Ltd. - -Copyright © 2019 Dror Eiger - -This module is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This module is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -__author__ = 'droreiger@gmail.com (Dror Eiger)' - -import collections -import crcmod -import enum -import io -import logging -import serial -import socket -import termios -import time -import typing - - -class Error(Exception): - """Error class for PIMA alarm handling.""" - pass - - -class GarbageInputError(Error): - """Error class when PIMA alarm reports garbage.""" - pass - - -class Arm(enum.Enum): - """Arming mode for the PIMA alarm.""" - FULL_ARM = b'\x01' - HOME1 = b'\x02' - HOME2 = b'\x03' - DISARM = b'\x00' - - -Status = typing.NewType('Status', typing.Dict[str, typing.Any]) -Partitions = typing.NewType('Partitions', typing.Set[int]) - - -class Alarm(object): - """Class wrapping the protocol for PIMA alarm.""" - _ZONES_TO_MODULE_ID = {32: b'\x0d', 96: b'\x0d', 144: b'\x13'} - _ZONES_TO_ZONE_BYTES = {32: 12, 96: 12, 144: 18} - class _Message(enum.Enum): - WRITE = b'\x0f' - READ = b'\x0e' - OPEN = b'\x01' - CLOSE = b'\x19' - STATUS = b'\x05' - class _Channel(enum.Enum): - IDLE = b'\x00' - SYSTEM = b'\x01' - ZONES = b'\x02' - OUTPUTS = b'\x03' - LOGIN = b'\x04' - PARAMETER = b'\x05' - _DISCRETE_FAILURES = { - 1: 'System Low Power', - 2: 'Unknown (2)', - 3: 'System Error', - 4: 'Zone Failure', - 5: 'Unknown (5)', - 6: 'Auxiliary Voltage Failure (Fuse short)', - 7: 'W/L Zone Low Battery', - 8: 'Wireless Receiver Failure', - 9: 'Low Battery', - 10: 'Telephone Line Failure', - 11: 'MAINS Failure (220V)', - 12: 'Tamper 1 Open', - 13: 'Tamper 2 Open', - 14: 'Clock Not Set', - 15: 'RAM Error', - 16: 'Station Commuincation Failure', - 17: 'Siren 1 Failure', - 18: 'Siren 2 Failure', - 19: 'SMS Communication', - 20: 'SMS Card', - 21: 'GSM200 Error', - 22: 'Network Comm. Fault', - 23: 'Radio Fault', - 24: 'Keyfob Rec. Fault', - 25: 'Wireless Receiver Tamper Open', - 26: 'Wireless Jamming', - 27: 'GSM-200 Failure', - 28: 'GSM Communication Failure', - 29: 'GSM-SIM Failure', - 30: 'GSM Link Failure', - 31: 'GSM Comm. Fault 2nd station', - 32: 'W/L Zone Supervision', - 33: 'Unknown (33)', - 34: 'Network fault Station 2', - 35: 'Net4Pro Fault', - 36: 'VVR 1 Fault', - 37: 'VVR 2 Fault', - 38: 'VVR 3 Fault', - 39: 'VVR 4 Fault', - 40: 'VVR 1 Power Fault', - 41: 'VVR 2 Power Fault', - 42: 'VVR 3 Power Fault', - 43: 'VVR 4 Power Fault', - 44: 'Unknown (44)', - 45: 'Unknown (45)', - 46: 'Unknown (46)', - 47: 'Unknown (47)', - 48: 'Unknown (48)', - } - - def __init__(self, zones: int, serialport: str = None, - ipaddr: str = None, ipport: int = None) -> None: - if serialport is not None: - try: - self._channel = serial.Serial(port=serialport, - baudrate=2400, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - timeout=1) - except (termios.error, serial.serialutil.SerialException) as e: - self._channel = None - raise Error('Failed to connect to serial port.') from e - else: - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect((ipaddr, ipport)) - self._channel = socket.SocketIO(sock, 'rwb') - except (socket.error, socket.gaierror) as e: - self._channel = None - raise Error('Error creating socket.') from e - self._crc = crcmod.mkCrcFun(0x18005, rev=True, initCrc=0x0000, - xorOut=0x0000) - self._zones = zones # type: int - self._module_id = self._ZONES_TO_MODULE_ID[self._zones] # type: bytes - - def __del__(self) -> None: - self._close() - - def __enter__(self): - return self - - def __exit__(self, unused_type, unused_value, unused_traceback) -> None: - self._close() - - def login(self, code: str) -> Status: - data = bytes([int(digit) for digit in code]).ljust(6, b'\xff') - self._read_message() - self._send_message(self._Message.WRITE, self._Channel.LOGIN, data=data) - return self.get_status() - - def get_status(self) -> Status: - """Returns the current alarm status.""" - try: - response = self._read_message() - except GarbageInputError as ex: - logging.info('Exception: %r.', ex) - # Clear up a messy channel (sometime happens on startup). - d = b'\xf3' - while d == d[:1] * len(d): - d = self._channel.readline() - logging.debug('Read message: %r.', d) - response = self._read_message() - self._send_message(self._Message.STATUS, self._Channel.IDLE) - data = Status({'logged in': False}) - if not response: - return data - if response[2:3] != self._Message.STATUS.value: - raise Error('Invalid message {}.'.format(self._make_hex(response[2:3]))) - if response[3:4] == self._Channel.IDLE.value: - return data - if response[3:4] != self._Channel.SYSTEM.value: - raise Error('Invalid status {}.'.format(self._make_hex(response[3:4]))) - if response[4:7] != b'\x02\x00\x00': - raise Error('Invalid address {}.'.format(self._make_hex(response[4:7]))) - # Calculate the break points. - zone_bytes = range(7, len(response), - self._ZONES_TO_ZONE_BYTES[self._zones])[:5] - # Get the data chuncks. - # HP32 zones us using only the first bytes. - zone_data = [response[i:i + self._zones // 8] for i in zone_bytes[:-1]] - data['open zones'] = self._parse_bytes(zone_data[0]) - data['alarmed zones'] = self._parse_bytes(zone_data[1]) - data['bypassed zones'] = self._parse_bytes(zone_data[2]) - data['failed zones'] = self._parse_bytes(zone_data[3]) - index = zone_bytes[-1] - data['partitions'] = {} - for partition, value in enumerate(response[index:index+16], 1): - data['partitions'][partition] = Arm(bytes([value])).name.lower() - index += 16 - failures = self._parse_bytes(response[index:index+6]) - failures = {self._DISCRETE_FAILURES[failure] for failure in failures} - index += 6 - for fail_type, count in (('Keypad %d Failure', 1), - ('Keypad %d Tamper', 1), - ('Zone Expander %d Failure', 2), - ('Zone Expander %d Tamper', 2), - ('Zone Expander %d Low Voltage', 2), - ('Zone Expander %d AC Failure', 2), - ('Zone Expander %d Low Battery', 2), - ('Out Expander %d Failure', 1), - ('Out Expander %d Tamper', 1), - ('Out Expander %d Low Voltage', 1), - ('Out Expander %d AC Failure', 1), - ('Out Expander %d Low Battery', 1)): - clustered_failures = self._parse_bytes(response[index:index+count]) - for failure in clustered_failures: - failures.add(fail_type % failure) - index += count - if failures: - data['failures'] = failures - # Skip ID Account - index += 4 - flags = response[index] - data['logged in'] = bool(flags & 1 << 0) - data['command ack'] = bool(flags & 1 << 1) - return data - - def arm(self, mode: Arm, partitions: Partitions) -> Status: - """Arms (or disarms) the provided alarm partitions.""" - self._read_message() - address = sum(1<<(p-1) for p in partitions).to_bytes(2, byteorder='little') - self._send_message( - self._Message.OPEN if mode == Arm.DISARM else self._Message.CLOSE, - self._Channel.SYSTEM, address=address, data=mode.value) - return self.get_status() - - def zones(self) -> Status: - raise NotImplementedError("No support yet for zones.") - - def outputs(self) -> Status: - raise NotImplementedError("No support yet for outputs.") - - def parameters(self) -> Status: - raise NotImplementedError("No support yet for parameters.") - - def _read_message(self) -> bytes: - data = None - while not data: - data = self._channel.read(1) - length = ord(data) - data = bytes([length]) + self._channel.read(length + 2) - if data == bytes([length]) * len(data): - raise GarbageInputError('Garbage ({})!'.format(length)) - if len(data) != length + 3: - raise Error('Not enough data in channel: {} should have {} bytes.'.format( - self._make_hex(data), length + 3)) - logging.debug('>>> ' + self._make_hex(data)) - data, crc = data[:-2], int.from_bytes(data[-2:], byteorder='big') - if (crc != self._crc(data)): - raise Error('Invalid input on channel, CRC for {} is {}, not {}!'.format( - self._make_hex(data), self._crc(data), crc)) - if self._module_id != data[1:2]: - raise Error('Invalid module ID. Expected {}, got {}'.format( - self._make_hex(self._module_id), self._make_hex(data[1:2]))) - return data - - def _send_message(self, message: _Message, channel: _Channel, - address: bytes=b'', data: bytes=b'') -> None: - output = b''.join((self._module_id, message.value, channel.value, - bytes([len(address)]), address, data)) - output = bytes([len(output)]) + output - output += self._crc(output).to_bytes(2, byteorder='big') - logging.debug('<<< ' + self._make_hex(output)) - self._channel.write(output) - if message != self._Message.STATUS: - time.sleep(1) - - @staticmethod - def _parse_bytes(data: bytes) -> typing.Set[int]: - bits = int.from_bytes(data, byteorder='little') - return {i+1 for i in range(bits.bit_length()) if bits & 1 << i} - - @staticmethod - def _make_hex(data: bytes) -> str: - return ' '.join('%02x' % d for d in data) - - def _close(self) -> None: - if self._channel: - self._channel.close() - self._channel = None diff --git a/pima_server.py b/pima_server.py deleted file mode 100644 index 1747a0b..0000000 --- a/pima_server.py +++ /dev/null @@ -1,348 +0,0 @@ -#!/usr/bin/env python3 -""" -JSON server module for PIMA alarms. -Uses pima.py to connect and control the alarm. - -PIMA is a trademark of PIMA Electronic Systems Ltd, http://www.pima-alarms.com. -This module was built with no affiliation of PIMA Electronic Systems Ltd. - -Copyright © 2019 Dror Eiger - -This module is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This module is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -__author__ = 'droreiger@gmail.com (Dror Eiger)' - -import argparse -from http.server import HTTPServer, BaseHTTPRequestHandler -import json -import logging -import logging.handlers -import re -import os -import paho.mqtt.client as mqtt -import socket -import ssl -import sys -import threading -import time -import typing -from urllib.parse import parse_qs, urlparse, ParseResult -import _thread - -import pima - - -class AlarmServer(threading.Thread): - """Class maintaining the current status and sends commands to the alarm.""" - - _SERIAL_BASE = '/dev/serial/by-path' - - def __init__(self) -> None: - self._alarm: pima.Alarm = None - ipaddr: str = None - ipport: int = None - serialport: str = None - if _parsed_args.pima_host and _parsed_args.pima_port: - # Connected by ethernet - ipaddr = _parsed_args.pima_host - ipport = _parsed_args.pima_port - logging.debug('IP Address: %s:%d.', ipaddr, ipport) - elif _parsed_args.serialport: - # Connected by Serial - serialport = _parsed_args.serialport - logging.debug('Port: %s.', serialport) - else: - # Connected by serial port - try: - ports = os.listdir(self._SERIAL_BASE) # type: typing.List[str] - except IOError: - logging.exception('Failed to lookup serial port.') - sys.exit(1) - if not ports: - logging.error('Serial port is missing!') - sys.exit(1) - serialport = os.path.join(self._SERIAL_BASE, ports[0]) - logging.debug('Port: %s.', serialport) - self._alarm_args = _parsed_args.zones, serialport, ipaddr, ipport # type: tuple - try: - self._create_alarm() - except pima.Error: - logging.exception('Failed to create alarm object.') - sys.exit(1) - self._status_lock = threading.Lock() - self._alarm_lock = threading.Lock() - super(AlarmServer, self).__init__(name='PIMA Alarm Server') - - def __del__(self) -> None: - if self._alarm: - del self._alarm - - def run(self) -> None: - """Continuously query the alarm for status.""" - while True: - try: - with self._alarm_lock: - status = self._alarm.get_status() # type: pima.Status - while not status['logged in']: - # Re-login if previous session ended. - status = self._alarm.login(_parsed_args.login) - self._set_status(status) - time.sleep(1) - except: - logging.exception('Exception raised by Alarm.') - try: - with self._alarm_lock: - logging.info('Trying to create the Alarm anew.') - self._create_alarm() - except pima.Error: - logging.exception('Failed to recreate Alarm object. Exit for a clean restart.') - _thread.interrupt_main() - - def get_status(self) -> pima.Status: - """Gets the internally stored alarm status.""" - with self._status_lock: - return self._status - - def arm(self, mode: pima.Arm, partitions: pima.Partitions) -> pima.Status: - """Arms (or disarms) the alarm, returning the status.""" - with self._alarm_lock: - status = self._alarm.arm(mode, partitions) # type: pima.Status - self._set_status(status) - return status - - def _set_status(self, status: pima.Status) -> None: - with self._status_lock: - if self._status == status: - return # No update, ignore. - self._status = status - logging.info('Status: %s.', self._status) - mqtt_publish_status(status) - - def _create_alarm(self) -> None: - self._alarm = pima.Alarm(*self._alarm_args) # type: pima.Alarm - self._status = self._alarm.get_status() # type: pima.Status - logging.info('Status: %s.', self._status) - while not self._status['logged in']: - self._status = self._alarm.login(_parsed_args.login) - logging.info('Status: %s.', self._status) - - -def RunJsonCommand(query: dict) -> dict: - _CMD_STATUS = 'status' - _CMD_ARM = 'arm' - if not _pima_server: - return {'error': 'No server.'} - try: - command = query['command'] - except KeyError: - return {'error': 'Missing command.'} - if isinstance(command, list): - command = command[0] - if command == _CMD_STATUS: - return _pima_server.get_status() - if command == _CMD_ARM: - try: - mode = query['mode'] - if isinstance(mode, list): - mode = mode[0] - mode = pima.Arm[mode.upper()] - except KeyError: - return {'error': 'Invalid arm mode.'} - partitions = pima.Partitions( - {int(p) for p in query.get('partitions', ['1'])}) - return _pima_server.arm(mode, partitions) - return {'error': 'Invalid command.'} - - -class JsonEncoder(json.JSONEncoder): - """Class for JSON encoding.""" - def default(self, obj): - if isinstance(obj, set): - return list(obj) - return json.JSONEncoder.default(self, obj) - - -def to_json(data: dict) -> bytes: - """Encode the provided dictionary as JSON.""" - return bytes(json.dumps(data, cls=JsonEncoder), 'utf-8') - - -def from_json(data: bytes) -> dict: - """Encode the provided dictionary as JSON.""" - return json.loads(data.decode('utf-8')) - - -class HTTPRequestHandler(BaseHTTPRequestHandler): - """Handler for PIMA alarm http requests.""" - _PIMA_URL = '/pima' - - def do_HEAD(self) -> None: - """Return a JSON header.""" - self.send_response(200) - self.send_header('Content-type', 'application/json') - self.end_headers() - - def do_GET(self) -> None: - """Vaildate and run the request.""" - self.do_HEAD() - logging.debug('Request: %s', self.path) - parsed_url = urlparse(self.path) - query = parse_qs(parsed_url.query) - if not self.is_valid_url(parsed_url.path, query) or not _pima_server: - self.write_json({'error': 'Invalid URL.'}) - return - try: - self.write_json(RunJsonCommand(query)) - except pima.Error: - logging.exception('Failed to run command.') - self.write_json({'error': 'Failed to run command.'}) - sys.exit(1) - - def write_json(self, data: dict) -> None: - """Send out the provided data dict as JSON.""" - logging.debug('Response: %r', data) - self.wfile.write(to_json(data)) - - @classmethod - def is_valid_url(cls, path: str, query: dict) -> bool: - """Validate the provided URL.""" - if path != cls._PIMA_URL: - return False - if query.get('key',[''])[0] != _parsed_args.key: - return False - return True - - -def mqtt_on_connect(client: mqtt.Client, userdata, flags, rc): - client.subscribe(_mqtt_topics['sub']) - - -def mqtt_on_message(client: mqtt.Client, userdata, message: mqtt.MQTTMessage): - mqtt_publish_status(RunJsonCommand(from_json(message.payload))) - - -def mqtt_publish_status(status: dict) -> None: - if _mqtt_client: - _mqtt_client.publish(_mqtt_topics['pub'], payload=to_json(status)) - - -def mqtt_publish_lwt_online() -> None: - if _mqtt_client: - _mqtt_client.publish(_mqtt_topics['lwt'], payload='online', retain=True) - - -class LoginCodes(object): - """'Container' for all valid login codes.""" - def __contains__(self, value) -> bool: - if not isinstance(value, str): - return False - return bool(re.fullmatch(r'\d{4,6}', value)) - def __iter__(self): - yield '000000' - - -def ParseArguments() -> argparse.Namespace: - """Parse command line arguments.""" - arg_parser = argparse.ArgumentParser( - description='JSON server for PIMA alarms.', - allow_abbrev=False) - arg_parser.add_argument('--ssl_cert', - help='Path to SSL certificate file.') - arg_parser.add_argument('--ssl_key', default=None, - help='Path to SSL key file.') - arg_parser.add_argument('-p', '--port', required=True, type=int, - help='Port for the server.') - arg_parser.add_argument('-k', '--key', required=True, - help='URL key to authenticate calls.') - arg_parser.add_argument('-l', '--login', required=True, choices=LoginCodes(), - help='Login code to the PIMA alarm.') - arg_parser.add_argument('-z', '--zones', type=int, default=32, - choices={32, 96, 144}, help='Alarm supported zones.') - arg_parser.add_argument('--serialport', default=None, - help='Serial port, e.g. /dev/serial0. Needed if connected directly through GPIO serial.') - arg_parser.add_argument('--pima_host', default=None, - help='Pima alarm hostname or IP address. if connected by ethernet.') - arg_parser.add_argument('--pima_port', type=int, default=None, - help='Pima alarm port. if connected by ethernet.') - arg_parser.add_argument('--mqtt_host', default=None, - help='MQTT broker hostname or IP address.') - arg_parser.add_argument('--mqtt_port', type=int, default=1883, - help='MQTT broker port.') - arg_parser.add_argument('--mqtt_client_id', default=None, - help='MQTT client ID.') - arg_parser.add_argument('--mqtt_user', default=None, - help=' for the MQTT channel.') - arg_parser.add_argument('--mqtt_topic', default='pima_alarm', - help='MQTT topic.') - arg_parser.add_argument('--log_level', default='WARNING', - choices={'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'}, - help='Minimal log level.') - return arg_parser.parse_args() - - -if __name__ == '__main__': - _parsed_args = ParseArguments() # type: argparse.Namespace - - log_socket = '/var/run/syslog' if sys.platform == 'darwin' else '/dev/log' - logging_handler = logging.handlers.SysLogHandler(address=log_socket) - logging_handler.setFormatter( - logging.Formatter(fmt='{levelname[0]}{asctime}.{msecs:03.0f} ' - '{filename}:{lineno}] {message}', - datefmt='%m%d %H:%M:%S', style='{')) - logger = logging.getLogger() - logger.setLevel(_parsed_args.log_level) - logger.addHandler(logging_handler) - - _pima_server = AlarmServer() # type: AlarmServer - _pima_server.start() - - _mqtt_client = None # type: typing.Optional[mqtt.Client] - _mqtt_topics = {} # type: typing.Dict[str, str] - if _parsed_args.mqtt_host: - _mqtt_topics['pub'] = os.path.join(_parsed_args.mqtt_topic, 'status') - _mqtt_topics['sub'] = os.path.join(_parsed_args.mqtt_topic, 'command') - _mqtt_topics['lwt'] = os.path.join(_parsed_args.mqtt_topic, 'LWT') - _mqtt_client = mqtt.Client(client_id=_parsed_args.mqtt_client_id, - clean_session=True) - _mqtt_client.on_connect = mqtt_on_connect - _mqtt_client.on_message = mqtt_on_message - if _parsed_args.mqtt_user: - _mqtt_client.username_pw_set(*_parsed_args.mqtt_user.split(':',1)) - _mqtt_client.will_set(_mqtt_topics['lwt'], payload='offline', retain=True) - while True: - try: - _mqtt_client.connect(_parsed_args.mqtt_host, _parsed_args.mqtt_port) - except (socket.timeout, OSError): - logging.exception('Failed to connect to MQTT broker. Retrying in 5 seconds...') - time.sleep(5) - else: - break - mqtt_publish_lwt_online() - _mqtt_client.loop_start() - - httpd = HTTPServer(('', _parsed_args.port), HTTPRequestHandler) - if _parsed_args.ssl_cert: - httpd.socket = ssl.wrap_socket(httpd.socket, certfile=_parsed_args.ssl_cert, - keyfile=_parsed_args.ssl_key, - server_side=True) - try: - httpd.serve_forever() - except KeyboardInterrupt: - pass - - httpd.server_close() - if _mqtt_client: - _mqtt_client.loop_stop() - del _pima_server diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a9f7a40 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +target-version = ["py37", "py38"] \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b98bb3d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,40 @@ +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build +doctests = True +# To work with Black +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +# F405 +# F403 +ignore = + E501, + W503, + E203, + D202, + W504, + F405, + F403, + +[isort] +# https://github.com/timothycrosley/isort +# https://github.com/timothycrosley/isort/wiki/isort-Settings +# splits long import on multiple lines indented by 4 spaces +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " +# by default isort don't check module indexes +not_skip = __init__.py +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = homeassistant,tests +forced_separate = tests +combine_as_imports = true \ No newline at end of file