PurpleAir Air Quality Sensor

I recently got a PurpleAir indoor air quality sensor. It took me a bit to figure out how to integrate the JSON responses from their API into Home Assistant, so I thought I’d share my config here for folks to copy and improve.

From my configuration.yaml:

  - 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?show=40509&key=HWSALTZNT199YJWT

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

    # 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 %}
      {% if (value_json["results"][0]["PM2_5Value"]|float) > 1000 %}
      {% elif (value_json["results"][0]["PM2_5Value"]|float) > 350.5 %}
        {{ calcAQI((value_json["results"][0]["PM2_5Value"]|float), 500.0, 401.0, 500.0, 350.5) }}
      {% elif (value_json["results"][0]["PM2_5Value"]|float) > 250.5 %}
        {{ calcAQI((value_json["results"][0]["PM2_5Value"]|float), 400.0, 301.0, 350.4, 250.5) }}
      {% elif (value_json["results"][0]["PM2_5Value"]|float) > 150.5 %}
        {{ calcAQI((value_json["results"][0]["PM2_5Value"]|float), 300.0, 201.0, 250.4, 150.5) }}
      {% elif (value_json["results"][0]["PM2_5Value"]|float) > 55.5 %}
        {{ calcAQI((value_json["results"][0]["PM2_5Value"]|float), 200.0, 151.0, 150.4, 55.5) }}
      {% elif (value_json["results"][0]["PM2_5Value"]|float) > 35.5 %}
        {{ calcAQI((value_json["results"][0]["PM2_5Value"]|float), 150.0, 101.0, 55.4, 35.5) }}
      {% elif (value_json["results"][0]["PM2_5Value"]|float) > 12.1 %}
        {{ calcAQI((value_json["results"][0]["PM2_5Value"]|float), 100.0, 51.0, 35.4, 12.1) }}
      {% elif (value_json["results"][0]["PM2_5Value"]|float) >= 0.0 %}
        {{ calcAQI((value_json["results"][0]["PM2_5Value"]|float), 50.0, 0.0, 12.0, 0.0) }}
      {% else %}
      {% endif %}

    unit_of_measurement: "AQI"
    # 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 Description'
        value_template: >
          {% if (states('sensor.purpleair')|float) >= 401.0 %}
          {% elif (states('sensor.purpleair')|float) >= 301.0 %}
          {% elif (states('sensor.purpleair')|float) >= 201.0 %}
            Very Unhealthy
          {% elif (states('sensor.purpleair')|float) >= 151.0 %}
          {% elif (states('sensor.purpleair')|float) >= 101.0 %}
            Unhealthy for Sensitive Groups
          {% elif (states('sensor.purpleair')|float) >= 51.0 %}
          {% elif (states('sensor.purpleair')|float) >= 0.0 %}
          {% else %}
          {% endif %}
        entity_id: sensor.purpleair
        friendly_name: 'PurpleAir PM 2.5'
        value_template: "{{ state_attr('sensor.purpleair','results')[0]['PM2_5Value'] }}"
        unit_of_measurement: "μg/m3"
        entity_id: sensor.purpleair
        friendly_name: 'PurpleAir Temperature'
        value_template: "{{ state_attr('sensor.purpleair','results')[0]['temp_f'] }}"
        unit_of_measurement: "°F"
        entity_id: sensor.purpleair
        friendly_name: 'PurpleAir Humidity'
        value_template: "{{ state_attr('sensor.purpleair','results')[0]['humidity'] }}"
        unit_of_measurement: "%"
        entity_id: sensor.purpleair
        friendly_name: 'PurpleAir Pressure'
        value_template: "{{ state_attr('sensor.purpleair','results')[0]['pressure'] }}"
        unit_of_measurement: "mb"
        entity_id: sensor.purpleair

thanks for sharing. You might want to edit your post and remove the key from the URL:

Right now, everyone who opens this URL from your post is able to see your air quality in real time :wink:

I considered that, but… my air quality data is already public on the PurpleAir site (I lied to them a bit about where my house is located…). The key is actually ignored by PurpleAir. It is also published by PurpleAir on their site (!!). You’ll note that your obfuscated URL works just as well as the one I posted with a (useless) key.

I assume these are just meant to be .4 and not .4.0 @colohan?

Great work with the config BTW!!

1 Like

Ooops, indeed you are right. I’ve edited my post with your correction. (Serves me right for doing search and replace in my code to convert from Javascript… :slight_smile: )


I actually think you did really well to work it out!

Your work prompted me to build a JSON middleware for the NSW (Australia) government AQI data so I can build a REST sensor for it: http://nswairquality.heliohost.org/cgi-bin/nswairquality.py


