1#!/usr/bin/env python
2# coding=utf-8
3
4from __future__ import print_function
5from __future__ import with_statement
6
7AP_DESCRIPTION="""
8Publish Home Assistant MQTT auto discovery topics for rtl_433 devices.
9
10rtl_433_mqtt_hass.py connects to MQTT and subscribes to the rtl_433
11event stream that is published to MQTT by rtl_433. The script publishes
12additional MQTT topics that can be used by Home Assistant to automatically
13discover and minimally configure new devices.
14
15The configuration topics published by this script tell Home Assistant
16what MQTT topics to subscribe to in order to receive the data published
17as device topics by MQTT.
18"""
19
20AP_EPILOG="""
21It is strongly recommended to run rtl_433 with "-C si" and "-M newmodel".
22This script requires rtl_433 to publish both event messages and device
23messages.
24
25MQTT Username and Password can be set via the cmdline or passed in the
26environment: MQTT_USERNAME and MQTT_PASSWORD.
27
28Prerequisites:
29
301. rtl_433 running separately publishing events and devices messages to MQTT.
31
322. Python installation
33* Python 3.x preferred.
34* Needs Paho-MQTT https://pypi.python.org/pypi/paho-mqtt
35
36  Debian/raspbian:  apt install python3-paho-mqtt
37  Or
38  pip install paho-mqtt
39* Optional for running as a daemon see PEP 3143 - Standard daemon process library
40  (use Python 3.x or pip install python-daemon)
41
42
43Running:
44
45This script can run continually as a daemon, where it will publish
46a configuration topic for the device events sent to MQTT by rtl_433
47every 10 minutes.
48
49Alternatively if the rtl_433 devices in your environment change infrequently
50this script can use the MQTT retain flag to make the configuration topics
51persistent. The script will only need to be run when things change or if
52the MQTT server loses its retained messages.
53
54Getting rtl_433 devices back after Home Assistant restarts will happen
55more quickly if MQTT retain is enabled. Note however that definitions
56for any transitient devices/false positives will retained indefinitely.
57
58If your sensor values change infrequently and you prefer to write the most
59recent value even if not changed set -f to append "force_update = true" to
60all configs. This is useful if you're graphing the sensor data or want to
61alert on missing data.
62
63Suggestions:
64
65Running this script will cause a number of Home Assistant entities (sensors
66and binary sensors) to be created. These entities can linger for a while unless
67the topic is republished with an empty config string.  To avoid having to
68do a lot of clean up When running this initially or debugging, set this
69script to publish to a topic other than the one Home Assistant users (homeassistant).
70
71MQTT Explorer (http://http://mqtt-explorer.com/) is a very nice GUI for
72working with MQTT. It is free, cross platform, and OSS. The structured
73hierarchical view makes it easier to understand what rtl_433 is publishing
74and how this script works with Home Assistant.
75
76MQTT Explorer also makes it easy to publish an empty config topic to delete an
77entity from Home Assistant.
78
79
80Known Issues:
81
82Currently there is no white or black lists, so any device that rtl_433 receives
83including transients, false positives, will create a bunch of entities in
84Home Assistant.
85
86As of 2020-10, Home Assistant MQTT auto discovery doesn't currently support
87supplying "friendly name", and "area" key, so some configuration must be
88done in Home Assistant.
89
90There is a single global set of field mappings to Home Assistant meta data.
91
92"""
93
94
95
96# import daemon
97
98
99import os
100import argparse
101import logging
102import time
103import json
104import paho.mqtt.client as mqtt
105
106
107discovery_timeouts = {}
108
109# Fields used for creating topic names
110NAMING_KEYS = [ "type", "model", "subtype", "channel", "id" ]
111
112# Fields that get ignored when publishing to Home Assistant
113# (reduces noise to help spot missing field mappings)
114SKIP_KEYS = NAMING_KEYS + [ "time", "mic", "mod", "freq", "sequence_num",
115                            "message_type", "exception", "raw_msg" ]
116
117
118# Global mapping of rtl_433 field names to Home Assistant metadata.
119# @todo - should probably externalize to a config file
120# @todo - Model specific definitions might be needed
121
122mappings = {
123    "temperature_C": {
124        "device_type": "sensor",
125        "object_suffix": "T",
126        "config": {
127            "device_class": "temperature",
128            "name": "Temperature",
129            "unit_of_measurement": "°C",
130            "value_template": "{{ value|float }}",
131            "state_class": "measurement"
132        }
133    },
134    "temperature_1_C": {
135        "device_type": "sensor",
136        "object_suffix": "T1",
137        "config": {
138            "device_class": "temperature",
139            "name": "Temperature 1",
140            "unit_of_measurement": "°C",
141            "value_template": "{{ value|float }}",
142            "state_class": "measurement"
143        }
144    },
145    "temperature_2_C": {
146        "device_type": "sensor",
147        "object_suffix": "T2",
148        "config": {
149            "device_class": "temperature",
150            "name": "Temperature 2",
151            "unit_of_measurement": "°C",
152            "value_template": "{{ value|float }}",
153            "state_class": "measurement"
154        }
155    },
156    "temperature_F": {
157        "device_type": "sensor",
158        "object_suffix": "F",
159        "config": {
160            "device_class": "temperature",
161            "name": "Temperature",
162            "unit_of_measurement": "°F",
163            "value_template": "{{ value|float }}",
164            "state_class": "measurement"
165        }
166    },
167
168    "battery_ok": {
169        "device_type": "sensor",
170        "object_suffix": "B",
171        "config": {
172            "device_class": "battery",
173            "name": "Battery",
174            "unit_of_measurement": "%",
175            "value_template": "{{ float(value) * 99 + 1 }}",
176            "state_class": "measurement"
177        }
178    },
179
180    "humidity": {
181        "device_type": "sensor",
182        "object_suffix": "H",
183        "config": {
184            "device_class": "humidity",
185            "name": "Humidity",
186            "unit_of_measurement": "%",
187            "value_template": "{{ value|float }}",
188            "state_class": "measurement"
189        }
190    },
191
192    "moisture": {
193        "device_type": "sensor",
194        "object_suffix": "H",
195        "config": {
196            "device_class": "humidity",
197            "name": "Moisture",
198            "unit_of_measurement": "%",
199            "value_template": "{{ value|float }}",
200            "state_class": "measurement"
201        }
202    },
203
204    "pressure_hPa": {
205        "device_type": "sensor",
206        "object_suffix": "P",
207        "config": {
208            "device_class": "pressure",
209            "name": "Pressure",
210            "unit_of_measurement": "hPa",
211            "value_template": "{{ value|float }}",
212            "state_class": "measurement"
213        }
214    },
215
216    "pressure_kPa": {
217        "device_type": "sensor",
218        "object_suffix": "P",
219        "config": {
220            "device_class": "pressure",
221            "name": "Pressure",
222            "unit_of_measurement": "kPa",
223            "value_template": "{{ value|float }}",
224            "state_class": "measurement"
225        }
226    },
227
228    "wind_speed_km_h": {
229        "device_type": "sensor",
230        "object_suffix": "WS",
231        "config": {
232            "name": "Wind Speed",
233            "unit_of_measurement": "km/h",
234            "value_template": "{{ value|float }}",
235            "state_class": "measurement"
236        }
237    },
238
239    "wind_avg_km_h": {
240        "device_type": "sensor",
241        "object_suffix": "WS",
242        "config": {
243            "device_class": "weather",
244            "name": "Wind Speed",
245            "unit_of_measurement": "km/h",
246            "value_template": "{{ value|float }}",
247            "state_class": "measurement"
248        }
249    },
250
251    "wind_avg_mi_h": {
252        "device_type": "sensor",
253        "object_suffix": "WS",
254        "config": {
255            "device_class": "weather",
256            "name": "Wind Speed",
257            "unit_of_measurement": "mi/h",
258            "value_template": "{{ value|float }}",
259            "state_class": "measurement"
260        }
261    },
262
263    "wind_avg_m_s": {
264        "device_type": "sensor",
265        "object_suffix": "WS",
266        "config": {
267            "name": "Wind Average",
268            "unit_of_measurement": "km/h",
269            "value_template": "{{ (float(value|float) * 3.6) | round(2) }}",
270            "state_class": "measurement"
271        }
272    },
273
274    "wind_speed_m_s": {
275        "device_type": "sensor",
276        "object_suffix": "WS",
277        "config": {
278            "name": "Wind Speed",
279            "unit_of_measurement": "km/h",
280            "value_template": "{{ float(value|float) * 3.6 }}",
281            "state_class": "measurement"
282        }
283    },
284
285    "gust_speed_km_h": {
286        "device_type": "sensor",
287        "object_suffix": "GS",
288        "config": {
289            "name": "Gust Speed",
290            "unit_of_measurement": "km/h",
291            "value_template": "{{ value|float }}",
292            "state_class": "measurement"
293        }
294    },
295
296    "wind_max_m_s": {
297        "device_type": "sensor",
298        "object_suffix": "GS",
299        "config": {
300            "name": "Wind max",
301            "unit_of_measurement": "km/h",
302            "value_template": "{{ (float(value|float) * 3.6) | round(2) }}",
303            "state_class": "measurement"
304        }
305    },
306
307    "gust_speed_m_s": {
308        "device_type": "sensor",
309        "object_suffix": "GS",
310        "config": {
311            "name": "Gust Speed",
312            "unit_of_measurement": "km/h",
313            "value_template": "{{ float(value|float) * 3.6 }}",
314            "state_class": "measurement"
315        }
316    },
317
318    "wind_dir_deg": {
319        "device_type": "sensor",
320        "object_suffix": "WD",
321        "config": {
322            "name": "Wind Direction",
323            "unit_of_measurement": "°",
324            "value_template": "{{ value|float }}",
325            "state_class": "measurement"
326        }
327    },
328
329    "rain_mm": {
330        "device_type": "sensor",
331        "object_suffix": "RT",
332        "config": {
333            "name": "Rain Total",
334            "unit_of_measurement": "mm",
335            "value_template": "{{ value|float }}",
336            "state_class": "total_increasing"
337        }
338    },
339
340    "rain_mm_h": {
341        "device_type": "sensor",
342        "object_suffix": "RR",
343        "config": {
344            "name": "Rain Rate",
345            "unit_of_measurement": "mm/h",
346            "value_template": "{{ value|float }}",
347            "state_class": "measurement"
348        }
349    },
350
351    "rain_in": {
352        "device_type": "sensor",
353        "object_suffix": "RT",
354        "config": {
355            "name": "Rain Total",
356            "unit_of_measurement": "mm",
357            "value_template": "{{ (float(value|float) * 25.4) | round(2) }}",
358            "state_class": "total_increasing"
359        }
360    },
361
362    "rain_rate_in_h": {
363        "device_type": "sensor",
364        "object_suffix": "RR",
365        "config": {
366            "name": "Rain Rate",
367            "unit_of_measurement": "mm/h",
368            "value_template": "{{ (float(value|float) * 25.4) | round(2) }}",
369            "state_class": "measurement"
370        }
371    },
372
373    "tamper": {
374        "device_type": "binary_sensor",
375        "object_suffix": "tamper",
376        "config": {
377            "device_class": "safety",
378            "force_update": "true",
379            "payload_on": "1",
380            "payload_off": "0"
381        }
382    },
383
384    "alarm": {
385        "device_type": "binary_sensor",
386        "object_suffix": "alarm",
387        "config": {
388            "device_class": "safety",
389            "force_update": "true",
390            "payload_on": "1",
391            "payload_off": "0"
392        }
393    },
394
395    "rssi": {
396        "device_type": "sensor",
397        "object_suffix": "rssi",
398        "config": {
399            "device_class": "signal_strength",
400            "unit_of_measurement": "dB",
401            "value_template": "{{ value|float|round(2) }}",
402            "state_class": "measurement"
403        }
404    },
405
406    "snr": {
407        "device_type": "sensor",
408        "object_suffix": "snr",
409        "config": {
410            "device_class": "signal_strength",
411            "unit_of_measurement": "dB",
412            "value_template": "{{ value|float|round(2) }}",
413            "state_class": "measurement"
414        }
415    },
416
417    "noise": {
418        "device_type": "sensor",
419        "object_suffix": "noise",
420        "config": {
421            "device_class": "signal_strength",
422            "unit_of_measurement": "dB",
423            "value_template": "{{ value|float|round(2) }}",
424            "state_class": "measurement"
425        }
426    },
427
428    "depth_cm": {
429        "device_type": "sensor",
430        "object_suffix": "D",
431        "config": {
432            "name": "Depth",
433            "unit_of_measurement": "cm",
434            "value_template": "{{ value|float }}",
435            "state_class": "measurement"
436        }
437    },
438
439    "power_W": {
440        "device_type": "sensor",
441        "object_suffix": "watts",
442        "config": {
443            "device_class": "power",
444            "name": "Power",
445            "unit_of_measurement": "W",
446            "value_template": "{{ value|float }}",
447            "state_class": "measurement"
448        }
449    },
450
451    "lux": {
452        "device_type": "sensor",
453        "object_suffix": "lux",
454        "config": {
455            "device_class": "weather",
456            "name": "Outside Luminancee",
457            "unit_of_measurement": "lux",
458            "value_template": "{{ value|int }}",
459            "state_class": "measurement"
460        }
461    },
462
463    "uv": {
464        "device_type": "sensor",
465        "object_suffix": "uv",
466        "config": {
467            "device_class": "weather",
468            "name": "UV Index",
469            "unit_of_measurement": "UV Index",
470            "value_template": "{{ value|int }}",
471            "state_class": "measurement"
472        }
473    },
474
475    "storm_dist": {
476        "device_type": "sensor",
477        "object_suffix": "stdist",
478        "config": {
479            "name": "Lightning Distance",
480            "unit_of_measurement": "mi",
481            "value_template": "{{ value|int }}",
482            "state_class": "measurement"
483        }
484    },
485
486    "strike_distance": {
487        "device_type": "sensor",
488        "object_suffix": "stdist",
489        "config": {
490            "name": "Lightning Distance",
491            "unit_of_measurement": "mi",
492            "value_template": "{{ value|int }}",
493            "state_class": "measurement"
494        }
495    },
496
497    "strike_count": {
498        "device_type": "sensor",
499        "object_suffix": "strcnt",
500        "config": {
501            "name": "Lightning Strike Count",
502            "value_template": "{{ value|int }}",
503            "state_class": "total_increasing"
504        }
505    },
506}
507
508
509def mqtt_connect(client, userdata, flags, rc):
510    """Callback for MQTT connects."""
511
512    logging.info("MQTT connected: " + mqtt.connack_string(rc))
513    if rc != 0:
514        logging.error("Could not connect. Error: " + str(rc))
515    else:
516        logging.info("Subscribing to: " + args.rtl_topic)
517        client.subscribe(args.rtl_topic)
518
519
520def mqtt_disconnect(client, userdata, rc):
521    """Callback for MQTT disconnects."""
522    logging.info("MQTT disconnected: " + mqtt.connack_string(rc))
523
524
525def mqtt_message(client, userdata, msg):
526    """Callback for MQTT message PUBLISH."""
527    logging.debug("MQTT message: " + json.dumps(msg.payload.decode()))
528
529    try:
530        # Decode JSON payload
531        data = json.loads(msg.payload.decode())
532
533    except json.decoder.JSONDecodeError:
534        logging.error("JSON decode error: " + msg.payload.decode())
535        return
536
537    topicprefix = "/".join(msg.topic.split("/", 2)[:2])
538    bridge_event_to_hass(client, topicprefix, data)
539
540
541def sanitize(text):
542    """Sanitize a name for Graphite/MQTT use."""
543    return (text
544            .replace(" ", "_")
545            .replace("/", "_")
546            .replace(".", "_")
547            .replace("&", ""))
548
549def rtl_433_device_topic(data):
550    """Return rtl_433 device topic to subscribe to for a data element"""
551
552    path_elements = []
553
554    for key in NAMING_KEYS:
555        if key in data:
556            element = sanitize(str(data[key]))
557            path_elements.append(element)
558
559    return '/'.join(path_elements)
560
561
562def publish_config(mqttc, topic, model, instance, mapping):
563    """Publish Home Assistant auto discovery data."""
564    global discovery_timeouts
565
566    instance_no_slash = instance.replace("/", "-")
567    device_type = mapping["device_type"]
568    object_suffix = mapping["object_suffix"]
569    object_id = instance_no_slash
570    object_name = "-".join([object_id,object_suffix])
571
572    path = "/".join([args.discovery_prefix, device_type, object_id, object_name, "config"])
573
574    # check timeout
575    now = time.time()
576    if path in discovery_timeouts:
577        if discovery_timeouts[path] > now:
578            logging.debug("Discovery timeout in the future for: " + path)
579            return False
580
581    discovery_timeouts[path] = now + args.discovery_interval
582
583    config = mapping["config"].copy()
584    config["name"] = object_name
585    config["state_topic"] = topic
586    config["unique_id"] = object_name
587    config["device"] = { "identifiers": object_id, "name": object_id, "model": model, "manufacturer": "rtl_433" }
588
589    if args.force_update:
590        config["force_update"] = "true"
591
592    logging.debug(path + ":" + json.dumps(config))
593
594    mqttc.publish(path, json.dumps(config), retain=args.retain)
595
596    return True
597
598def bridge_event_to_hass(mqttc, topicprefix, data):
599    """Translate some rtl_433 sensor data to Home Assistant auto discovery."""
600
601    if "model" not in data:
602        # not a device event
603        logging.debug("Model is not defined. Not sending event to Home Assistant.")
604        return
605
606    model = sanitize(data["model"])
607
608    skipped_keys = []
609    published_keys = []
610
611    instance = rtl_433_device_topic(data)
612    if not instance:
613        # no unique device identifier
614        logging.warning("No suitable identifier found for model: ", model)
615        return
616
617    # detect known attributes
618    for key in data.keys():
619        if key in mappings:
620            # topic = "/".join([topicprefix,"devices",model,instance,key])
621            topic = "/".join([topicprefix,"devices",instance,key])
622            if publish_config(mqttc, topic, model, instance, mappings[key]):
623                published_keys.append(key)
624        else:
625            if key not in SKIP_KEYS:
626                skipped_keys.append(key)
627
628    if published_keys:
629        logging.info("Published %s: %s" % (instance, ", ".join(published_keys)))
630
631        if skipped_keys:
632            logging.info("Skipped %s: %s" % (instance, ", ".join(skipped_keys)))
633
634
635def rtl_433_bridge():
636    """Run a MQTT Home Assistant auto discovery bridge for rtl_433."""
637
638    mqttc = mqtt.Client()
639
640    if args.debug:
641        mqttc.enable_logger()
642
643    if args.user is not None:
644        mqttc.username_pw_set(args.user, args.password)
645    mqttc.on_connect = mqtt_connect
646    mqttc.on_disconnect = mqtt_disconnect
647    mqttc.on_message = mqtt_message
648    mqttc.connect_async(args.host, args.port, 60)
649    logging.debug("MQTT Client: Starting Loop")
650    mqttc.loop_start()
651
652    while True:
653        time.sleep(1)
654
655
656def run():
657    """Run main or daemon."""
658    # with daemon.DaemonContext(files_preserve=[sock]):
659    #  detach_process=True
660    #  uid
661    #  gid
662    #  working_directory
663    rtl_433_bridge()
664
665
666if __name__ == "__main__":
667    logging.getLogger().setLevel(logging.INFO)
668
669    parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter,
670                                     description=AP_DESCRIPTION,
671                                     epilog=AP_EPILOG)
672
673    parser.add_argument("-d", "--debug", action="store_true")
674    parser.add_argument("-q", "--quiet", action="store_true")
675    parser.add_argument("-u", "--user", type=str, help="MQTT username")
676    parser.add_argument("-P", "--password", type=str, help="MQTT password")
677    parser.add_argument("-H", "--host", type=str, default="127.0.0.1",
678                        help="MQTT hostname to connect to (default: %(default)s)")
679    parser.add_argument("-p", "--port", type=int, default=1883,
680                        help="MQTT port (default: %(default)s)")
681    parser.add_argument("-r", "--retain", action="store_true")
682    parser.add_argument("-f", "--force_update", action="store_true",
683                        help="Append 'force_update = true' to all configs.")
684    parser.add_argument("-R", "--rtl-topic", type=str,
685                        default="rtl_433/+/events",
686                        dest="rtl_topic",
687                        help="rtl_433 MQTT event topic to subscribe to (default: %(default)s)")
688    parser.add_argument("-D", "--discovery-prefix", type=str,
689                        dest="discovery_prefix",
690                        default="homeassistant",
691                        help="Home Assistant MQTT topic prefix (default: %(default)s)")
692    parser.add_argument("-i", "--interval", type=int,
693                        dest="discovery_interval",
694                        default=600,
695                        help="Interval to republish config topics in seconds (default: %(default)d)")
696    args = parser.parse_args()
697
698    if args.debug and args.quiet:
699        logging.critical("Debug and quiet can not be specified at the same time")
700        exit(1)
701
702    if args.debug:
703        logging.info("Enabling debug logging")
704        logging.getLogger().setLevel(logging.DEBUG)
705    if args.quiet:
706        logging.getLogger().setLevel(logging.ERROR)
707
708    # allow setting MQTT username and password via environment variables
709    if not args.user and 'MQTT_USERNAME' in os.environ:
710        args.user = os.environ['MQTT_USERNAME']
711
712    if not args.password and 'MQTT_PASSWORD' in os.environ:
713        args.password = os.environ['MQTT_PASSWORD']
714
715    if not args.user or not args.password:
716        logging.warning("User or password is not set. Check credentials if subscriptions do not return messages.")
717
718    run()
719