PurpleAir Air Quality Sensor

Hopefully you got something working for yourself already, but in case not: the easiest way is just to add in @gibwar’s custom component, either manually or via HACS. Since you’re interested for wildfire smoke purposes, you’ll probably want to apply the LRAPA correction to the AQI, which will be done automatically if you use the minor fork I mentioned earlier. Note that the raw pm2.5 readings aren’t adjusted, just the resulting AQI, but that’s probably all you need.

And yeah, just paste in that link when adding the integration via the config flow. @gibwar also made it super easy to add multiple instances that way, which makes it easy to make a sensor for getting an average of PurpleAir sensors near you.

I’m unable to install the integration via the gui. I added & installed the github url to HACS repo. I see dir homeassistant/custom_components/purpleair
I restarted HA, more than once.
But when I go to Configuration > Integrations > (+)
I don’t see anything containing “pur” (screenshot):

What am I missing? I have added lots of other integrations via the gui (and yaml) before.

HA ver 0.114.4
Also tried manually moving the purpleair dir from the .zip file into my custom_components dir and restarting HA. Same issue.

URL i added to HACS as a repository:

For now, I just pasted the yaml from Post #1 in this thread into configuration.yaml and changed the url

1 Like

I wish I’d read the entire thread before I went hunting to find the ID of the station nearest to me. I basically tried random IDs increasing from the 37027 one earlier in this thread and looked at the create time in the json result. I knew the sensor I wanted was created 8/31/20 and I knew the name from the purple air map. I managed to work my way up in leaps and found 63835 created 8/31/20. A little script later searching the ones numerically up and down where the create date was still 8/31 turned up the one I wanted.

FYI - 435 new IDs were created on 8/31 - most often in pairs as each PurpleAir device has two sensors, each with a different ID.

I thought I’d share my icon choices for the items that didn’t have anything specific. I’m curious what others may have used.
AQI

### customize.yaml
### AQI 
sensor.purpleair_description:
  icon: mdi:lungs
sensor.purpleair_aqi:
  icon: mdi:chemical-weapon
sensor.purpleair_pm25:
  icon: mdi:grain

For those that utilize Grafana:

{
  "annotations": {
    "list": [
      {
        "builtIn": 1,
        "datasource": "-- Grafana --",
        "enable": true,
        "hide": true,
        "iconColor": "rgba(0, 211, 255, 1)",
        "name": "Annotations & Alerts",
        "type": "dashboard"
      }
    ]
  },
  "editable": true,
  "gnetId": null,
  "graphTooltip": 0,
  "id": 1,
  "links": [],
  "panels": [
    {
      "datasource": "HomeAssistant",
      "description": "",
      "fieldConfig": {
        "defaults": {
          "custom": {},
          "mappings": [],
          "max": 500,
          "min": 0,
          "thresholds": {
            "mode": "absolute",
            "steps": [
              {
                "color": "green",
                "value": null
              },
              {
                "color": "green",
                "value": 0
              },
              {
                "color": "#EAB839",
                "value": 51
              },
              {
                "color": "orange",
                "value": 101
              },
              {
                "color": "red",
                "value": 151
              },
              {
                "color": "purple",
                "value": 201
              },
              {
                "color": "dark-purple",
                "value": 301
              }
            ]
          }
        },
        "overrides": []
      },
      "gridPos": {
        "h": 8,
        "w": 12,
        "x": 12,
        "y": 0
      },
      "id": 6,
      "options": {
        "orientation": "auto",
        "reduceOptions": {
          "calcs": [
            "lastNotNull"
          ],
          "fields": "",
          "values": false
        },
        "showThresholdLabels": false,
        "showThresholdMarkers": true
      },
      "pluginVersion": "7.2.0",
      "targets": [
        {
          "alias": "",
          "groupBy": [
            {
              "params": [
                "$__interval"
              ],
              "type": "time"
            },
            {
              "params": [
                "null"
              ],
              "type": "fill"
            }
          ],
          "measurement": "AQI",
          "orderByTime": "ASC",
          "policy": "default",
          "refId": "A",
          "resultFormat": "time_series",
          "select": [
            [
              {
                "params": [
                  "value"
                ],
                "type": "field"
              },
              {
                "params": [],
                "type": "last"
              }
            ]
          ],
          "tags": []
        }
      ],
      "timeFrom": null,
      "timeShift": null,
      "title": "Air Quaility",
      "transparent": true,
      "type": "gauge"
    },
    {
      "aliasColors": {
        "AQI": "blue"
      },
      "bars": false,
      "dashLength": 10,
      "dashes": false,
      "datasource": "HomeAssistant",
      "description": "",
      "fieldConfig": {
        "defaults": {
          "custom": {},
          "mappings": [],
          "max": 500,
          "min": 0,
          "thresholds": {
            "mode": "absolute",
            "steps": [
              {
                "color": "green",
                "value": null
              },
              {
                "color": "green",
                "value": 0
              },
              {
                "color": "#EAB839",
                "value": 51
              },
              {
                "color": "orange",
                "value": 101
              },
              {
                "color": "red",
                "value": 151
              },
              {
                "color": "purple",
                "value": 201
              },
              {
                "color": "dark-purple",
                "value": 301
              }
            ]
          }
        },
        "overrides": []
      },
      "fill": 1,
      "fillGradient": 0,
      "gridPos": {
        "h": 8,
        "w": 12,
        "x": 12,
        "y": 8
      },
      "hiddenSeries": false,
      "id": 7,
      "legend": {
        "avg": false,
        "current": false,
        "max": false,
        "min": false,
        "show": true,
        "total": false,
        "values": false
      },
      "lines": true,
      "linewidth": 1,
      "nullPointMode": "null",
      "options": {
        "alertThreshold": true
      },
      "percentage": false,
      "pluginVersion": "7.2.0",
      "pointradius": 2,
      "points": false,
      "renderer": "flot",
      "seriesOverrides": [],
      "spaceLength": 10,
      "stack": false,
      "steppedLine": false,
      "targets": [
        {
          "alias": "AQI",
          "groupBy": [
            {
              "params": [
                "$__interval"
              ],
              "type": "time"
            },
            {
              "params": [
                "none"
              ],
              "type": "fill"
            }
          ],
          "measurement": "AQI",
          "orderByTime": "ASC",
          "policy": "default",
          "refId": "A",
          "resultFormat": "time_series",
          "select": [
            [
              {
                "params": [
                  "value"
                ],
                "type": "field"
              },
              {
                "params": [],
                "type": "last"
              }
            ]
          ],
          "tags": []
        }
      ],
      "thresholds": [
        {
          "colorMode": "ok",
          "fill": false,
          "line": true,
          "op": "gt",
          "value": 0,
          "yaxis": "left"
        },
        {
          "colorMode": "custom",
          "fill": false,
          "fillColor": "rgba(51, 162, 229, 0.2)",
          "line": true,
          "lineColor": "#FADE2A",
          "op": "gt",
          "value": 51,
          "yaxis": "left"
        },
        {
          "colorMode": "custom",
          "fill": false,
          "fillColor": "rgba(51, 162, 229, 0.2)",
          "line": true,
          "lineColor": "#FF9830",
          "op": "gt",
          "value": 101,
          "yaxis": "left"
        },
        {
          "colorMode": "custom",
          "fill": false,
          "fillColor": "rgba(51, 162, 229, 0.2)",
          "line": true,
          "lineColor": "#F2495C",
          "op": "gt",
          "value": 151,
          "yaxis": "left"
        },
        {
          "colorMode": "custom",
          "fill": false,
          "fillColor": "rgba(51, 162, 229, 0.2)",
          "line": true,
          "lineColor": "#B877D9",
          "op": "gt",
          "value": 201,
          "yaxis": "left"
        },
        {
          "colorMode": "custom",
          "fill": false,
          "fillColor": "rgba(51, 162, 229, 0.2)",
          "line": true,
          "lineColor": "#8F3BB8",
          "op": "gt",
          "value": 301,
          "yaxis": "left"
        }
      ],
      "timeFrom": null,
      "timeRegions": [],
      "timeShift": null,
      "title": "Air Quaility",
      "tooltip": {
        "shared": true,
        "sort": 0,
        "value_type": "individual"
      },
      "transparent": true,
      "type": "graph",
      "xaxis": {
        "buckets": null,
        "mode": "time",
        "name": null,
        "show": true,
        "values": []
      },
      "yaxes": [
        {
          "format": "short",
          "label": null,
          "logBase": 1,
          "max": null,
          "min": null,
          "show": true
        },
        {
          "format": "short",
          "label": null,
          "logBase": 1,
          "max": null,
          "min": null,
          "show": true
        }
      ],
      "yaxis": {
        "align": false,
        "alignLevel": null
      }
    }
  ],
  "refresh": "5m",
  "schemaVersion": 26,
  "style": "dark",
  "tags": [],
  "templating": {
    "list": []
  },
  "time": {
    "from": "now-6h",
    "to": "now"
  },
  "timepicker": {},
  "timezone": "",
  "title": "Environmental",
  "uid": "hmhTkNOMk",
  "version": 10
}
2 Likes