Very cool! Considering a PurpleAir outdoor sensor and was wondering if anyone had made an integration work. Do you happen to know if their API returns PM10 values as well as PM2.5? I’m in Arizona where we tend to get a lot of dust (PM10) on breezy days. Would be cool if it returned both.

Yes, it does. You can see all the data it returns by looking at the PurpleAir map, here is a random sensor site I just clicked on.

I get my data from the PurpleAir sensor locally - so I’m not hitting their API, and so I keep getting data if there are internet / API issues.
The data is available at http://n.n.n.n/json?live=true where n.n.n.n is your sensors IP address.

Huh. Indeed it does. I unknowingly had the subnet of my home network this was on configured to forbid connections between hosts on the network – which means I couldn’t connect to it. And mistook that for “guess that doesn’t work”.

I’ve fixed my firewall rules, and now I can get at this locally. Thanks!

1 Like

Hi…thanks a lot for sharing this. I’ll definitely incorporate to my HA. I intend to add it in my personal website. You said you convert it from your javascript. Would you mind sharing your javascript on this part?

It’s not my javascript – it is PurpleAir’s javascript. The link to the original code is provided in my code: https://docs.google.com/document/d/15ijz94dXJ-YAZLi9iZ_RaBwrZ4KtYeCy08goGBwnbCU/edit#

Thanks a lot, I just saw it in your HA config. Sorry, I’m quite new to Javascript. Is it a complete script starting from pulling data from PurpleAir till populating the desired values to variables that I can pick from?

