Airthings Wave Plus with MQTT discovery

In case it is helpful for anyone else, I have released a script called airthings-mqtt-ha that can run on a Raspberry Pi or other machine with a compatible Bluetooth Low Energy (BLE) radio and pull sensor values from Airthings environmental monitoring devices and send those values to Home Assistant via MQTT discovery. This means that the Airthings device and its associated sensors will automatically appear in Home Assistant if everything is set up correctly. For example, in my system the following device and associated sensors appear in Home Assistant after running the script:

I have only tested the script with one Airthings Wave Plus device, but it should theoretically work for multiple devices and other Airthings models. I would also like to point out that my script heavily leverages code developed by Marty Tremblay for his sensor.airthings_wave project for interacting with Airthings devices. Marty’s project is a great one, and while it did not work for my use case because I am not running Home Assistant on my Raspberry Pi or a machine with BLE capability you should definitely check it out in case it works for you. If you find my script useful please head over to Marty’s project and consider a donation because he really did all of the heavy lifting figuring out how to interface with these devices.

I look forward to any feedback people have, and hope that this proves helpful to others.

7 Likes

I’ve used this with my AirThings Wave2 basic unit. Worked pretty well. I’ve also made a matching sensor:

      airthings_radon_warning_level:
         friendly_name: 'Airthings Radon Warning Level'
         value_template: >-
            {% set radon_level = states.sensor.airthings_wave_radon_1_day_avg.state | default(10) | int %}
            {% if radon_level >= 150 %}
               Red
            {% elif radon_level >= 100 %}
               Yellow
            {% else %}
               Green
            {% endif %}
         icon_template: >-
            {% set radon_level = states.sensor.airthings_wave_radon_1_day_avg.state | default(10) | int %}
            {% if radon_level >= 150 %}
               mdi:radioactive
            {% elif radon_level >= 100 %}
               mdi:radioactive
            {% else %}
               mdi:radioactive-off
            {% endif %}

This can be used for automations as the radon levels change Green/Yellow/Red.

Thanks!

One comment, I couldn’t get it to work when started from systemctl. It reliably hangs when auto-starting itself in that environment with these logs:

Apr 19 02:28:46 radon Setting up Airthings sensors...
Apr 19 02:28:48 radon kernel: [   46.606195] NET: Registered protocol family 38
Apr 19 02:28:48 radon kernel: [   46.628776] cryptd: max_cpu_qlen set to 1000
Apr 19 02:28:49 radon xxxxxxx: Manufacturer: Airthings AS Model: 2950 Serial: xxxx Device:Airthings Wave2
Apr 19 02:29:02 radon Done Airthings setup.
Apr 19 02:29:02 radon Sending HA mqtt discovery configuration messages...
Apr 19 02:29:02 radon Sending messages to mqtt broker...
(nothing after this, script hangs)

I eventually gave up and converted the script to a single-run script (no while loop) that Cron for the raspberry pi “pi” userid just runs every 5 minutes. That works fine.

Additionally… the systemctl environment generally works if I run the script once in a regular interactive SSH session, kill it, then try again. But auto-start at boot never works.

I’d appreciate some thoughts on why the systemctl envrionment won’t work by itself.

Good to hear that it is mostly working for you. I am running it on a raspberry pi as well and I have not had issues running it using systemd and it does start for me on boot. Are you using the .service file that I included in the repository? One thing to try would be setting your log level to “DEBUG” in your config.toml file to get a better feeling for where the script is failing.

Yep, I used your .service file. I did turn on debugging, I also have read through the whole script. I can’t figure it out, I think it has something to do with the overall environment variables when in the supervise vs really being in an interactive shell. I didn’t get a lot of clues from the logs.

You are likely correct about the issue being a environment or permissions issue when running in the systemd environment, but systemd is not something that I have any particular level of expertise with. However, I understand that the systemd-run command can be used to try to debug these kinds of issues. I also find it curious that it works for me and not for you. To the extent it matters, I am running a fully updated raspbian buster OS on my device and the only other application I have running is a secondary pi-hole instance for failover. Sorry I cannot be of more assistance, but if you do figure out the issue please provide an update here in case others are encountering the same thing. Thank you.

I’ve had the same problem, could you share your method and script please?

Replying to my own post, I think I managed to do what @brillb mentioned he did

Created airthings-cron.py with the following code (removed the while loop and a few other things - warning I’m not a coder, but it appears to work!):

