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