How is the local API working for you?
I will be getting a PurpleAir device probably a PurpleAir PA-II-SD and a maybe a PurpleAir PA-I-Indoor.

I’m curious what model you have and if anyone else knows if the local API is the same for all the devices (at lease for the sensors that device contains).

Just wanted to share how I’m using the PurpleAir sensors. Like many of you I’m just using the REST sensor. My neighbor has an outdoor PurpleAir so I use the public API to grab that data. I then bought an indoor PurpleAir and use the local API to get that data.

For the outdoor sensor I use different data than most. I found using the PM2_5Value, which is using the real-time data, made the sensor too “wobbly.” Since I had actions tied to it’s value weird things would happen when there was a sudden change in the data. I found buried in the json data the 10 minute average of the sensor. Using that helped smooth out the data. Unfortunately, the data is not proper json. Its actually a string. But with a bit of string parsing in the template I was able to get at the value. I also decided to assign the AQI value to a variable in the template, for two reasons, to make it more readable, and easier to switch back to the real-time value if I wanted.

- platform: rest
  name: 'PurpleAir'

  resource: https://www.purpleair.com/json?show=19269
  scan_interval: 60
  force_update: true

  value_template: >
    {% macro calcAQI(Cp, Ih, Il, BPh, BPl) -%}
      {{ (((Ih - Il)/(BPh - BPl)) * (Cp - BPl) + Il)|round }}   
    {%- endmacro %}
    # Use the 10m average of PM25 and assign it to variable
    {% set pm25 = value_json.results[0].Stats.split(',')[1].split(':')[1] %}
    {% if (pm25|float) > 1000 %}
      invalid
    {% elif (pm25|float) > 350.5 %}
      {{ calcAQI((pm25|float), 500.0, 401.0, 500.0, 350.5) }}
    {% elif (pm25|float) > 250.5 %}
      {{ calcAQI((pm25|float), 400.0, 301.0, 350.4, 250.5) }}
    {% elif (pm25|float) > 150.5 %}
      {{ calcAQI((pm25|float), 300.0, 201.0, 250.4, 150.5) }}
    {% elif (pm25|float) > 55.5 %}
      {{ calcAQI((pm25|float), 200.0, 151.0, 150.4, 55.5) }}
    {% elif (pm25|float) > 35.5 %}
      {{ calcAQI((pm25|float), 150.0, 101.0, 55.4, 35.5) }}
    {% elif (pm25|float) > 12.1 %}
      {{ calcAQI((pm25|float), 100.0, 51.0, 35.4, 12.1) }}
    {% elif (pm25|float) >= 0.0 %}
      {{ calcAQI((pm25|float), 50.0, 0.0, 12.0, 0.0) }}
    {% else %}
      invalid
    {% endif %}

  unit_of_measurement: "AQI"
  json_attributes:
    - Stats
    - PM2_5Value
    - LastSeen
    - LastUpdateCheck
    - Label
  json_attributes_path: "$.results[0]"

For the indoor sensor that data that is returned is the from the local API is different. I have access to the AQI directly but it is a real-time value as far as I can tell. PurpleAir says they are updating the FAQ to include details of the local API, so hopefully I can get a better definition of what the value actually is.

- platform: rest
  name: 'AQI Indoors'
  resource: http://<Internal IP>/json
  scan_interval: 30
  force_update: true

  value_template: '{{ value_json["pm2.5_aqi"] }}'
  unit_of_measurement: "AQI"

I also tied the indoor AQI to the fan of the air handler in my HVAC system. It also gave me a use case for the new wait_for_trigger action options for automation. It’s not working quite right. I think it has to do with getting to the timeout value. But that is a discussion for another thread.

- alias: HVAC Dirty Air Cycle
  trigger:
    platform: numeric_state
    entity_id: sensor.aqi_indoors
    above: 50
  condition:
    - condition: state
      entity_id: fan.whole_house
      state: 'off'
    - condition: numeric_state
      entity_id: sensor.open_window_count
      below: 1
  action:
    - service: climate.set_fan_mode
      data:
        entity_id: climate.my_ecobee
        fan_mode: 'on'
    - wait_for_trigger:
        - platform: numeric_state
          entity_id: sensor.aqi_indoors
          below: 20
      timeout:
        minutes: 60
      continue_on_timeout: true
    - service: climate.set_fan_mode
      data:
        entity_id: climate.my_ecobee
        fan_mode: 'auto'
3 Likes

This is the information I wanted; thank you.
I was planing to do something similar for my exhaust fans whether or not to run based on a comparison of indoor and outdoor air quality.
I currently have one of my three exhaust fans set to an automation (in HA) based on humidity.
I still have my ‘main’ exhaust fan set on a every 6 hour schedule, this is the one I will hamper based on air quality. I am waiting for my own PurpleAir before I implement since the outdoor one I am using is several miles away.

