PurpleAir Air Quality Sensor

I see github activity around the air quality sensors, including built in transformations functions.

How do I use the HA air_quality platform?
E.g. pass in PM2.5 values and AQI without needing to use the calcAQI macro?
E.g. get an air quality UI widget?

Oooh, good find @dango! I’ve always assumed that either my temperature sensor was defective, or just useless due to it getting heated by the PurpleAir computer itself. I’ll try adding in that offset in my config and see if it gives me more reasonable data…

@ptr727: I honestly don’t know, as I’ve not tried the air_quality platform. If you figure it out, please be sure to let us know. :slight_smile:

With the entire west US pretty much on fire at this point, I was very much interested in a PurpleAir integration. Thanks @colohan for the groundwork on the REST template. After playing around a bit, I ended up making a very quick and dirty custom component to better integrate and get all the sensor goodness that comes with it, as I was interested in some of the raw parts of the data outside the AQI.

This is my first component, but it’s worked pretty well in my use case so far today. You can grab the source at https://gitlab.com/gibwar/home-assistant-purpleair and add it to your custom components. It creates a proper air_quality sensor with the correct PM 1.0, PM 2.5, and PM 10 data averaged out if the sensor you pick has an A and B channel. It only supports setup via the GUI and only with sensors that have free public access and it’ll batch the updates every 5 minutes for any additional sensors you add. It also only supports the web API, as I don’t have a device locally to test/setup a local API. I don’t think it’d be terribly difficult to add though. It’d need to flag it and update it outside the update schedule as that batches the sensor IDs together to lessen the load on their servers.

As I said, this is my first integration, so I hope you all find it useful.