#!/usr/bin/python3
#
# Copyright (c) 2021 Mark McCans
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import logging, time, json, sys, os, toml
import paho.mqtt.publish as publish
from paho.mqtt import MQTTException
from airthings import AirthingsWaveDetect
# DEBUG: Remove old imports
#from pygatt.exceptions import NotConnectedError, BLEError, NotificationTimeout

_LOGGER = logging.getLogger("airthings-mqtt-ha")

CONFIG = {}     # Variable to store configuration
DEVICES = {}    # Variable to store devices

# Default configuration values
CONFIG_DEFAULTS = {
    "general": {"refresh_interval": 150, "retry_count": 10, "retry_wait": 3, "log_level": "INFO"},
    "devices": [],
    "sensors": [],
    "mqtt": {"host": "localhost", "port": 1883}
}

class ATSensors:

    sensors_list = []

    def __init__(self, scan_interval, devices=None):
        _LOGGER.info("Setting up Airthings sensors...")
        self.airthingsdetect = AirthingsWaveDetect(scan_interval, None)
        # Note: Doing this so multiple mac addresses can be sent in instead of just one.
        if devices is not None and devices != {}:
            self.airthingsdetect.airthing_devices = list(devices)
        else:
            _LOGGER.info("No devices provided, so searching for sensors...")
            self.find_devices()
        
        # Get info about the devices
        if not self.get_device_info():
            # Exit if setup fails
            _LOGGER.error("Failed to set up Airthings sensors. Exiting.")
            sys.exit(1)

        _LOGGER.info("Done Airthings setup.")

    def find_devices(self):
        try:
            _LOGGER.debug("Searching for Airthings sensors...")
            num_devices_found = self.airthingsdetect.find_devices()
            _LOGGER.debug("Found {} airthings device(s)".format(num_devices_found))
            if num_devices_found != 0:
                # Put found devices into DEVICES variable
                for d in self.airthingsdetect.airthing_devices:
                    DEVICES[d] = {}
            else:
                # Exit if no devices found
                _LOGGER.warning("No airthings devices found. Exiting.")
                sys.exit(1)
        except:
            _LOGGER.exception("Failed while searching for devices. Exiting.")
            sys.exit(1)

    def get_device_info(self):
        _LOGGER.debug("Getting info about device(s)...")
        for attempt in range(CONFIG["general"]["retry_count"]):
            try:
                devices_info = self.airthingsdetect.get_info()
            #except (NotificationTimeout, NotConnectedError):
            #    _LOGGER.warning("Bluetooth error on attempt {}. Retrying in {} seconds.".format(attempt+1, CONFIG["general"]["retry_wait"]))
            #    time.sleep(CONFIG["general"]["retry_wait"])
            except:
                _LOGGER.exception("Unexpected exception while getting device information.")
                return False
            else:
                # Success!
                break
        else:
            # We failed all attempts
            _LOGGER.exception("Failed to get info from devices after {} attempts.".format(CONFIG["general"]["retry_count"]))
            return False

        # Collect device details
        for mac, dev in devices_info.items():
            _LOGGER.info("{}: {}".format(mac, dev))
            DEVICES[mac]["manufacturer"] = dev.manufacturer
            DEVICES[mac]["serial_nr"] = dev.serial_nr
            DEVICES[mac]["model_nr"] = dev.model_nr
            DEVICES[mac]["device_name"] = dev.device_name

        _LOGGER.debug("Getting sensors...")
        for attempt in range(CONFIG["general"]["retry_count"]):
            try:
                devices_sensors = self.airthingsdetect.get_sensors()
            #except (NotificationTimeout, NotConnectedError):
            #    _LOGGER.warning("Bluetooth error on attempt {}. Retrying in {} seconds.".format(attempt+1, CONFIG["general"]["retry_wait"]))
            #    time.sleep(CONFIG["general"]["retry_wait"])
            except:
                _LOGGER.exception("Unexpected exception while getting sensors information.")
                return False
            else:
                # Success!
                break
        else:
            # We failed all attempts
            _LOGGER.exception("Failed to get info from sensors after {} attempts.".format(CONFIG["general"]["retry_count"]))
            return False

        # Collect sensor details
        for mac, sensors in devices_sensors.items():
            for sensor in sensors:
                self.sensors_list.append([mac, sensor.uuid, sensor.handle])
                _LOGGER.debug("{}: Found sensor UUID: {} Handle: {}".format(mac, sensor.uuid, sensor.handle))
        
        return True
    
    def get_sensor_data(self):
        _LOGGER.debug("Getting sensor data...")
        for attempt in range(CONFIG["general"]["retry_count"]):
            try:
                sensordata = self.airthingsdetect.get_sensor_data()
                return sensordata
            #except (NotificationTimeout, NotConnectedError):
            #    _LOGGER.warning("Bluetooth error on attempt {}. Retrying in {} seconds.".format(attempt+1, CONFIG["general"]["retry_wait"]))
            #    time.sleep(CONFIG["general"]["retry_wait"])
            except:
                _LOGGER.exception("Unexpected exception while getting sensor data.")
                return False
            else:
                # Success!
                break
        else:
            # We failed all attempts
            _LOGGER.exception("Failed to get sensor data after {} attempts.".format(CONFIG["general"]["retry_count"]))
            return False
        
        return True