Notifications for Air Quality sent to mine and my wife’s phones.
All of this being based on the custom sensor from above.

- id: '1602015312411'
  alias: Air Quality Notification 1 Good
  description: ''
  trigger:
  - platform: state
    entity_id: sensor.purpleair_description
    to: Good
    for: 0:10:00
  condition:
  - condition: time
    before: '20:00:00'
    after: 07:00:00
  action:
  - service: notify.mobile_app_gphone
    data:
      message: Air Quaility is Good
  - service: notify.mobile_app_sm_g950u
    data:
      message: Air Quaility is Good
  mode: single

- id: '1602009421488'
  alias: Air Quality Notification 2 Moderate
  description: ''
  trigger:
  - platform: state
    entity_id: sensor.purpleair_description
    to: Moderate
    for: 0:10:00
  condition:
  - condition: time
    before: '20:00:00'
    after: 07:00:00
  action:
  - service: notify.mobile_app_gphone
    data:
      message: Air Quaility is Moderate
  - service: notify.mobile_app_sm_g950u
    data:
      message: Air Quaility is Moderate
  mode: single

- id: '1602017331248'
  alias: Air Quality Notification 3 Unhealthy for Sensitive Groups
  description: ''
  trigger:
  - platform: state
    entity_id: sensor.purpleair_description
    for: 0:10:00
    to: Unhealthy for Sensitive Groups
  condition:
  - condition: time
    before: '20:00:00'
    after: 07:00:00
  action:
  - service: notify.mobile_app_gphone
    data:
      message: Air Quaility is Unhealthy for Sensitive Groups
  - service: notify.mobile_app_sm_g950u
    data:
      message: Air Quaility is Unhealthy for Sensitive Groups
  mode: single

- id: '1602017478518'
  alias: Air Quality Notification 4 Unhealthy
  description: ''
  trigger:
  - platform: state
    entity_id: sensor.purpleair_description
    to: Unhealthy
    for: 0:10:00
  condition:
  - condition: time
    before: '20:00:00'
    after: 07:00:00
  action:
  - service: notify.mobile_app_gphone
    data:
      message: Air Quaility is Unhealthy
  - service: notify.mobile_app_sm_g950u
    data:
      message: Air Quaility is Unhealthy
  mode: single

- id: '1602017682704'
  alias: Air Quality Notification 5 Very Unhealthy
  description: ''
  trigger:
  - platform: state
    entity_id: sensor.purpleair_description
    to: Very Unhealthy
    for: 0:10:00
  condition:
  - condition: time
    before: '20:00:00'
    after: 07:00:00
  action:
  - service: notify.mobile_app_gphone
    data:
      message: Air Quaility is Very Unhealthy
  - service: notify.mobile_app_sm_g950u
    data:
      message: Air Quaility is Very Unhealthy
  mode: single

- id: '1602017729305'
  alias: Air Quality Notification 6 Hazardous
  description: ''
  trigger:
  - platform: state
    entity_id: sensor.purpleair_description
    to: Hazardous
    for: 0:10:00
  condition:
  - condition: time
    before: '20:00:00'
    after: 07:00:00
  action:
  - service: notify.mobile_app_gphone
    data:
      message: Air Quaility is Hazardous
  - service: notify.mobile_app_sm_g950u
    data:
      message: Air Quaility is Hazardous
  mode: single

- id: '1602035435658'
  alias: Air Quality Notification 7 Very Hazardous
  description: ''
  trigger:
  - platform: state
    entity_id: sensor.purpleair_description
    to: Very Hazardous
    for: 0:10:00
  condition:
  - condition: time
    before: '20:00:00'
    after: 07:00:00
  action:
  - service: notify.mobile_app_gphone
    data:
      message: Air Quaility is Very Hazardous
  - service: notify.mobile_app_sm_g950u
    data:
      message: Air Quaility is Very Hazardous
  mode: single

Try to reload the integrations page - I had the same problem, then purple showed up in the list after restart and browser reload.

1 Like

The internal JSON API appears to return the 120sec by default (?live=false). If you want real time, just add ?live=true

I noticed PurpleAir added a US EPA conversion to their list of available conversions. I find it better tracks the EPA ratings with wildfire smoke. Specifically LRAPA seems to reasonably match AirNow official sensors for lower smoke levels, but for extreme smoke levels (AirNow AQI 180+), LRAPA seems to come in much too low.

I might play around with switching to the PurpleAir US EPA conversion formula, or maybe update the component to provide multiple conversions at once.