Thanks for the excellent configuration, @colohan. I made some small tweaks for my own use that others might find useful. Namely, I made the value of the data holder the last time the sensor was seen, moved the AQI value to a template sensor, added in the 8 degree temperature adjustment as suggested, put a couple variables in the template to reduce repetition in the conditionals so they’re easier to read, and added unique_ids and device_classes to the entities, and added a binary_sensor for connectivity and use in availability_template values.

  # https://community.home-assistant.io/t/purpleair-air-quality-sensor/146588
  # Reasonable sensors pulled from https://www.purpleair.com/map: 37021, 20801, 20707.
  - platform: rest
    name: 'PurpleAir'

    # Substitute in the URL of the sensor you care about. To find the URL, go
    # to purpleair.com/map, find your sensor, click on it, click on "Get This
    # Widget" then click on "JSON".
    resource: https://www.purpleair.com/json?key=PMWGYB07N4CDBH23&show=37021

    # Only query once a minute to avoid rate limits:
    scan_interval: 60

    # Set the sensor value to the last update time of the device.
    device_class: timestamp
    value_template: >
      {{ state_attr('sensor.purpleair', 'results')[0]['LastSeen'] }}

    # The value of the sensor can't be longer than 255 characters, but the
    # attributes can. Store away all the data for use by the templates below.
      - results

  - platform: template
        friendly_name: 'PurpleAir AQI'
        unique_id: purpleair_aqi
        # Set this sensor to be the AQI value.
        # Code translated from JavaScript found at:
        # https://docs.google.com/document/d/15ijz94dXJ-YAZLi9iZ_RaBwrZ4KtYeCy08goGBwnbCU/edit#
        value_template: >
          {% macro calcAQI(Cp, Ih, Il, BPh, BPl) -%}
            {{ (((Ih - Il)/(BPh - BPl)) * (Cp - BPl) + Il)|round }}
          {%- endmacro %}
          {% set pm25 = state_attr('sensor.purpleair', 'results')[0]['PM2_5Value']|float %}
          {% if pm25 > 1000 %}
          {% elif pm25 > 350.5 %}
            {{ calcAQI(pm25, 500.0, 401.0, 500.0, 350.5) }}
          {% elif pm25 > 250.5 %}
            {{ calcAQI(pm25, 400.0, 301.0, 350.4, 250.5) }}
          {% elif pm25 > 150.5 %}
            {{ calcAQI(pm25, 300.0, 201.0, 250.4, 150.5) }}
          {% elif pm25 > 55.5 %}
            {{ calcAQI(pm25, 200.0, 151.0, 150.4, 55.5) }}
          {% elif pm25 > 35.5 %}
            {{ calcAQI(pm25, 150.0, 101.0, 55.4, 35.5) }}
          {% elif pm25 > 12.1 %}
            {{ calcAQI(pm25, 100.0, 51.0, 35.4, 12.1) }}
          {% elif pm25 >= 0.0 %}
            {{ calcAQI(pm25, 50.0, 0.0, 12.0, 0.0) }}
          {% else %}
          {% endif %}
        unit_of_measurement: "AQI"
        entity_id: sensor.purpleair
        availability_template: "{{ sensor.purpleair_available }}"
        friendly_name: 'PurpleAir AQI Description'
        unique_id: purpleair_description
        value_template: >
          {% set aqi = states('sensor.purpleair_aqi')|float %}
          {% if aqi >= 401.0 %}
            Very Hazardous
          {% elif aqi >= 301.0 %}
          {% elif aqi >= 201.0 %}
            Very Unhealthy
          {% elif aqi >= 151.0 %}
          {% elif aqi >= 101.0 %}
            Unhealthy for Sensitive Groups
          {% elif aqi >= 51.0 %}
          {% elif aqi >= 0.0 %}
          {% else %}
          {% endif %}
        entity_id: sensor.purpleair
        availability_template: "{{ sensor.purpleair_available }}"
        friendly_name: 'PurpleAir PM 2.5'
        unique_id: purpleair_pm25
        value_template: "{{ state_attr('sensor.purpleair', 'results')[0]['PM2_5Value'] }}"
        unit_of_measurement: "μg/m3"
        entity_id: sensor.purpleair
        availability_template: "{{ sensor.purpleair_available }}"
        friendly_name: 'PurpleAir Temperature'
        unique_id: purpleair_temp
        # Why "- 8"?
        # https://www2.purpleair.com/community/faq#!hc-primary-and-secondary-data-header
        value_template: "{{ state_attr('sensor.purpleair', 'results')[0]['temp_f'] | float - 8 }}"
        unit_of_measurement: "°F"
        device_class: temperature
        entity_id: sensor.purpleair
        availability_template: "{{ sensor.purpleair_available }}"
        friendly_name: 'PurpleAir Humidity'
        unique_id: purpleair_humidity
        value_template: "{{ state_attr('sensor.purpleair', 'results')[0]['humidity'] }}"
        unit_of_measurement: "%"
        device_class: humidity
        entity_id: sensor.purpleair
        availability_template: "{{ sensor.purpleair_available }}"
        friendly_name: 'PurpleAir Pressure'
        unique_id: purpleair_pressure
        value_template: "{{ state_attr('sensor.purpleair', 'results')[0]['pressure'] }}"
        unit_of_measurement: "mb"
        device_class: pressure
        entity_id: sensor.purpleair
        availability_template: "{{ sensor.purpleair_available }}"

  - platform: template
      # Add a connectivity sensor for purpleair. Why isn't the above rest sensor
      # a binary_sensor? Turns out binary_sensor rest devices can't have a
      # json_attributes section.
        friendly_name: 'PurpleAir Available'
        unique_id: purpleair_available
        value_template: > 
          {% set stats = state_attr('sensor.purpleair', 'results')[0]['Stats']|from_json %}
          {% set minutes = stats['timeSinceModified']|int / 1000 /60 %}
          {{ minutes < 5 }}
        entity_id: sensor.purpleair
        device_class: connectivity

@michael.olson thanks for the tweaks! Due to wildfires nearby, this data is very essential for us.
How do you and/or others display this data? I’m newbie to HA and wanted to know if I am doing it right.
Here’s how I display my entities:

I’ve not incorporated some of the excellent tweaks from others above (yet) into my config, but I’m also located in the Bay Area, so smoke is all I care about right now. I figured that what I care about is:

  1. how bad is it outside; and
  2. can I safely cool my house by opening a window right now?

To answer those two simple questions, what I have on my home screen is this:

It simply shows me the temperature inside (from my PurpleAir), outside (from a Multisensor 6), AQI inside (from my PurpleAir), and outside (averages the 3 closest outdoor PurpleAir devices to my home). Clicking on any of those shows a history graph, which can be helpful.

The code I have for the averaging is this:

  - platform: template
        friendly_name: 'Outdoor AQI from neighbours'
        # Average three sensors so if one goes down I still have data:
        value_template: >-
          {% set sum = 0.0 %}
          {% set count = 0.0 %}
          {% if states('sensor.purpleair_aqi_neighbour_1') != 'invalid' %}
            {% set sum = sum + states('sensor.purpleair_aqi_neighbour_1')|float %}
            {% set count = count + 1 %}
          {% endif %}
          {% if states('sensor.purpleair_aqi_neighbour_2') != 'invalid' %}
            {% set sum = sum + states('sensor.purpleair_aqi_neighbour_2')|float %}
            {% set count = count + 1 %}
          {% endif %}
          {% if states('sensor.purpleair_aqi_neighbour_3') != 'invalid' %}
            {% set sum = sum + states('sensor.purpleair_aqi_neighbour_3')|float %}
            {% set count = count + 1 %}
          {% endif %}
          {{ (sum / count)|int }}
        unit_of_measurement: "AQI"

Yesterday I installed zwave-capable thermostats in my home. So now I can use the output of my PurpleAir indoor sensor to automatically turn our recirculation fan on/off, so it filters the smoke out of the air.

Previously I would just leave it on constantly, and it would bug me that it was continuously using 800W to filter the air. But now it can just run on demand.

See if you can tell when I turned this script on: :slight_smile:

(Hint: it was not smokey outside when it was not on, and I turned it on once the smoke levels outside got worse.)


Excellent update on the averaging. I don’t have anything to add to the display side, I haven’t tried any fancy cards for the display. I want to get my sensor aggregation set up first and then I’ll update my config above to include multiple sensors. It would be really nice if the binary_sensor type allowed json_attributes, since what I want to do is have a purpleair_neighbor_n for 1-4 neighbors where the only directly observable value is the data availability.

I may make a small script to do the aggregation locally, as I’m worried about both a) that this should probably include an average of channel a and channel b and b) update time mismatches when aggregating using the rest sensors. Both of which are hard to fix within Home Assistant. (For update times, it’s a nitpick, but the aggregation presumably happens when any of 1, 2, 3 is updated, rather than when all of 1, 2, 3 are updated, so you end up averaging the same data point many times. The actual difference in calculation is almost certainly negligible, though . . .)

The other concern is that the values from PurpleAir don’t apparently directly correspond to the same scale as AirNow uses. I was reading this article on the difference between the two, and it suggests that in order to make the PurpleAir values equivalent to what AirNow uses, you need to apply the LRAPA adjustments, particularly for the wood smoke we’re all concerned about right now. Quoting:

The laser sensors used by Purple Air rely on a hard-coded constant that represents the average density of the particles it is detecting. Because wood smoke particles are less dense (1.5 g/cm³) than typical PM 2.5 particles, the resulting AQI values are too high. This is the reason that LRAPA conversion is necessary.

I was reading the code linked in the original config and don’t see how to do the LRAPA adjustment there. More investigation required.