def mqtt_publish(msgs):
    # Publish the sensor data to mqtt broker
    try:
        _LOGGER.info("Sending messages to mqtt broker...")
        if "username" in CONFIG["mqtt"] and CONFIG["mqtt"]["username"] != "" and "password" in CONFIG["mqtt"] and CONFIG["mqtt"]["password"] != "":
            auth = {'username':CONFIG["mqtt"]["username"], 'password':CONFIG["mqtt"]["password"]}
        else:
            auth = None
        publish.multiple(msgs, hostname=CONFIG["mqtt"]["host"], port=CONFIG["mqtt"]["port"], client_id="airthings-mqtt", auth=auth)
        _LOGGER.info("Done sending messages to mqtt broker.")
    except MQTTException as e:
        _LOGGER.error("Failed while sending messages to mqtt broker: {}".format(e))
    except:
        _LOGGER.exception("Unexpected exception while sending messages to mqtt broker.")

if __name__ == "__main__":
    logging.basicConfig()
    _LOGGER.setLevel(logging.INFO)

    # Load configuration from file
    try:
        CONFIG = toml.load(os.path.join(sys.path[0], "config.toml"))
    except:
        # Exit if there is an error reading config file
        _LOGGER.exception("Error reading config.toml file. Exiting.")
        sys.exit(1)
    
    # Fill in any missing configuration variable with defaults
    for key in CONFIG_DEFAULTS:
        if key not in CONFIG: CONFIG[key] = CONFIG_DEFAULTS[key]
        for val in CONFIG_DEFAULTS[key]:
            if val not in CONFIG[key]: CONFIG[key][val] = CONFIG_DEFAULTS[key][val]

    # Set logging level (defaults to INFO)
    if CONFIG["general"]["log_level"] in ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]:
        _LOGGER.setLevel(CONFIG["general"]["log_level"])

    # Pull out devices (if any) configured
    for d in CONFIG["devices"]:
        if "mac" in d: DEVICES[d["mac"]] = {}

    a = ATSensors(180, DEVICES)

# Update sensor values in accordance with the REFRESH_INTERVAL set.
# Get sensor data
sensors = a.get_sensor_data()

# Only connect to mqtt broker if we have data
if sensors is not None:
    # Variable to store mqtt messages
    msgs = []
    
# Send HA mqtt discovery messages to broker on first run
    _LOGGER.info("Sending HA mqtt discovery configuration messages...")
    for mac, data in sensors.items():
        
        # Create device details for this device
        device = {}
        device["connections"] = [["mac", mac]]
        if "serial_nr" in DEVICES[mac]: device["identifiers"] = [DEVICES[mac]["serial_nr"]]
        if "manufacturer" in DEVICES[mac]: device["manufacturer"] = DEVICES[mac]["manufacturer"]
        if "device_name" in DEVICES[mac]: device["name"] = DEVICES[mac]["device_name"]
        if "model_nr" in DEVICES[mac]: device["model"] = DEVICES[mac]["model_nr"]

        for name, val in data.items():
            if name != "date_time":
                try:
                    config = {}
                    s = next((item for item in CONFIG["devices"] if item["mac"] == mac), None)
                    if s != None:
                        if name in s:
                            config["name"] = s[name]["name"]
                            if "device_class" in s[name] and s[name]["device_class"] is not None:
                                config["device_class"] = s[name]["device_class"]
                            if "icon" in s[name] and s[name]["icon"] is not None:
                                config["icon"] = s[name]["icon"]
                            if "unit_of_measurement" in s[name] and s[name]["unit_of_measurement"] is not None:
                                config["unit_of_measurement"] = s[name]["unit_of_measurement"]
                            config["uniq_id"] = mac+"_"+name
                            config["state_topic"] = "airthings/"+mac+"/"+name
                            config["device"] = device

                    msgs.append({'topic': "homeassistant/sensor/airthings_"+mac.replace(":","")+"/"+name+"/config", 'payload': json.dumps(config), 'retain': True})
                except:
                    _LOGGER.exception("Failed while creating HA mqtt discovery messages.")
    
    # Publish the HA mqtt discovery data to mqtt broker
    mqtt_publish(msgs)
    _LOGGER.info("Done sending HA mqtt discovery configuration messages.")
    msgs = []
    time.sleep(5)
    first = False

