├── requirements.txt ├── .vscode ├── settings.json └── launch.json ├── .gitignore ├── RFLinkGateway.service ├── Dockerfile ├── supervisor_RFLinkGateway ├── .dockerignore ├── config.json.sample ├── RFLinkGateway.py ├── readme.md ├── MQTTClient.py └── SerialProcess.py /requirements.txt: -------------------------------------------------------------------------------- 1 | paho-mqtt==1.5.0 2 | pyserial==3.4 3 | tornado==6.3.3 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "/usr/local/bin/python3" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/encodings.xml 2 | .idea/misc.xml 3 | .idea/modules.xml 4 | .idea/RFLinkGateway.iml 5 | .idea/vcs.xml 6 | .idea/workspace.xml 7 | *.pyc 8 | RFLinkGateway.log 9 | bin 10 | lib 11 | include 12 | .Python 13 | -------------------------------------------------------------------------------- /RFLinkGateway.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=RFLinkGateway 3 | After=multi-user.target 4 | Conflicts=getty@tty1.service 5 | 6 | [Service] 7 | Type=simple 8 | WorkingDirectory=/opt/scripts/RFLinkGateway 9 | # Set correct path 10 | ExecStart=/opt/scripts/RFLinkGateway/bin/python3 /opt/scripts/RFLinkGateway/RFLinkGateway.py 11 | user=root 12 | User=root 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | # Keeps Python from generating .pyc files in the container 4 | ENV PYTHONDONTWRITEBYTECODE=1 5 | 6 | # Turns off buffering for easier container logging 7 | ENV PYTHONUNBUFFERED=1 8 | 9 | # Install pip requirements 10 | COPY requirements.txt . 11 | RUN python -m pip install -r requirements.txt 12 | 13 | WORKDIR /app 14 | COPY . /app 15 | 16 | 17 | 18 | CMD ["python", "RFLinkGateway.py"] 19 | -------------------------------------------------------------------------------- /supervisor_RFLinkGateway: -------------------------------------------------------------------------------- 1 | [program:RFLinkGateway] 2 | command=/opt/scripts/RFLinkGateway/bin/python3 /opt/scripts/RFLinkGateway/RFLinkGateway.py 3 | user=root 4 | directory= /opt/scripts/RFLinkGateway 5 | redirect_stderr=true 6 | stdout_logfile=/var/log/supervisor/rflink-gateway-supervisor.log 7 | stdout_logfile_maxbytes=10000000 8 | stderr_logfile_maxbytes=10000000 9 | autostart=true 10 | autorestart=true 11 | environment=HOME="/opt/scripts/RFLinkGateway" 12 | stopasgroup=true 13 | killasgroup=true 14 | priority=10 15 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/.venv 3 | **/.classpath 4 | **/.dockerignore 5 | **/.env 6 | **/.git 7 | **/.gitignore 8 | **/.project 9 | **/.settings 10 | **/.toolstarget 11 | **/.vs 12 | **/.vscode 13 | **/*.*proj.user 14 | **/*.dbmdl 15 | **/*.jfm 16 | **/bin 17 | **/charts 18 | **/docker-compose* 19 | **/compose* 20 | **/Dockerfile* 21 | **/node_modules 22 | **/npm-debug.log 23 | **/obj 24 | **/secrets.dev.yaml 25 | **/values.dev.yaml 26 | README.md 27 | supervisor_RFLinkGateway 28 | RFLinkGateway.service 29 | config.json.sample -------------------------------------------------------------------------------- /config.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "mqtt_host": "your_mqtt_host", 3 | "mqtt_port": 1883, 4 | "mqtt_prefix": "/data/RFLINK", 5 | "mqtt_format": "json", 6 | "mqtt_message_timeout": 60, 7 | "mqtt_user":"your_mqtt_user", 8 | "mqtt_password":"your_mqtt_password", 9 | "rflink_tty_device": "/dev/ttyUSB0", 10 | "rflink_direct_output_params": [ 11 | "BAT", 12 | "CMD", 13 | "SET_LEVEL", 14 | "SWITCH", 15 | "HUM", 16 | "CHIME", 17 | "PIR", 18 | "SMOKEALERT" 19 | ], 20 | "rflink_signed_output_params": [ 21 | "TEMP", 22 | "WINCHL", 23 | "WINTMP" 24 | ], 25 | "rflink_wdir_output_params": [ 26 | "WINDIR" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Python", 10 | "type": "python", 11 | "request": "launch", 12 | "stopOnEntry": true, 13 | "pythonPath": "${config:python.pythonPath}", 14 | "program": "${workspaceRoot}/RFLinkGateway.py", 15 | "cwd": "${workspaceRoot}", 16 | "env": {}, 17 | "envFile": "${workspaceRoot}/.env", 18 | "debugOptions": [ 19 | "WaitOnAbnormalExit", 20 | "WaitOnNormalExit", 21 | "RedirectOutput" 22 | ] 23 | } ] 24 | } -------------------------------------------------------------------------------- /RFLinkGateway.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import multiprocessing 4 | import time 5 | 6 | import tornado.gen 7 | import tornado.ioloop 8 | import tornado.websocket 9 | from tornado.options import options 10 | 11 | import MQTTClient 12 | import SerialProcess 13 | 14 | logger = logging.getLogger('RFLinkGW') 15 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s') 16 | logger.setLevel(logging.DEBUG) 17 | 18 | ch = logging.StreamHandler() 19 | ch.setFormatter(formatter) 20 | ch.setLevel(logging.DEBUG) 21 | logger.addHandler(ch) 22 | 23 | 24 | def main(): 25 | # messages read from device 26 | messageQ = multiprocessing.Queue() 27 | # messages written to device 28 | commandQ = multiprocessing.Queue() 29 | 30 | config = {} 31 | try: 32 | with open('config.json') as json_data: 33 | config = json.load(json_data) 34 | except Exception as e: 35 | logger.error("Config load failed") 36 | exit(1) 37 | 38 | sp = SerialProcess.SerialProcess(messageQ, commandQ, config) 39 | sp.daemon = True 40 | sp.start() 41 | 42 | mqtt = MQTTClient.MQTTClient(messageQ, commandQ, config) 43 | mqtt.daemon = True 44 | mqtt.start() 45 | 46 | # wait a second before sending first task 47 | time.sleep(1) 48 | options.parse_command_line() 49 | 50 | mainLoop = tornado.ioloop.IOLoop.instance() 51 | mainLoop.start() 52 | 53 | 54 | if __name__ == "__main__": 55 | main() 56 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # RFLink Gateway to MQTT 2 | 3 | ## Purpose 4 | Bridge between RFLink Gateway and MQTT broker. 5 | 6 | ## Current features 7 | Forwarding messages received on TTY port from RFLink Gateway Arduino board 8 | to MQTT broker in both directions. 9 | 10 | Every message received from RFLinkGateway is split into single parameters 11 | and published to different MQTT topics. 12 | Example: 13 | Message: 14 | 15 | `20;37;Acurite;ID=cbd5;TEMP=0066;HUM=79;WINSP=001a;BAT=OK` 16 | 17 | ### ASCII 18 | ``` 19 | /data/RFLINK/Acurite/cbd5/READ/TEMP 10.2 20 | /data/RFLINK/Acurite/cbd5/READ/HUM 73 21 | /data/RFLINK/Acurite/cbd5/READ/WINSP 2.6 22 | /data/RFLINK/Acurite/cbd5/READ/BAT OK 23 | ``` 24 | 25 | ### JSON 26 | ```json 27 | /data/RFLINK/Acurite/cbd5/READ/TEMP {"value": 10.2} 28 | /data/RFLINK/Acurite/cbd5/READ/HUM {"value": 73} 29 | /data/RFLINK/Acurite/cbd5/READ/WINSP {"value": 2.6} 30 | /data/RFLINK/Acurite/cbd5/READ/BAT {"value": "OK"} 31 | ``` 32 | 33 | Every message received on particular MQTT topic is translated to 34 | RFLink Gateway and sent to 433 MHz. 35 | 36 | ## Installation 37 | Install the dependencies with the following commands: 38 | 39 | `pip install -r requirements.txt ` 40 | 41 | 42 | 43 | ## Configuration 44 | 45 | Whole configuration is located in config.json file. You can copy and edit `config.json.sample`. 46 | 47 | ```json 48 | { 49 | "mqtt_host": "your_mqtt_host", 50 | "mqtt_port": 1883, 51 | "mqtt_prefix": "/data/RFLINK", 52 | "mqtt_format": "json", 53 | "mqtt_message_timeout": 60, 54 | "mqtt_user":"your_mqtt_user", 55 | "mqtt_password":"your_mqtt_password", 56 | "rflink_tty_device": "/dev/ttyUSB0", 57 | "rflink_direct_output_params": [ 58 | "BAT", 59 | "CMD", 60 | "SET_LEVEL", 61 | "SWITCH", 62 | "HUM", 63 | "CHIME", 64 | "PIR", 65 | "SMOKEALERT" 66 | ], 67 | "rflink_signed_output_params": [ 68 | "TEMP", 69 | "WINCHL", 70 | "WINTMP" 71 | ], 72 | "rflink_wdir_output_params": [ 73 | "WINDIR" 74 | ] 75 | } 76 | ``` 77 | 78 | config param | meaning 79 | ------------- |--------- 80 | mqtt_host | MQTT broker host | 81 | mqtt_port | MQTT broker port| 82 | mqtt_prefix | prefix for publish and subscribe topic| 83 | mqtt_format | publish and subscribe topic as `json` or `ascii` | 84 | rflink_tty_device | Serial device | 85 | rflink_direct_output_params | Parameters transferred to MQTT without any processing | 86 | rflink_signed_output_params | Parameters with signed values | 87 | rflink_wdir_output_params | Parameters with wind direction values | 88 | 89 | 90 | 91 | ## Running 92 | 93 | Scripts assume script directory located at: `/opt/scripts/RFLinkGateway`, and virtualenv was used. If not, use system Python binary, not the virtualenv'ed one. 94 | 95 | ### Running in Supervisor 96 | 97 | ```Shell 98 | vim supervisor_RFLinkGateway 99 | cp supervisor_RFLinkGateway /etc/supervisor/conf.d/ 100 | supervisorctl reread 101 | supervisorctl update 102 | supervisorctl start RFLinkGateway 103 | ``` 104 | 105 | ### Start as a Service 106 | 107 | ```Shell 108 | vim RFLinkGateway.service 109 | cp RFLinkGateway.service /lib/systemd/system/RFLinkGateway.service 110 | sudo systemctl daemon-reload 111 | sudo systemctl enable RFLinkGateway.service 112 | ``` 113 | 114 | ### Start as a docker container 115 | ````Shell 116 | cd /opt/scripts/RFLinkGateway 117 | docker build --tag rflink . 118 | docker run --name rflinkgw -v /path/to/configfile:/app/config.json --device=/dev/ttyUSB0:/dev/ttyUSB0:rw rflink:latest 119 | ```` 120 | 121 | ### Logging 122 | Script logs to STDOUT, it can be redirected through supervisord to files or syslog. 123 | For docker you can use any driver (such a Loki). 124 | 125 | ## Output data 126 | 127 | Application pushes informations to MQTT broker in following format: 128 | `[mqtt_prefix]/[device_type]/[device_id]/READ/[parameter]` 129 | 130 | `/data/RFLINK/TriState/8556a8/READ/1 OFF` 131 | 132 | Except if there its a CMD (Normally a signal from a switch), it is pushed to the following topic: 133 | `[mqtt_prefix]/[device_type]/[device_id]/[switch_id]/READ/[parameter]` 134 | 135 | like this, you only have to read the command message to use with devices with two or more switches 136 | 137 | `/data/RFLINK/NewKaku/0201e3fa/2/READ/CMD ON` 138 | 139 | Every change should be published to topic: 140 | `[mqtt_prefix]/[device_type]/[device_id]/W/[switch_ID]` 141 | 142 | `/data/RFLINK/TriState/8556a8/W/1 ON` 143 | 144 | 145 | 146 | ## References 147 | - RFLink Gateway project http://www.rflink.nl/ 148 | - RFLink Gateway protocol http://www.rflink.nl/blog2/protref 149 | -------------------------------------------------------------------------------- /MQTTClient.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import multiprocessing 3 | import time 4 | 5 | import paho.mqtt.client as mqtt 6 | 7 | def is_number(s) -> bool: 8 | try: 9 | float(s) 10 | return True 11 | except ValueError: 12 | pass 13 | 14 | try: 15 | import unicodedata 16 | unicodedata.numeric(s) 17 | return True 18 | except (TypeError, ValueError): 19 | pass 20 | 21 | return False 22 | 23 | class MQTTClient(multiprocessing.Process): 24 | def __init__(self, messageQ, commandQ, config) -> None: 25 | self.logger = logging.getLogger('RFLinkGW.MQTTClient') 26 | self.logger.info("Starting...") 27 | self.config=config 28 | multiprocessing.Process.__init__(self) 29 | self.__messageQ = messageQ 30 | self.__commandQ = commandQ 31 | self.client_connected = False 32 | self.connect_retry_counter = 0 33 | self.mqttDataPrefix = self.config['mqtt_prefix'] 34 | self.mqttDataFormat = self.config['mqtt_format'] 35 | self._mqttConn = mqtt.Client(client_id='RFLinkGateway') 36 | self._mqttConn.username_pw_set(self.config['mqtt_user'], self.config['mqtt_password']) 37 | 38 | self._mqttConn.on_disconnect = self._on_disconnect 39 | self._mqttConn.on_publish = self._on_publish 40 | self._mqttConn.on_message = self._on_message 41 | self._mqttConn.on_connect = self._on_connect 42 | self.connect(self.config) 43 | 44 | 45 | def connect (self,config) -> None: 46 | try: 47 | self._mqttConn.connect(config['mqtt_host'], port=config['mqtt_port'], keepalive=120) 48 | except Exception as e: 49 | self.logger.error("problem with connect: %s" % e) 50 | def close(self) -> None: 51 | self.logger.info("Closing connection") 52 | self._mqttConn.disconnect() 53 | 54 | def _on_connect(self,client,userdata,flags,rc) -> None: 55 | self.client_connected = True 56 | self.connect_retry_counter = 0 57 | self.logger.info("Client connected") 58 | self._mqttConn.subscribe("%s/+/+/WRITE/+" % self.mqttDataPrefix) 59 | 60 | 61 | def _on_disconnect(self, client, userdata, rc) -> None: 62 | if rc != 0: 63 | self.logger.error("Unexpected disconnection.") 64 | self.client_connected = False 65 | self.connect(self.config) 66 | 67 | def _on_publish(self, client, userdata, mid) -> None: 68 | self.logger.debug("Message " + str(mid) + " published.") 69 | 70 | def _on_message(self, client, userdata, message) -> None: 71 | self.logger.debug("Message received: %s" % (message)) 72 | 73 | data = message.topic.replace(self.config['mqtt_prefix'] + "/", "").split("/") 74 | data_out = { 75 | 'method': 'subscribe', 76 | 'topic': message.topic, 77 | 'family': data[0], 78 | 'deviceId': data[1], 79 | 'param': data[3], 80 | 'payload': message.payload.decode('ascii'), 81 | 'qos': 1 82 | } 83 | self.__commandQ.put(data_out) 84 | 85 | def publish(self, task) -> None: 86 | topic = "%s/%s" % (self.config['mqtt_prefix'], task['topic']) 87 | if self.mqttDataFormat == 'json': 88 | if is_number(task['payload']): 89 | task['payload'] = '{"value": ' + str(task['payload']) + '}' 90 | else: 91 | task['payload'] = '{"value": "' + str(task['payload']) + '"}' 92 | try: 93 | result = self._mqttConn.publish(topic, payload=task['payload']) 94 | self.logger.debug('Sending message %s :%s, result:%s' % (result.mid, task, result.rc)) 95 | if result.rc != 0: 96 | raise Exception("Send failed") 97 | except Exception as e: 98 | self.logger.error('Publish problem: %s' % (e)) 99 | self.__messageQ.put(task) 100 | 101 | def run(self): 102 | while True: 103 | if self.client_connected == False: 104 | #TODO Add reconnection limit 105 | time.sleep (1+2*self.connect_retry_counter) 106 | self.logger.error('Reconnecting, try:%s' % (self.connect_retry_counter+1)) 107 | self.connect(self.config) 108 | self.connect_retry_counter += 1 109 | if not self.__messageQ.empty(): 110 | task = self.__messageQ.get() 111 | if task['method'] == 'publish': 112 | self.publish(task) 113 | else: 114 | time.sleep(0.1) 115 | self._mqttConn.loop() 116 | -------------------------------------------------------------------------------- /SerialProcess.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import multiprocessing 3 | import time 4 | 5 | import serial 6 | 7 | 8 | class SerialProcess(multiprocessing.Process): 9 | def __init__(self, messageQ, commandQ, config) -> None: 10 | self.logger = logging.getLogger('RFLinkGW.SerialProcessing') 11 | 12 | self.logger.info("Starting...") 13 | multiprocessing.Process.__init__(self) 14 | 15 | self.__messageQ = messageQ 16 | self.__commandQ = commandQ 17 | 18 | self.gatewayPort = config['rflink_tty_device'] 19 | self.sp = serial.Serial() 20 | self.connect() 21 | 22 | self.processing_exception = config['rflink_direct_output_params'] 23 | 24 | self.processing_signed = config['rflink_signed_output_params'] 25 | 26 | self.processing_wdir = config['rflink_wdir_output_params'] 27 | 28 | def close(self) -> None: 29 | self.sp.close() 30 | self.logger.debug('Serial closed') 31 | 32 | def prepare_output(self, data_in) -> list: 33 | out = [] 34 | data = data_in.decode("ascii").replace(";\r\n", "").split(";") 35 | self.logger.debug("Received message:%s" % (data)) 36 | if len(data) > 3 and data[0] == '20': 37 | family = data[2] 38 | deviceId = data[3].split("=")[1] 39 | switch = data[4].split("=")[1] 40 | d = {} 41 | for t in data[4:]: 42 | token = t.split("=") 43 | d[token[0]] = token[1] 44 | for key in d: 45 | if key in self.processing_exception: 46 | val = d[key] 47 | elif key in self.processing_signed: 48 | # Hexadecimal, high bit contains negative sign, division by 10 49 | if int(d[key], 16) & 0x8000: 50 | val = -( float (int(d[key], 16) & 0x7FFF) / 10 ) 51 | else: 52 | val = float (int(d[key], 16)) / 10 53 | elif key in self.processing_wdir: 54 | # Integer value from 0-15, reflecting 0-360 degrees in 22.5 degree steps 55 | val = int(d[key], 10) * 22.5 56 | else: 57 | val = float (int(d[key], 16)) / 10 58 | # Hexadecimal, division by 10 59 | if key == "CMD": 60 | topic_out = "%s/%s/%s/READ/%s" % (family, deviceId, switch, key) 61 | else: 62 | topic_out = "%s/%s/READ/%s" % (family, deviceId, key) 63 | self.logger.debug('set topic to: %s' % (topic_out)) 64 | 65 | data_out = { 66 | 'method': 'publish', 67 | 'topic': topic_out, 68 | 'family': family, 69 | 'deviceId': deviceId, 70 | 'param': key, 71 | 'payload': val, 72 | 'qos': 1, 73 | 'timestamp': time.time() 74 | } 75 | out = out + [data_out] 76 | return out 77 | 78 | def prepare_input(self, task): 79 | out_str = '10;%s;%s;%s;%s;\n' % (task['family'], task['deviceId'], task['param'], task['payload']) 80 | self.logger.debug('Sending to serial:%s' % (out_str)) 81 | return out_str 82 | 83 | def connect(self) -> None: 84 | self.logger.info('Connecting to serial') 85 | while not self.sp.isOpen(): 86 | try: 87 | time.sleep(1) 88 | self.sp = serial.Serial(self.gatewayPort, 57600, timeout=1) 89 | self.logger.debug('Serial connected') 90 | except Exception as e: 91 | self.logger.error('Serial port is closed %s' % (e)) 92 | 93 | def run(self): 94 | self.sp.flushInput() 95 | while True: 96 | try: 97 | if not self.__commandQ.empty(): 98 | task = self.__commandQ.get() 99 | # send it to the serial device 100 | self.sp.write(self.prepare_input(task).encode('ascii')) 101 | except Exception as e: 102 | self.logger.error("Send error:%s" % (format(e))) 103 | try: 104 | if (self.sp.inWaiting() > 0): 105 | data = self.sp.readline() 106 | task_list = self.prepare_output(data) 107 | for task in task_list: 108 | self.logger.debug("Sending to Q:%s" % (task)) 109 | self.__messageQ.put(task) 110 | else: 111 | time.sleep(0.01) 112 | except Exception as e: 113 | self.logger.error('Receive error: %s' % (e)) 114 | self.connect() 115 | --------------------------------------------------------------------------------