├── mochad_dispatch ├── __init__.py └── main.py ├── Dockerfile ├── setup.py ├── LICENSE ├── .gitignore ├── tests └── test_func_decode.py └── README.rst /mochad_dispatch/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jfloff/alpine-python:3.4 2 | COPY . /src 3 | RUN cd /src; python setup.py install -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | VERSION = "1.0.0" 4 | 5 | REQUIRES = [ 6 | "paho-mqtt", 7 | "pytz", 8 | ] 9 | 10 | setup( 11 | name="mochad_dispatch", 12 | version=VERSION, 13 | description="mochad_dispatch is a daemon written in Python that translates mochad's tcp-based events to MQTT messages", 14 | url="https://github.com/StoaferP/mochad_dispatch", 15 | download_url="https://github.com/StoaferP/mochad_dispatch/archive/{}.zip".format(VERSION), 16 | author="Chris Przybycien", 17 | author_email="chrisisdiy@gmail.com", 18 | license="MIT", 19 | packages=["mochad_dispatch"], 20 | long_description=open("README.rst").read(), 21 | zip_safe=False, 22 | install_requires=REQUIRES, 23 | test_suite="tests", 24 | entry_points={"console_scripts": ["mochad_dispatch = mochad_dispatch.main:main"]}, 25 | classifiers=[ 26 | "License :: OSI Approved :: MIT License", 27 | "Operating System :: POSIX :: Linux", 28 | "Programming Language :: Python :: 3.4", 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Christopher Przybycien 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 | 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | 176 | # IDE stuff 177 | .vscode/ 178 | -------------------------------------------------------------------------------- /tests/test_func_decode.py: -------------------------------------------------------------------------------- 1 | from mochad_dispatch.main import MochadClient 2 | 3 | MOCHAD_FUNCS = { 4 | "Motion_alert_MS10A": { 5 | "device_type": "MS10A", 6 | "event_type": "motion", 7 | "event_state": "alert", 8 | }, 9 | "Motion_normal_MS10A": { 10 | "device_type": "MS10A", 11 | "event_type": "motion", 12 | "event_state": "normal", 13 | }, 14 | "Motion_alert_low_MS10A": { 15 | "device_type": "MS10A", 16 | "event_type": "motion", 17 | "event_state": "alert", 18 | "low_battery": True, 19 | }, 20 | "Motion_normal_low_MS10A": { 21 | "device_type": "MS10A", 22 | "event_type": "motion", 23 | "event_state": "normal", 24 | "low_battery": True, 25 | }, 26 | "Motion_alert_SP554A": { 27 | "device_type": "SP554A", 28 | "event_type": "motion", 29 | "event_state": "alert", 30 | }, 31 | "Motion_normal_SP554A": { 32 | "device_type": "SP554A", 33 | "event_type": "motion", 34 | "event_state": "normal", 35 | }, 36 | "Motion_alert_Home_Away_SP554A": { 37 | "device_type": "SP554A", 38 | "event_type": "motion", 39 | "event_state": "alert", 40 | "home_away": True, 41 | }, 42 | "Motion_normal_Home_Away_SP554A": { 43 | "device_type": "SP554A", 44 | "event_type": "motion", 45 | "event_state": "normal", 46 | "home_away": True, 47 | }, 48 | "Contact_alert_min_DS10A": { 49 | "device_type": "DS10A", 50 | "event_type": "contact", 51 | "event_state": "alert", 52 | "delay": "min", 53 | }, 54 | "Contact_normal_min_DS10A": { 55 | "device_type": "DS10A", 56 | "event_type": "contact", 57 | "event_state": "normal", 58 | "delay": "min", 59 | }, 60 | "Contact_alert_max_DS10A": { 61 | "device_type": "DS10A", 62 | "event_type": "contact", 63 | "event_state": "alert", 64 | "delay": "max", 65 | }, 66 | "Contact_normal_max_DS10A": { 67 | "device_type": "DS10A", 68 | "event_type": "contact", 69 | "event_state": "normal", 70 | "delay": "max", 71 | }, 72 | "Contact_alert_min_low_DS10A": { 73 | "device_type": "DS10A", 74 | "event_type": "contact", 75 | "event_state": "alert", 76 | "delay": "min", 77 | "low_battery": True, 78 | }, 79 | "Contact_normal_min_low_DS10A": { 80 | "device_type": "DS10A", 81 | "event_type": "contact", 82 | "event_state": "normal", 83 | "delay": "min", 84 | "low_battery": True, 85 | }, 86 | "Contact_alert_max_low_DS10A": { 87 | "device_type": "DS10A", 88 | "event_type": "contact", 89 | "event_state": "alert", 90 | "delay": "max", 91 | "low_battery": True, 92 | }, 93 | "Contact_normal_max_low_DS10A": { 94 | "device_type": "DS10A", 95 | "event_type": "contact", 96 | "event_state": "normal", 97 | "delay": "max", 98 | "low_battery": True, 99 | }, 100 | "Contact_alert_min_tamper_DS12A": { 101 | "device_type": "DS12A", 102 | "event_type": "contact", 103 | "event_state": "alert", 104 | "delay": "min", 105 | "tamper": True, 106 | }, 107 | "Contact_normal_min_tamper_DS12A": { 108 | "device_type": "DS12A", 109 | "event_type": "contact", 110 | "event_state": "normal", 111 | "delay": "min", 112 | "tamper": True, 113 | }, 114 | "Contact_alert_max_tamper_DS12A": { 115 | "device_type": "DS12A", 116 | "event_type": "contact", 117 | "event_state": "alert", 118 | "delay": "max", 119 | "tamper": True, 120 | }, 121 | "Contact_normal_max_tamper_DS12A": { 122 | "device_type": "DS12A", 123 | "event_type": "contact", 124 | "event_state": "normal", 125 | "delay": "max", 126 | "tamper": True, 127 | }, 128 | "Arm_KR10A": { 129 | "device_type": "KR10A", 130 | "command": "arm", 131 | }, 132 | "Disarm_KR10A": { 133 | "device_type": "KR10A", 134 | "command": "disarm", 135 | }, 136 | "Lights_On_KR10A": { 137 | "device_type": "KR10A", 138 | "command": "lights_on", 139 | }, 140 | "Lights_Off_KR10A": { 141 | "device_type": "KR10A", 142 | "command": "lights_off", 143 | }, 144 | "Panic_KR10A": { 145 | "device_type": "KR10A", 146 | "command": "panic", 147 | }, 148 | "Panic_KR15A": { 149 | "device_type": "KR15A", 150 | "command": "panic", 151 | }, 152 | "Arm_Home_min_SH624": { 153 | "device_type": "SH624", 154 | "command": "arm_home", 155 | "delay": "min", 156 | }, 157 | "Arm_Away_min_SH624": { 158 | "device_type": "SH624", 159 | "command": "arm_away", 160 | "delay": "min", 161 | }, 162 | "Arm_Home_max_SH624": { 163 | "device_type": "SH624", 164 | "command": "arm_home", 165 | "delay": "max", 166 | }, 167 | "Arm_Away_max_SH624": { 168 | "device_type": "SH624", 169 | "command": "arm_away", 170 | "delay": "max", 171 | }, 172 | "Disarm_SH624": { 173 | "device_type": "SH624", 174 | "command": "disarm", 175 | }, 176 | "Panic_SH624": { 177 | "device_type": "SH624", 178 | "command": "panic", 179 | }, 180 | "Lights_On_SH624": { 181 | "device_type": "SH624", 182 | "command": "lights_on", 183 | }, 184 | "Lights_Off_SH624": { 185 | "device_type": "SH624", 186 | "command": "lights_off", 187 | }, 188 | } 189 | 190 | 191 | def test_known_funcs(): 192 | for func_raw, func_dict in MOCHAD_FUNCS.items(): 193 | result = MochadClient.decode_func(True, func_raw) 194 | print(func_dict, result) 195 | assert result == func_dict 196 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | mochad_dispatch 3 | =============== 4 | 5 | **mochad_dispatch** allows you to connect your X10 security devices (door/window sensors, motion sensors, remotes) to home automation software like `OpenHAB `_, `Home Assistant `_ or `Domoticz `_ 6 | 7 | What exactly does it do? 8 | ======================== 9 | **mochad_dispatch** connects to `mochad `_ (which reads messages from a USB receiver like the X10 CM15a) and listens for X10 security and button-press messages which now includes power line receipt of control messages (Rx PL) then publishes those to an MQTT broker. 10 | 11 | It will automatically reconnect to both mochad and the MQTT broker. However, if a reconnect attempt fails for 60 seconds straight, **mochad_dispatch** will give up and exit. 12 | 13 | Usage description 14 | ----------------- 15 | :: 16 | 17 | usage: mochad_dispatch [-h] [-s SERVER] [-f] [-l] [-m MQTT_DISCOVERY] [--cafile CAFILE] [-c HOUSECODES] dispatch_uri 18 | 19 | positional arguments: 20 | dispatch_uri dispatch messages to this URI. mqtt://host:port[,user=username,pass=password] 21 | 22 | options: 23 | -h, --help show this help message and exit 24 | -s SERVER, --server SERVER 25 | IP/host of server running mochad (default 127.0.0.1) 26 | -f, --foreground Don't fork; run in foreground (for debugging) 27 | -l, --legacy Use legacy X10 topic format (default is HomeAssistant MQTT auto discovery format) 28 | -m MQTT_DISCOVERY, --mqtt-discovery MQTT_DISCOVERY 29 | MQTT discovery for Home Assistant (default homeassistant/5A0uqYZF2_mochad_dispatch) 30 | --cafile CAFILE File containing trusted CA certificates 31 | -c HOUSECODES, --housecodes HOUSECODES 32 | House codes for X10 devices (default ABCDEFGHIJKLMNOP) 33 | 34 | How do I use it? 35 | ================ 36 | Run mochad_dispatch with a mochad hostname and a MQTT URI 37 | :: 38 | 39 | $ mochad_dispatch -s hal9000 mqtt://mqtt.example.com:1883 40 | 41 | Then subscribe to the appropriate device topics. The general format is 42 | 43 | X10/**MOCHAD_HOST**/**KIND**/**ADDRESS** 44 | 45 | where **KIND** is **security** for RFSEC alerts and **button** for button presses from an X10 remote. Note that **button** events are sent at QoS 0 and without the retain flag so they will not persist. 46 | 47 | What about MQTT with TLS? 48 | ------------------------- 49 | For TLS support use the '--cafile' option like so 50 | :: 51 | 52 | $ mochad_dispatch -s hal9000 --cafile /etc/pki/tls/cert.pem mqtt://mqtt.example.com:8883 53 | 54 | What about MQTT username and password? 55 | -------------------------------------- 56 | For username and password use the ',user=theusername,pass=thepassword' appended to the URI like so 57 | :: 58 | 59 | $ mochad_dispatch -s hal9000 mqtt://mqtt.example.com:1883,user=theusername,pass=thepassword 60 | 61 | What about house code filtering? 62 | -------------------------------- 63 | You can also add filtering by house code as well using the -c/--housecodes optino and list your codes that you want to use. The default is all A thru P. To use just add -c AD or any other combination of house codes. 64 | :: 65 | 66 | $ mochad_dispatch -s hal9000 -c AD mqtt://mqtt.example.com:1883 67 | 68 | Home Assistant Integration 69 | ========================== 70 | Mochad Dispatch has the ability to dynamcally add binary sensors for state of devices. This is the defualt opertaion. These devices can used to trigger other automations. 71 | 72 | To switch off the Home Addistant integration through MQTT discovery, use -l/--legacy option. 73 | 74 | To change the mqtt category for the MQTT discovery to not use the default "homeassistant" or change the unique id for the node default of 5A0uqYZF2_mochad_dispatch 75 | :: 76 | 77 | $ mochad_dispatch -s hal9000 -c AD --mqtt-discovery homeassistant/5A0uqYZF2_mochad_dispatch mqtt://mqtt.example.com:1883 78 | 79 | Also, through configuration in Home Assistant for the X10 security devices, you can use configure this under the '''mqtt:''' heading. See https://www.home-assistant.io/integrations/alarm_control_panel.mqtt/ 80 | :: 81 | 82 | mqtt: 83 | - alarm_control_panel: 84 | name: "Alarm Panel" 85 | state_topic: "X10/hal9000/security/C8:21:B2" 86 | value_template: "{{value_json.command}}" 87 | 88 | Troubleshooting 89 | =============== 90 | mochad_dispatch has been tested with mochad 0.1.17 and Mosquitto 1.4.3 91 | 92 | Start by making sure your MQTT broker is relaying X10 messages by subscribing to the topic 93 | 94 | X10/# 95 | 96 | For example, using the mosquitto broker: 97 | :: 98 | 99 | $ mosquitto_sub -v -t X10/# 100 | X10/hal9000/security/C8:21:B2 {"dispatch_time": "2016-02-18T18:36:12.147877+00:00", "func": {"event_type": "contact", "event_state": "normal", "device_type": "DS10A", "delay": "min"}} 101 | X10/hal9000/security/33:8C:30 {"dispatch_time": "2016-02-18T18:30:42.763780+00:00", "func": {"event_state": "normal", "device_type": "DS10A", "delay": "min", "event_type": "contact"}} 102 | 103 | Dockerized App 104 | ============== 105 | Build the docker image (using the Dockerfile based on the jfloff/alpine-python image) and run the mochad_dispatch command. IMPORTANT: you must use the "-f" flag (to disable background/daemon mode) else the docker container will exit immediately. 106 | :: 107 | 108 | $ docker build -t mochad_dispatch . 109 | $ docker run -d -it mochad_dispatch mochad_dispatch -s hal9000 mqtt://mqtt.example.com:1883 -f 110 | 111 | Dockerized App Full Stack Example 112 | ================================= 113 | Run (and background) individual Docker containers to provide an MQTT broker, a MOCHAD daemon, and a MOCHAD_DISPATCH instance (assuming you've already built an image as described above): 114 | :: 115 | 116 | $ docker run -d --name=mosquitto -p 1883:1883 -p 9001:9001 sourceperl/mosquitto 117 | $ docker run -d --name=mochad -p 1099:1099 --device "/dev/bus/usb/005" jshridha/mochad:latest 118 | $ docker run --link mosquitto --link mochad:hal9000 -d -it mochad_dispatch mochad_dispatch -s hal9000 mqtt://mosquitto:1883 -f 119 | -------------------------------------------------------------------------------- /mochad_dispatch/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import os 5 | import signal 6 | import socket 7 | import time 8 | from datetime import datetime 9 | import pytz 10 | import argparse 11 | import urllib.parse 12 | import paho.mqtt.client as mqtt 13 | import json 14 | from paho.mqtt.enums import CallbackAPIVersion 15 | 16 | import threading 17 | 18 | import logging 19 | from logging.handlers import RotatingFileHandler 20 | 21 | base_path: str 22 | args: argparse.Namespace 23 | dispatcher_type: type[MqttDispatcher] 24 | main_logger: logging.Logger 25 | killer: GracefulKiller 26 | 27 | 28 | class GracefulKiller: 29 | kill_now = False 30 | 31 | def __init__(self): 32 | self.kill_now = False 33 | signal.signal(signal.SIGINT, self.exit_gracefully) 34 | signal.signal(signal.SIGTERM, self.exit_gracefully) 35 | 36 | def exit_gracefully(self, *args): 37 | self.kill_now = True 38 | main_logger.info("Caught signal, mochad_dispatch is exiting...") 39 | 40 | def do_kill_now(self): 41 | os.kill(os.getpid(), signal.SIGTERM) 42 | 43 | def errordie(self, message): 44 | """ 45 | Print error message then quit with exit code 46 | """ 47 | global main_logger 48 | prog = os.path.basename(sys.argv[0]) 49 | main_logger.error("{}: error: {}\n".format(prog, message)) 50 | self.do_kill_now() 51 | 52 | 53 | class SocketReader: 54 | def __init__(self, host, port): 55 | self.host = host 56 | self.port = port 57 | self.sock = None 58 | self.sock_file = None 59 | 60 | def open_connection(self): 61 | """Open the socket and prepare the file-like object.""" 62 | try: 63 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 64 | self.sock.connect((self.host, self.port)) 65 | self.sock_file = self.sock.makefile("r") 66 | except Exception as e: 67 | raise Exception("Could not connect to {}: {}".format(self.host, e)) 68 | 69 | def read_line(self): 70 | """Read a single line from the socket.""" 71 | if self.sock_file: 72 | return self.sock_file.readline().strip() 73 | else: 74 | raise ValueError("Connection is not open. Call open_connection first.") 75 | 76 | def read_to_eof(self): 77 | """Read all remaining content until EOF.""" 78 | if self.sock_file: 79 | return self.sock_file.read() 80 | else: 81 | raise ValueError("Connection is not open. Call open_connection first.") 82 | 83 | def close_connection(self): 84 | """Close the socket and associated file.""" 85 | if self.sock_file: 86 | self.sock_file.close() 87 | if self.sock: 88 | self.sock.close() 89 | 90 | 91 | class MqttDispatcher: 92 | """ 93 | MqttDispatcher object 94 | 95 | Used by MochadClient object to dispatch messages via MQTT 96 | 97 | :param mochad_host: The hostname of the mochad server. This will be used in the topic name 98 | :param dispatch_uri: The URI that describes an MQTT broker. Messages dispatched from MochadClient will be 99 | published to this broker. 100 | :param logger: Logger object to use 101 | :param cafile: The file containing trusted CA certificates. Specifying this will enable SSL/TLS encryption 102 | to the MQTT broker 103 | """ 104 | 105 | def __init__(self, mochad_host, dispatch_uri, logger, cafile, killer, legacy, mqtt_discovery): 106 | user, password = "", "" 107 | if "," in dispatch_uri: 108 | real_uri = dispatch_uri.split(",")[0] 109 | if "user" in dispatch_uri and "pass" in dispatch_uri: 110 | user = dispatch_uri.split(",")[1].split("=")[1] 111 | password = dispatch_uri.split(",")[2].split("=")[1] 112 | logger.debug(f"real_uri: {real_uri}, user: {user}, password: {password}") 113 | else: 114 | real_uri = dispatch_uri 115 | uri = urllib.parse.urlparse(real_uri) 116 | self.mochad_host = mochad_host 117 | self.logger = logger 118 | self.killer = killer 119 | self.legacy = legacy 120 | self.mqtt_discovery = mqtt_discovery.split("/")[0] 121 | self.mqtt_ha_id = mqtt_discovery.split("/")[1] 122 | self.devices_discovered = {} 123 | self.host = uri.hostname 124 | self.port = uri.port if uri.port else 1883 125 | mqtt_client_id = "mochadc/{}-{}".format(os.getpid(), socket.gethostname()) 126 | self.logger.info(f"mqtt_client_id: {mqtt_client_id}, mqtt host: {self.host}, mqtt port: {self.port}") 127 | self.mqttc = mqtt.Client(CallbackAPIVersion.VERSION2, mqtt_client_id) 128 | if user and password: 129 | self.logger.info(f"mqtt connection with username and password.") 130 | self.mqttc.username_pw_set(user, password) 131 | 132 | self.logger.debug("self.mqttc: {}".format(self.mqttc)) 133 | # connection error handling 134 | self.reconnect_time = -1 135 | 136 | def on_connect(client, userdata, flags, rc, properties): 137 | self.reconnect_time = 0 138 | 139 | def on_disconnect(client, userdata, flags, rc, properties): 140 | if self.reconnect_time == -1: 141 | # Why suggest SSL here? If on_disconnect is called BEFORE on_connect that means the socket initially 142 | # connected but failed BEFORE getting to the MQTT-specific negotiation. To my knowledge only SSL 143 | # happens in between those two 144 | self.logger.error("Could not connect to MQTT broker: possibly SSL/TLS failure") 145 | self.killer.do_kill_now() 146 | elif self.reconnect_time == 0: 147 | self.reconnect_time = time.time() 148 | 149 | self.mqttc.on_connect = on_connect 150 | self.mqttc.on_disconnect = on_disconnect 151 | 152 | # configure TLS if argument "cafile" is given 153 | if cafile: 154 | self.mqttc.tls_set(cafile) 155 | 156 | try: 157 | rc = self.mqttc.connect(self.host, self.port) 158 | self.logger.info(f"mqtt connect return code: {rc}") 159 | except Exception as e: 160 | raise Exception("Could not connect to MQTT broker: {}".format(e)) 161 | self.mqttc.loop_start() 162 | 163 | def dispatch_message(self, addr, message_dict, kind): 164 | """ 165 | Publish a dict to an MQTT broker in JSON format 166 | """ 167 | if self.legacy: 168 | # X10 topic format 169 | # X10/MOCHAD_HOST/security/DEVICE_ADDRESS 170 | # 171 | # (based on discussion at below URL) 172 | # https://groups.google.com/forum/#!topic/homecamp/sWqHvQnLvV0 173 | topic = f"X10/{self.mochad_host}/{kind}/{addr}" 174 | payload = json.dumps(message_dict) 175 | # Distinguish between status messages (security) and 176 | # button presses per Andy Stanford-Clark's suggestion at 177 | # https://groups.google.com/d/msg/mqtt/rIp1uJsT9Nk/7YOWNCQO3ZEJ 178 | else: 179 | if self.devices_discovered.get(addr) is None: 180 | self.devices_discovered[addr] = True 181 | self.dispatch_mqtt_discovery(kind, addr) 182 | time.sleep(1) 183 | 184 | # Home Assistant MQTT auto discovery format 185 | # homeassistant/device_automation/HA_ID/mochad_dispatch/config 186 | # { 187 | # "action": "publish", 188 | # "topic": "X10/mqtt_ha_id/button/DEVICE_ADDRESS", 189 | # "payload": { 190 | # "state": "ON" 191 | # } 192 | # } 193 | topic = f"X10/{self.mqtt_ha_id}/{kind}/{addr}" 194 | payload = json.dumps(message_dict) 195 | if kind == "button": 196 | qos, retain = 0, False 197 | else: 198 | qos, retain = 1, True 199 | result, mid = self.mqttc.publish(topic, payload, qos=qos, retain=retain) 200 | pass 201 | 202 | def dispatch_mqtt_discovery(self, kind, addr): 203 | """ 204 | Publish Home Assistant MQTT discovery message 205 | """ 206 | topic = f"X10/{self.mqtt_ha_id}/{kind}/{addr}" 207 | dev_id = f"{self.mqtt_ha_id}_{addr}" 208 | cmp_id = f"{dev_id}_state" 209 | payload = { 210 | "device": { 211 | "identifiers": dev_id, 212 | "name": dev_id, 213 | "model": "mochad_dispatch", 214 | "manufacturer": "mochad_dispatch", 215 | }, 216 | "origin": { 217 | "name": "mochad_dispatch", 218 | "url": "https://github.com/StoaferP/mochad_dispatch", 219 | }, 220 | "cmps": { 221 | cmp_id: { 222 | "p": "binary_sensor", 223 | "value_template": "{{ value_json.state }}", 224 | "unique_id": f"{dev_id}_{kind}", 225 | } 226 | }, 227 | "state_topic": topic, 228 | } 229 | config_topic = f"{self.mqtt_discovery}/device/{self.mqtt_ha_id}/{addr}/config" 230 | 231 | qos, retain = 1, True 232 | result, mid = self.mqttc.publish(config_topic, json.dumps(payload), qos=qos, retain=retain) 233 | pass 234 | 235 | def watchdog(self): 236 | """ 237 | Continually watches the MQTT broker connection health. Exits gracefully if the connection is retried for 60 238 | seconds straight without success. 239 | 240 | Why not just do this in the on_disconnect callback? The on_disconnect callback is not called while 241 | loop_start/loop_forever is doing an automatic reconnect. This makes it impossible to use on_disconnect to 242 | handle reconnect issues in the loop_start/loop_forever functions. 243 | """ 244 | while self.killer.kill_now == False: 245 | if self.reconnect_time > 0 and time.time() - self.reconnect_time > 60: 246 | 247 | self.logger.error("Could not reconnect to MQTT broker after 60s") 248 | self.killer.do_kill_now() 249 | break 250 | else: 251 | time.sleep(1) 252 | 253 | 254 | class MochadClient: 255 | """ 256 | MochadClient object 257 | 258 | Makes a persistent connection to mochad and translates RFSEC messages to MQTT 259 | 260 | :param host: IP/hostname of system running mochad 261 | :param logger: Logger object to use 262 | :param dispatcher: object to use for dispatching messages. Must be MqttDispatcher 263 | 264 | """ 265 | 266 | pl_houseunit: str 267 | reader: SocketReader 268 | 269 | def __init__(self, host, logger, dispatcher, house_codes, killer, legacy): 270 | self.host = host 271 | self.logger = logger 272 | self.reconnect_time = -1 273 | self.dispatcher = dispatcher 274 | self.house_codes = house_codes 275 | self.killer = killer 276 | self.legacy = legacy 277 | 278 | def parse_mochad_line(self, line): 279 | """ 280 | Parse a raw line of output from mochad 281 | """ 282 | if type(line) == bytes: 283 | line = line.decode() 284 | 285 | if line[15:23] == "Rx RFSEC": 286 | 287 | # decode message. format is either: 288 | # 09/22 15:39:07 Rx RFSEC Addr: 21:26:80 Func: Contact_alert_min_DS10A 289 | # ~ or ~ 290 | # 09/22 15:39:07 Rx RFSEC Addr: 0x80 Func: Motion_alert_SP554A 291 | line_list = line.split(" ") 292 | addr = line_list[5] 293 | func = line_list[7] 294 | 295 | func_dict = self.decode_func(self.legacy, func) 296 | 297 | return addr, func_dict, "security" 298 | 299 | elif line[16:20] == "x RF": 300 | 301 | # decode RF message. format is: 302 | # 02/13 23:54:28 Rx RF HouseUnit: B1 Func: On 303 | # 12/15 21:30:45 Tx RF HouseUnit: A4 Func: On\n 304 | line_list = line.split(" ") 305 | house_code = line_list[5] 306 | hc = house_code[0:1] 307 | if hc in self.house_codes: 308 | house_func = line_list[7] 309 | return house_code, self.create_state_payload(house_func), "button" 310 | 311 | elif line[15:20] == "Rx PL": 312 | 313 | # decode PL message. format is in 2 parts: 314 | # 02/13 23:54:28 Rx PL HouseUnit: B1 315 | # 02/13 23:54:28 Rx PL House: B Func: On 316 | line_list = line.split(" ") 317 | if line_list[4] == "HouseUnit:": 318 | hc = line_list[5][0:1] 319 | if hc in self.house_codes: 320 | self.pl_houseunit = line_list[5] 321 | if line_list[4] == "House:" and self.pl_houseunit != None: 322 | house_func = line_list[7] 323 | house_unit = self.pl_houseunit 324 | return house_unit, self.create_state_payload(house_func), "button" 325 | 326 | return "", "", "" 327 | 328 | def create_state_payload(self, the_function): 329 | """ 330 | Create a state payload for messages dispatched to MQTT. This will use the legacy flag to determine the format 331 | of the payload 332 | """ 333 | if self.legacy: 334 | payload = {"func": the_function} 335 | else: 336 | payload = {"state": the_function.upper()} 337 | return payload 338 | 339 | @staticmethod 340 | def decode_func(legacy, raw_func): 341 | """ 342 | Decode the "Func:" parameter of an RFSEC message 343 | """ 344 | MOTION_DOOR_WINDOW_SENSORS = ["DS10A", "DS12A", "MS10A", "SP554A"] 345 | SECURITY_REMOTES = ["KR10A", "KR15A", "SH624"] 346 | func_list = raw_func.split("_") 347 | func_dict = dict() 348 | 349 | func_dict["device_type"] = func_list.pop() 350 | 351 | # set event_type and event_state for motion and door/window sensors 352 | if func_dict["device_type"] in MOTION_DOOR_WINDOW_SENSORS: 353 | func_dict["event_type"] = func_list[0].lower() 354 | func_dict["event_state"] = func_list[1] 355 | i = 2 356 | elif func_dict["device_type"] in SECURITY_REMOTES: 357 | i = 0 358 | else: 359 | raise Exception("Unknown device type in {}: {}".format(raw_func, func_dict["device_type"])) 360 | 361 | # crawl through rest of func parameters 362 | while i < len(func_list): 363 | # delay setting 364 | if func_list[i] == "min" or func_list[i] == "max": 365 | func_dict["delay"] = func_list[i] 366 | # tamper detection 367 | elif func_list[i] == "tamper": 368 | func_dict["tamper"] = True 369 | # low battery 370 | elif func_list[i] == "low": 371 | func_dict["low_battery"] = True 372 | # Home/Away switch on SP554A 373 | elif func_list[i] == "Home" and func_list[i + 1] == "Away": 374 | func_dict["home_away"] = True 375 | # skip over 'Away' in func_list 376 | i += 1 377 | # Arm system 378 | elif func_list[i] == "Arm" and i + 1 == len(func_list): 379 | func_dict["command"] = "arm" 380 | # Arm system in Home mode 381 | elif func_list[i] == "Arm" and func_list[i + 1] == "Home": 382 | if legacy: 383 | func_dict["command"] = "arm_home" 384 | else: 385 | func_dict["command"] = "armed_home" 386 | # skip over 'Home' in func_list 387 | i += 1 388 | # Arm system in Away mode 389 | elif func_list[i] == "Arm" and func_list[i + 1] == "Away": 390 | if legacy: 391 | func_dict["command"] = "arm_away" 392 | else: 393 | func_dict["command"] = "armed_away" 394 | # skip over 'Away' in func_list 395 | i += 1 396 | # Disarm system 397 | elif func_list[i] == "Disarm": 398 | if legacy: 399 | func_dict["command"] = "disarm" 400 | else: 401 | func_dict["command"] = "disarming" 402 | # Panic 403 | elif func_list[i] == "Panic": 404 | func_dict["command"] = "panic" 405 | # Lights on 406 | elif func_list[i] == "Lights" and func_list[i + 1] == "On": 407 | func_dict["command"] = "lights_on" 408 | # skip ovedr 'On' in func_list 409 | i += 1 410 | # Lights off 411 | elif func_list[i] == "Lights" and func_list[i + 1] == "Off": 412 | func_dict["command"] = "lights_off" 413 | # skip ovedr 'Off' in func_list 414 | i += 1 415 | # unknown 416 | else: 417 | raise Exception("Unknown func parameter in {}: {}".format(raw_func, func_list[i])) 418 | 419 | i += 1 420 | 421 | return func_dict 422 | 423 | def connect(self): 424 | """ 425 | Connect to mochad 426 | """ 427 | 428 | if self.reader is not None: 429 | self.reader.close_connection() 430 | 431 | self.reader = SocketReader(self.host, 1099) 432 | try: 433 | self.reader.open_connection() 434 | except Exception as e: 435 | self.logger.error("Could not connect to mochad: {}".format(e)) 436 | raise 437 | 438 | def dispatch_message(self, addr, message_dict, kind): 439 | """ 440 | Use dispatcher object to dispatch decoded RFSEC message 441 | """ 442 | try: 443 | self.dispatcher.dispatch_message(addr, message_dict, kind) 444 | except Exception as e: 445 | self.logger.error("Failed to dispatch mochad message {}: {}".format(message_dict, e)) 446 | 447 | def worker(self): 448 | """ 449 | Maintain the connection to mochad, read output from mochad and dispatch any RFSEC messages 450 | """ 451 | 452 | # CONNECTION LOOP 453 | while self.killer.kill_now == False: 454 | # if we are in reconnect status, sleep before connecting 455 | if self.reconnect_time > 0: 456 | time.sleep(1) 457 | 458 | # if we've been reconnecting for over 60s, bail out 459 | if (time.time() - self.reconnect_time) > 60: 460 | self.logger.error("Could not reconnect to mochad after 60s") 461 | break 462 | 463 | try: 464 | self.connect() 465 | except OSError as e: 466 | if self.reconnect_time == 0: 467 | self.reconnect_time = time.time() 468 | self.logger.warn("Could not connect to mochad. Retrying: {}".format(e)) 469 | # reconnect_time = -1 here means the first connection failed 470 | elif self.reconnect_time == -1: 471 | self.logger.error("Could not connect to mochad: {}".format(e)) 472 | self.killer.do_kill_now() 473 | break 474 | 475 | # keep trying to reconnect 476 | continue 477 | 478 | # if we make it this far we've successfully connected, reset the 479 | # reconnect time 480 | self.reconnect_time = 0 481 | self.logger.info(f"Connected to mochad host: {self.host}") 482 | 483 | # READ FROM NETWORK LOOP 484 | while True: 485 | line = self.reader.read_line() 486 | # an empty string means connection lost, exit read loop 487 | if not line: 488 | break 489 | # parse the line 490 | try: 491 | addr, message_dict, kind = self.parse_mochad_line(line.rstrip()) 492 | except Exception as e: 493 | self.logger.error("Failed to parse mochad message {}: {}".format(line, e)) 494 | continue 495 | 496 | # addr/func will be blank when we have nothing to dispatch 497 | if addr and message_dict: 498 | # we don't to use mochad's timestamp because it lacks a year 499 | message_dict["dispatch_time"] = datetime.now(pytz.UTC).isoformat() 500 | self.dispatch_message(addr, message_dict, kind) 501 | 502 | # we broke out of the read loop: we got disconnected, retry connect 503 | self.logger.warn("Lost connection to mochad. Retrying.") 504 | self.reconnect_time = time.time() 505 | 506 | 507 | def daemon_main(): 508 | """ 509 | Main function which will be executed by Daemonize after initializing 510 | """ 511 | global main_logger, killer, args, dispatcher_type 512 | 513 | main_logger.info("Start daemon_main()") 514 | 515 | try: 516 | main_logger.debug(f"dispatcher_type({args.server}, {args.dispatch_uri}, logger, {args.cafile}), killer") 517 | dispatcher = dispatcher_type( 518 | args.server, 519 | args.dispatch_uri, 520 | main_logger, 521 | args.cafile, 522 | killer, 523 | args.legacy, 524 | args.mqtt_discovery, 525 | ) 526 | main_logger.debug("Created dispatcher: {}".format(dispatcher)) 527 | except Exception as e: 528 | killer.errordie(f"Startup error: Could not create dispatcher {e}") 529 | exit(1) 530 | 531 | main_logger.debug("Create Mochad Client: {}".format(dispatcher)) 532 | mochad_client = MochadClient( 533 | args.server, 534 | main_logger, 535 | dispatcher, 536 | args.housecodes.upper(), 537 | killer, 538 | args.legacy, 539 | ) 540 | 541 | main_logger.info("Start task dispatcher.watchdog()") 542 | dispacther_watchdog_task_handle = threading.Thread(target=dispatcher.watchdog) 543 | dispacther_watchdog_task_handle.daemon = True # Daemon threads will shut down when the main process exits 544 | 545 | dispacther_watchdog_task_handle.start() 546 | 547 | main_logger.info("Start task mochad_client.worker()") 548 | mochad_client_worker_task_handle = threading.Thread(target=mochad_client.worker) 549 | mochad_client_worker_task_handle.daemon = True # Daemon threads will shut down when the main process exits 550 | mochad_client_worker_task_handle.start() 551 | 552 | while killer.kill_now == False: 553 | time.sleep(2) 554 | 555 | 556 | def main(): 557 | """ 558 | Main entry point into mochad_dispatch. Processes command line arguments then hands off to Daemonize and MochadClient 559 | """ 560 | global args, dispatcher_type, main_logger, base_path, killer 561 | 562 | killer = GracefulKiller() 563 | 564 | # parse command line args 565 | parser = argparse.ArgumentParser() 566 | parser.add_argument( 567 | "-s", 568 | "--server", 569 | default="127.0.0.1", 570 | help="IP/host of server running mochad (default 127.0.0.1)", 571 | ) 572 | parser.add_argument( 573 | "-f", 574 | "--foreground", 575 | action="store_true", 576 | default=False, 577 | help="Don't fork; run in foreground (for debugging)", 578 | ) 579 | parser.add_argument( 580 | "-l", 581 | "--legacy", 582 | action="store_true", 583 | default=False, 584 | help="Use legacy X10 topic format (default is HomeAssistant MQTT auto discovery format)", 585 | ) 586 | parser.add_argument( 587 | "-m", 588 | "--mqtt-discovery", 589 | default="homeassistant/5A0uqYZF2_mochad_dispatch", 590 | help="MQTT discovery for Home Assistant (default homeassistant/5A0uqYZF2_mochad_dispatch)", 591 | ) 592 | parser.add_argument("--cafile", help="File containing trusted CA certificates") 593 | parser.add_argument( 594 | "-c", 595 | "--housecodes", 596 | default="ABCDEFGHIJKLMNOP", 597 | help="House codes for X10 devices (default ABCDEFGHIJKLMNOP)", 598 | ) 599 | parser.add_argument( 600 | "dispatch_uri", 601 | help="dispatch messages to this URI. e.g. mqtt://host:port[,user=username,pass=password]", 602 | ) 603 | 604 | args = parser.parse_args() 605 | 606 | if base_path is None: 607 | base_path = os.path.abspath("./") 608 | 609 | main_logger = logging.getLogger("mochad_dispatch") 610 | main_logger.setLevel(logging.INFO) 611 | # create console handler and set level to debug 612 | ch = logging.StreamHandler() 613 | ch.setLevel(logging.INFO) 614 | 615 | # create formatter 616 | formatter = logging.Formatter("%(asctime)s %(name)s: %(levelname)s: %(message)s") 617 | 618 | # add formatter to ch 619 | ch.setFormatter(formatter) 620 | 621 | # add ch to logger 622 | main_logger.addHandler(ch) 623 | main_file_handler = RotatingFileHandler( 624 | os.path.join(base_path, "mochad_dispatch.log"), maxBytes=5000000, backupCount=2 625 | ) 626 | main_file_handler.setLevel(logging.INFO) 627 | main_file_handler.setFormatter(formatter) 628 | main_logger.addHandler(main_file_handler) 629 | 630 | main_logger.info("Starting mochad_dispatch") 631 | main_logger.debug("args: {}".format(args)) 632 | 633 | # set dispatcher type based on dispatch_uri 634 | uri = urllib.parse.urlparse(args.dispatch_uri) 635 | 636 | if uri.scheme == "mqtt": 637 | dispatcher_type = MqttDispatcher 638 | else: 639 | killer.errordie("unsupported URI scheme '{}'".format(uri.scheme)) 640 | 641 | daemon_main() 642 | 643 | 644 | if __name__ == "__main__": 645 | main() 646 | exit(0) 647 | --------------------------------------------------------------------------------