Here’s the listed conversions with the formulas they use as of today 2020-10-10:

US EPA: Courtesy of the United States Environmental Protection Agency Office of Research and Development, correction equation from their US wide study validated for wildfire and woodsmoke. 0-250 ug/m3 range (>250 may underestimate true PM2.5):PM2.5 (µg/m³) = 0.534 x PA(cf_1) - 0.0844 x RH + 5.604

AQandU: Courtesy of the University of Utah, conversion factors from their study of the PA sensors during winter in Salt Lake City. Visit their web site. PM2.5 (µg/m³) = 0.778 x PA + 2.65

LRAPA: Courtesy of the Lane Regional Air Protection Agency, conversion factors from their study of the PA sensors. Visit their web site. 0 - 65 µg/m³ range:LRAPA PM2.5 (µg/m³) = 0.5 x PA (PM2.5 CF=ATM) – 0.66

Simplified … a little (all in one automation vice several),
I could not come up with a simple way to simply the state change without getting it for every value change in AQI.

- id: '1602015312411'
  alias: Air Quality Notification Change
  description: ''
  trigger:
  - platform: state
    entity_id: sensor.purpleair_description
    to: Good
    for: 0:10:00
  - platform: state
    entity_id: sensor.purpleair_description
    to: Moderate
    for: 0:10:00
  - platform: state
    entity_id: sensor.purpleair_description
    to: Unhealthy for Sensitive Groups
    for: 0:10:00
  - platform: state
    entity_id: sensor.purpleair_description
    for: 0:10:00
    to: Unhealthy
  - platform: state
    entity_id: sensor.purpleair_description
    for: 0:10:00
    to: Very Unhealthy
  - platform: state
    entity_id: sensor.purpleair_description
    for: 0:10:00
    to: Hazardous
  - platform: state
    entity_id: sensor.purpleair_description
    to: Very Hazardous
    for: 0:10:00
  condition:
  - condition: time
    before: '20:00:00'
    after: 07:00:00
  action:
  - service: notify.mobile_app_gphone
    data:
      message: Air Quaility is {{ states('sensor.purpleair_description') }} at AQI
        {{ states('sensor.purpleair_aqi') }}
  mode: single

Temp sensor is off by 8 degrees! Easy fix:


value_template: "{{ state_attr('sensor.purpleair','results')[0]['temp_f'] | int - 8}}"

I just recieved teh PurpleAir PA-I-Indoor sensor and set it up. The local api works great, it’s just a rest call to http://x.x.x.x/json?live=true

This is even more simplified for an automation. I mis-read the description for leaving state blank, and have verified that this works without extra notifications.
This will send a notification between the hours of 0700-2100 for any change in air quality (i.e. from Good to Moderate; the change must be for at least 10 minutes to prevent spurious notifications). I have a separate automation to send a notification at 0700 reporting the air quality at that time.

- id: '1602977146994'
  alias: Air Quality Notification Change (Simple)
  description: ''
  trigger:
  - platform: state
    entity_id: sensor.purpleair_description
    for: 0:10:00
  condition:
  - condition: time
    before: '21:00:00'
    after: 07:00:00
  action:
  - service: notify.mobile_app_gphone
    data:
      message: Air Quaility is {{ states('sensor.purpleair_description') }} at AQI
        {{ states('sensor.purpleair_aqi') }}
    mode: single

Thanks, now I am more confident in getting both an indoor and an outdoor sensor.

As much as possible I try to get things that have local control; it does not bother me if it has cloud access but I don’t like the things that require it. I would have gotten this anyway but I feel better knowing it is accessible locally.

@ozczecho could you give a little more detail.
I have tried the canvas gauge in the past and just tried again but have not been successful with it, that is why I went with an imported grafana graph.

I installed via HACS and tried to put in as both a manual card and a picture entry; but with no luck. I can only get a “No card type configured.” I am sure it is something simple, but it eludes me at this time.

I tried your example and also the examples from the github page, same result.
I tried on at least HA 115 and 116.

Hi @GlennHA,

I am currently on HA 0.115. I don’t use HACS - so I cannot help there.

I copied the source file canvas-gauge-card.js into /www/custom-lovelace

In my configuration.yaml, I have:

lovelace:
  mode: yaml
  resources:
     
    - url: /local/custom-lovelace/canvas-gauge-card.js?v=1.2
      type: module

And then

  - type: vertical-stack
    title: A stack of cards
    cards:
    - type: custom:canvas-gauge-card
      card_height: 200
      entity: sensor.purpleair
      name: AQI reading
      gauge:
        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

I hope that helps .