@colohan - Thanks for sharing your purple air implementation. I grabbed that plus the excellent gauge card (https://github.com/custom-cards/canvas-gauge-card) and mapped the values to the colours on the AQI charts.


      - type: custom:canvas-gauge-card
        card_height: 200
        entity: sensor.purpleair
        name: AQI reading
          top: 50%
          left: 50%
          type: "radial-gauge"
          title: AQI
          width: 200
          height: 200
          minValue: 0
          maxValue: 500
          startAngle: 40
          ticksAngle: 280
          valueBox: true
          majorTicks: ["0", "50", "100", "150", "200", "250", "300", "350", "400", "450", "500"]
          minorTicks: 10
          strokeTicks: true
          highlights: [{"from": 0, "to": 50,"color": "rgba(104, 225, 67, .75)"},{"from": 50, "to": 100,"color": "rgba(255, 255, 85, .75)"},{"from": 100, "to": 150,"color": "rgba(239, 133, 51, .75)"},{"from": 150, "to": 200,"color": "rgba(234, 51, 36, .75)"},{"from": 200, "to": 300,"color": "rgba(140, 26, 75, .75)"},{"from": 300, "to": 500,"color": "rgba(115, 20, 37, .75)"}]
          borders: no
          needleType: "arrow"
          needleWidth: 4
          needleCircleSize: 7
          needleCircleOuter: true
          needleCircleInner: false
          animationDuration: 1500
          animationRule: "linear"
          valueBoxBorderRadius: 10
          colorValueBoxRect: "#222"
          colorValueBoxRectEnd: "#333"
          valueDec: 2
          valueInt: 2
      - type: entities
        title: My Area 
        show_header_toggle: false
          - entity: sensor.purpleair_description
            name: Air Quality Index (AQI)
          - entity: sensor.purpleair_pm25
            name: PurpleAir PM 2.5
          - entity: sensor.purpleair_temperature
            name: PurpleAir Temperature
          - entity: sensor.purpleair_humidity
            name: PurpleAir Humidity
          - entity: sensor.purpleair_pressure
            name: PurpleAir Pressure

@bthoven – If you are using Home Assistant, the Javascript implementation is not relevant to you – it is simply where I learned how to create this Home Assistant configuration. The above script is written in YAML (with some embedded Jinja2 scripting, since Home Assistant configs use both (which sadly makes it much harder to learn…)).

If you cut-and-paste the code above into your configuration.yaml, then you will end up with a series of new variables defined in your Home Assistant (such as sensor.purpleair_aqi, sensor.purpleair_humidity, etc.) that you can either use as inputs to widgets, or use in your own automations. These will tell you all about the air quality in my home. If you modify the resource: line you can have it tell you about the air quality in your home instead. :slight_smile:

@colohan Thanks. I do not own a sensor. Apart from HA, I also have a website designed just for a weather station console, thanks to a guy who originated and shared it. The latter was driven mainly by html, javascript and css. I’m not sure how the result from configuration.yaml used in HA can be somehow reused for my website.

Btw, just did it with a simple Javascript for my website. Thanks to the PurpleAir document you mentioned.

The local API seems to be slightly different. Here is my config:

  - platform: rest
    name: 'PurpleAir'
    resource: http://<myhost>/json?live=true
    value_template: '{{ value_json.SensorId }}'
      - Mem
      - memfrag
      - uptime
      - rssi
      - Adc
      - current_temp_f
      - current_humidity
      - current_dewpoint_f
      - pressure
      - p_0_3_um
      - p_0_3_um_b
      - p_0_5_um
      - p_0_5_um_b
      - p_1_0_um
      - p_1_0_um_b
      - pm1_0_atm
      - pm1_0_atm_b
      - p_2_5_um
      - p_2_5_um_b
      - pm2_5_atm
      - pm2_5_atm_b
      - p_5_0_um
      - p_5_0_um_b
      - p_10_0_um
      - p_10_0_um_b
      - pm10_0_atm
      - pm10_0_atm_b
      - pm2.5_aqi
      - pm2.5_aqi_b
  - platform: template
        friendly_name: "PurpleAir Memory"
        value_template: '{{ states.sensor.purpleair.attributes["Mem"] }}'
        friendly_name: "PurpleAir ADC"
        value_template: '{{ states.sensor.purpleair.attributes["Adc"] }}'
        friendly_name: "PurpleAir Uptime"
        value_template: '{{ states.sensor.purpleair.attributes["uptime"] }}'
        unit_of_measurement: "seconds"
        friendly_name: "PurpleAir Memory Fragmentation"
        value_template: '{{ states.sensor.purpleair.attributes["memfrag"] }}'
        friendly_name: "PurpleAir Wifi RSSI"
        value_template: '{{ states.sensor.purpleair.attributes["rssi"] }}'
        friendly_name: "PurpleAir Temperature"
        value_template: '{{ states.sensor.purpleair.attributes["current_temp_f"] }}'
        unit_of_measurement: "°F"
        friendly_name: "PurpleAir Humidity"
        value_template: '{{ states.sensor.purpleair.attributes["current_humidity"] }}'
        unit_of_measurement: "%"
        friendly_name: "PurpleAir Dewpoint"
        value_template: '{{ states.sensor.purpleair.attributes["current_dewpoint_f"] }}'
        unit_of_measurement: "°F"
        friendly_name: "PurpleAir Pressure"
        value_template: '{{ states.sensor.purpleair.attributes["pressure"] }}'
        unit_of_measurement: "mbar"
        friendly_name: "PurpleAir AirQuality A"
        value_template: '{{ states.sensor.purpleair.attributes["pm2.5_aqi"] }}'
        unit_of_measurement: "AQI"
        friendly_name: "PurpleAir AirQuality B"
        value_template: '{{ states.sensor.purpleair.attributes["pm2.5_aqi_b"] }}'
        unit_of_measurement: "AQI"
        friendly_name: "PurpleAir .3um Partical Count A"
        value_template: '{{ states.sensor.purpleair.attributes["p_0_3_um"] }}'
        unit_of_measurement: "um/dl"
        friendly_name: "PurpleAir .3um Partical Count B"
        value_template: '{{ states.sensor.purpleair.attributes["p_0_3_um_b"] }}'
        unit_of_measurement: "um/dl"
        friendly_name: "PurpleAir .5um Partical Count A"
        value_template: '{{ states.sensor.purpleair.attributes["p_0_5_um"] }}'
        unit_of_measurement: "um/dl"
        friendly_name: "PurpleAir .5um Partical Count B"
        value_template: '{{ states.sensor.purpleair.attributes["p_0_5_um_b"] }}'
        unit_of_measurement: "um/dl"
        friendly_name: "PurpleAir 1.0um Partical Count A"
        value_template: '{{ states.sensor.purpleair.attributes["p_1_0_um"] }}'
        unit_of_measurement: "um/dl"
        friendly_name: "PurpleAir 1.0um Partical Count B"
        value_template: '{{ states.sensor.purpleair.attributes["p_1_0_um_b"] }}'
        unit_of_measurement: "um/dl"
        friendly_name: "PurpleAir 2.5um Partical Count A"
        value_template: '{{ states.sensor.purpleair.attributes["p_2_5_um"] }}'
        unit_of_measurement: "um/dl"
        friendly_name: "PurpleAir 2.5um Partical Count B"
        value_template: '{{ states.sensor.purpleair.attributes["p_2_5_um_b"] }}'
        unit_of_measurement: "um/dl"
        friendly_name: "PurpleAir 5.0um Partical Count A"
        value_template: '{{ states.sensor.purpleair.attributes["p_5_0_um"] }}'
        unit_of_measurement: "um/dl"
        friendly_name: "PurpleAir 5.0um Partical Count B"
        value_template: '{{ states.sensor.purpleair.attributes["p_5_0_um_b"] }}'
        unit_of_measurement: "um/dl"
        friendly_name: "PurpleAir 10.0um Partical Count A"
        value_template: '{{ states.sensor.purpleair.attributes["p_10_0_um"] }}'
        unit_of_measurement: "um/dl"
        friendly_name: "PurpleAir 10.0um Partical Count B"
        value_template: '{{ states.sensor.purpleair.attributes["p_10_0_um_b"] }}'
        unit_of_measurement: "um/dl"
        friendly_name: "PurpleAir 10.0um Mass A"
        value_template: '{{ states.sensor.purpleair.attributes["pm10_0_atm"] }}'
        unit_of_measurement: "ug/m3"
        friendly_name: "PurpleAir 10.0um Mass B"
        value_template: '{{ states.sensor.purpleair.attributes["pm10_0_atm_b"] }}'
        unit_of_measurement: "ug/m3"
        friendly_name: "PurpleAir 1.0um Mass A"
        value_template: '{{ states.sensor.purpleair.attributes["pm1_0_atm"] }}'
        unit_of_measurement: "ug/m3"
        friendly_name: "PurpleAir 1.0um Mass B"
        value_template: '{{ states.sensor.purpleair.attributes["pm1_0_atm_b"] }}'
        unit_of_measurement: "ug/m3"
        friendly_name: "PurpleAir 2.5um Mass A"
        value_template: '{{ states.sensor.purpleair.attributes["pm2_5_atm"] }}'
        unit_of_measurement: "ug/m3"
        friendly_name: "PurpleAir 2.5um Mass B"
        value_template: '{{ states.sensor.purpleair.attributes["pm2_5_atm_b"] }}'
        unit_of_measurement: "ug/m3"

I did something similar, but use Node Red running against the local API:

[{"id":"7a752971.c61c08","type":"tab","label":"Purple Air","disabled":false,"info":""},{"id":"fa0d0904.ff7658","type":"inject","z":"7a752971.c61c08","name":"Poll every minute","topic":"","payload":"","payloadType":"date","repeat":"60","crontab":"","once":true,"onceDelay":0.1,"x":141,"y":406,"wires":[["5d3ed5bb.302cbc"]]},{"id":"5d3ed5bb.302cbc","type":"http request","z":"7a752971.c61c08","name":"Get measurements","method":"GET","ret":"obj","paytoqs":false,"url":"","tls":"","persist":false,"proxy":"","authType":"","x":410,"y":406,"wires":[["d04a0995.0eafc8","527d4cd7.c639a4","e9bb02fd.ec1f6","a4e7f11b.763d4"]]},{"id":"d04a0995.0eafc8","type":"unit-converter","z":"7a752971.c61c08","category":"temperature","inputUnit":"F","outputUnit":"C","inputField":"payload.current_temp_f","outputField":"payload","inputFieldType":"msg","outputFieldType":"msg","name":"F to C","x":630,"y":280,"wires":[["b4006d9a.103d8"]]},{"id":"e9bb02fd.ec1f6","type":"ha-entity","z":"7a752971.c61c08","name":"Atmospheric Pressure","server":"421dff97.2edf9","version":1,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":"Purple Air Pressure"},{"property":"device_class","value":"pressure"},{"property":"icon","value":""},{"property":"unit_of_measurement","value":"hPa"}],"state":"payload.pressure","stateType":"msg","attributes":[],"resend":true,"outputLocation":"","outputLocationType":"none","inputOverride":"allow","x":1080,"y":404,"wires":[[]]},{"id":"527d4cd7.c639a4","type":"unit-converter","z":"7a752971.c61c08","category":"temperature","inputUnit":"F","outputUnit":"C","inputField":"payload.current_dewpoint_f","outputField":"payload","inputFieldType":"msg","outputFieldType":"msg","name":"F to C","x":630,"y":340,"wires":[["9d6a1a8c.fb73d8"]]},{"id":"17c43b58.ec8cc5","type":"ha-entity","z":"7a752971.c61c08","name":"Outside Temperature","server":"421dff97.2edf9","version":1,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":"Purple Air Temperature"},{"property":"device_class","value":"temperature"},{"property":"icon","value":""},{"property":"unit_of_measurement","value":"°C"}],"state":"payload","stateType":"msg","attributes":[],"resend":true,"outputLocation":"","outputLocationType":"none","inputOverride":"allow","x":1080,"y":280,"wires":[[]]},{"id":"6f267455.cf1b8c","type":"ha-entity","z":"7a752971.c61c08","name":"Dewpoint","server":"421dff97.2edf9","version":1,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":"Purple Air Dewpoint"},{"property":"device_class","value":"temperature"},{"property":"icon","value":""},{"property":"unit_of_measurement","value":"°C"}],"state":"payload","stateType":"msg","attributes":[],"resend":true,"outputLocation":"","outputLocationType":"none","inputOverride":"allow","x":1040,"y":340,"wires":[[]]},{"id":"b4006d9a.103d8","type":"function","z":"7a752971.c61c08","name":"Single Decimal Place","func":"var x = Number(msg.payload)\n\nmsg.payload = x.toFixed(1)\nreturn msg;","outputs":1,"noerr":0,"x":820,"y":280,"wires":[["17c43b58.ec8cc5"]]},{"id":"9d6a1a8c.fb73d8","type":"function","z":"7a752971.c61c08","name":"Single Decimal Place","func":"var x = Number(msg.payload)\n\nmsg.payload = x.toFixed(1)\nreturn msg;","outputs":1,"noerr":0,"x":820,"y":340,"wires":[["6f267455.cf1b8c"]]},{"id":"a4e7f11b.763d4","type":"function","z":"7a752971.c61c08","name":"PM 2.5 AQI","func":"var x = Number(msg.payload[\"pm2.5_aqi\"])\nmsg.payload = x.toFixed(0)\nreturn msg;","outputs":1,"noerr":0,"x":650,"y":460,"wires":[["e5fb9dd8.358ad"]]},{"id":"ad11ab29.8ba6f8","type":"ha-entity","z":"7a752971.c61c08","name":"AQI 2.5","server":"421dff97.2edf9","version":1,"debugenabled":false,"outputs":1,"entityType":"sensor","config":[{"property":"name","value":"Purple Air AQI 2.5"},{"property":"device_class","value":""},{"property":"icon","value":""},{"property":"unit_of_measurement","value":"AQI"}],"state":"payload","stateType":"msg","attributes":[],"resend":true,"outputLocation":"","outputLocationType":"none","inputOverride":"allow","x":1040,"y":460,"wires":[[]]},{"id":"e5fb9dd8.358ad","type":"function","z":"7a752971.c61c08","name":"Moving Average","func":"// determines the average of all payload values passed in \n// over the specified time range (10 minutes)\nconst range = 10 * 60 * 1000;   // window time millisecs\nlet buffer = context.get('buffer') || [];\nlet total = context.get('total') || 0;   // the accumulated total so far\n\nlet now = new Date();\nlet value = Number(msg.payload);\n// remove any samples that are too old\nwhile (buffer[0] && buffer[0].timestamp < now - range) {\n    // remove oldest sample from array and total\n    node.warn(`removing oldest ${buffer[0].timestamp}`);\n    total -= buffer[0].value;\n    buffer.shift();\n}\n// add the new sample to the end\nbuffer.push({timestamp: now, value: value});\ntotal += value;\n\ncontext.set('buffer', buffer);\ncontext.set('total', total);\n\nmsg.payload = (total/buffer.length).toFixed(0);\n// uncommnet the following to see the buffer status in real time\n//node.warn(`length: ${buffer.length}, total: ${total}, average: ${msg.payload}`);\nreturn msg;","outputs":1,"noerr":0,"x":840,"y":460,"wires":[["ad11ab29.8ba6f8"]]},{"id":"421dff97.2edf9","type":"server","z":"","name":"Home Assistant","legacy":false,"addon":true,"rejectUnauthorizedCerts":true,"ha_boolean":"y|yes|true|on|home|open","connectionDelay":true,"cacheJson":true}]

I used the Node Red Companion Component for node-red-contrib-home-assistant-websocket from HACS to make creating the sensors easy.

1 Like

I have two sensors, one indoors and outdoors, and each sensor has a primary and secondary sensor.
The script uses just the primary, some discussion suggests to average the pri and sec sensors, or display both, like the map does.

Is there an easy way to convert the rest code to an easier integration, i.e. without copy and pasting the code 4 times?

Thanks for this recipe! It’s pretty good!

I noticed the temperature seemed inaccurate, and I noticed that purpleair website seems to do a transformation on the temperature, it’s noted in deep in the FAQ - https://www2.purpleair.com/community/faq#!hc-primary-and-secondary-data-header - you should subtract 8 from the temp_f, so if the json shows e.g. 76, purpleair shows 68, and the temperature outside is probably closer to 68 than 76.