# Collect all of the sensor data
_LOGGER.info("Collecting sensor value messages...")
for mac, data in sensors.items():
    for name, val in data.items():
        if name != "date_time":
            if isinstance(val, str) == False:
                if name == "temperature":
                    val = round(val,1)
                else:
                    val = round(val)
            _LOGGER.info("{} = {}".format("airthings/"+mac+"/"+name, val))
            msgs.append({'topic': "airthings/"+mac+"/"+name, 'payload': val})

# Publish the sensor data to mqtt broker
mqtt_publish(msgs)

Ran crontab -e and added the following at the bottom so that the above runs every 5 minutes (adjust path of airthing-cron.py to suit your config):

*/5 * * * * python3 /home/pi/airthings/airthings-cron.py

Hope that helps somebody else! Thanks to the OP

In case anyone finds it helpful, I have packaged the script as a Home Assistant Add-on that can be found at GitHub - mjmccans/hassio-addon-airthings. It has been working well for me on an Odroid N2+ running HassOS, and I have also tested it on a Raspberry Pi and a Supervised install on Debian (although you need to make sure the right bluetooth packages are installed if you go the Supervised route). Please let me know if you have any comments or have any issues getting it to work.

1 Like

You are awesome! Thanks for this!

Thank you very much for creating your airthings-mqtt-ha GitHub repository. And thank you very much your extremely clear instructions on how to install it! I followed your instructions, and was successful in getting it to work in my VMware installation of Home Assistant. I really appreciate your work you did to put it together. I can see my Airthings Wave Plus in my Home Assistant dashboard.

@mjmccans
I may be (probably am!) missing something super basic, but when I install your addon, start it as instructed and go to log, it returns the below. Could someone nudge me in the right direction to get this working? Many thanks in advance!

[10:00:55] INFO: Getting mqtt configuration…
[10:00:55] ERROR: Got unexpected response from the API: Service not enabled
[10:00:55] INFO: Starting python script…
usage: airthings-mqtt.ha.py [-h] --host HOST [–port PORT] --username USERNAME
–password PASSWORD --config CONFIG
airthings-mqtt.ha.py: error: argument --host: expected one argument

Do you have an mqtt broker set up in Home Assistant? I could be wrong but that looks like an error that could occur if the addon does not get the credentials for the internal mqtt broker. If that doesn’t help then please raise an issue on the addon’s GitHub page and I can try to help you more there. Thank you.

I do feel like a noob…

Mosquito Broker installed and boom, all working just fine.

I took the mention of the “internal MQTT broker” in your documentation to mean that there wasn’t a need to install any additional components.

Thank you very much!

No problem at all, and don’t feel like a noob. I think I should update the addon instructions to make it more clear, and also update the addon to provide a more helpful error message than “expected one argument”. It is an error case that I had not contemplated, so perhaps it is me who is the noob!

1 Like

The add on has continued to work perfectly for quite some time (thank you! :grinning:), but has just stopped reporting.

Checking the log, I’m receiving the following error:

Traceback (most recent call last):
  File "/src/./airthings-mqtt.ha.py", line 373, in <module>
    val = round(val)
TypeError: type NoneType doesn't define __round__ method

Is this something I can fix on my end? This appears to have occurred in the past week. I suspect due to a HA update.

It looks like there is an unexpected None value being returned, which I probably should have checked for but it is not something that is supposed to be retuned at that point in the script. Could you open an issue on Github here and I can add some additional checking or otherwise we can try to figure out why the unexpected None value is being returned?

Apologies, I completely missed your reply… it started working again, without any changes. Go figure :crazy_face:

If it stops working again, I’ll make sure to open the issue on Github.

Thank you again!