Thank you for sharing your custom component! I couldn’t seem to add the GitLab repo to HACS as a custom repository :confused: (Perhaps HACS doesn’t support GitLab – I was getting error GitHub returned 404 for https://api.github.com/repos/ https://gitlab.com/gibwar/home-assistant-purpleair). So I forked it to GitHub so I could add this from HACS.

Worked like a charm out of the box! Thank you for even going the extra mile to add the configuration flow.

@painkiller: this is how I’m visualizing my air quality right now. Similar to @colohan I’m mostly using this to determine when to open a window, though I’ll also use it to determine when to run the dehumidifier or the HVAC fan (which has a beefy Aprilaire filter).

edit: @gibwar converts PM2.5 to AQI in their code and I need to do something similar for my indoor PM2.5 sensor (a PMS5003 via ESPHome) so everything is AQI and more easily comparable (perhaps by adding something like this to ESPHome for the PMS5003).


I like that graph card you’re using to plot the temperature and humidity. Can you tell me what card it is?

It’s just the vanilla History Graph card!

For absolute humidty I’m using dolezsa/thermal_comfort, though since there haven’t been updates to that repo and I found a bug, I forked it. My aim with that card is to understand how much water is in the air so I can decided whether opening a window will make things drier inside (once the outside air gets heated up).

1 Like

Thanks for importing this into github for use with HACS! I’m getting an error trying to to add my sensor:

Traceback (most recent call last):
  File "/config/custom_components/purpleair/PurpleAirApi.py", line 126, in _update
    b = float(readings['B'][prop])
TypeError: float() argument must be a string or a number, not 'NoneType'

This is my public indoor sensor. The json url is: https://www.purpleair.com/json?key=NEJZHIZC2QV6IIRO&show=62605

I wonder if I’m doing something wrong?

1 Like

Hi all.

Hope it’s OK to join this thread, I see it’s still active.

I can’t for the life of me find the AQI within the JSON results, e.g. https://www.purpleair.com/json?show=37849. I’m expecting to see the “Now”, “10 Min”, “30 Min”, etc. figures that I see in the map displays. Am I looking in the wrong place? Am I crazy? Just dumb?

Can’t find it because it ain’t there. :wink:

The AQI is computed based on the PM2.5 value that the sensor exports. See the code posted above for links to the PurpleAir Javascript code which does this calculation, and translations into the Django used in Home Assistant configs.

1 Like

ugh. Well at least I’m not my third option of “just dumb” – (umm… in this particular case :stuck_out_tongue: )

Thanks, I’ve got it working now!

I didn’t see anything like that and there wasn’t much config I needed to input so I have no idea!

I’m a random stranger on the internet who is also a noob, but I thought I’d share what I’ve been able to do following on Colohan’s work.

In #5 at the bottom of this article, they mention a new conversion which is supposed to be more appropriate for Bay Area wildfire smoke (same reason I set mine up). I took a stab at implementing this conversion as a new sensor, which I don’t know if I’ve done correctly but it gives me a value.

        friendly_name: 'PurpleAir Wildfire AQI'
        unique_id: purpleair_wildfire_aqi
        value_template: "{{ ((0.524 * states('sensor.purpleair_pm25') | float) - (0.0852 * states('sensor.purpleair_humidity') | float) + 5.71) | round(2)}}"
        unit_of_measurement: "AQI"
        entity_id: sensor.purpleair
        availability_template: "{{ sensor.purpleair_available }}"

I also configured Home Assistant to alert me when the air quality changes from Good to Unhealthy. I haven’t put these two solutions together yet but thought I’d share what I was able to accomplish so far.

    name: 1. Clean Air
    message: The outdoor air quality is Clean
    entity_id: sensor.purpleair_description
    state: 'Good'
    repeat: 60
    can_acknowledge: true
    skip_first: false
      - notify 
# you can repeat this pattern for each of the AQI categories, for example, here is unhealthy air. I did all 6.

    name: 4. Air is Unhealthy
    message: The outdoor air quality is Unhealthy
    entity_id: sensor.purpleair_description
    state: 'Unhealthy'
    repeat: 60
    can_acknowledge: true
    skip_first: false
      - notify

I took a stab at implementing this conversion as a new sensor, which I don’t know if I’ve done correctly but it gives me a value.

I think maybe my UOM is wrong, maybe the EPA Wildfire correction is a correction to the PM2.5 value, which would then feed in to the AQI calculation. Interested to see if someone else has a similar read.

It doesn’t seem to quite match the PurpleAir map LARPA values when using the formula from the article.

I noticed on the Conversion ? popup, they list the formula they supposedly use (I haven’t checked against the js) is LRAPA PM2.5 (µg/m³) = 0.5 x PA (PM2.5 CF=ATM) – 0.66. Applying that seems to get me values similar to what’s on the their map, once converted to an AQI. Is PurpleAir applying a less accurate conversion? Maybe, but at least this matches what I’m used to seeing on their map.