Purple Air webhook-based integration

Not sure if this is really a “custom component”, but I thought that other people might find this useful. I recently got a Purple Air outdoor sensor and wanted to pull the data into Home Assistant. When I ran out of tokens for the Purple Air API, I almost reached out about getting additional tokens, but instead decided to look into the custom “data processor” configuration.

I found a copy of what the data processor payload looks like on the Purple Air Discourse instance and used that to write the following configuration:

template:
  - unique_id: Purple Air
    trigger:
      - platform: webhook
        webhook_id: !secret purple_air_webhook_id
        allowed_methods: [POST]
        local_only: true
    # List of sensors taken from Purple Air integration
    # Sample payload at https://community.purpleair.com/t/custom-data-processor-schema/2214
    sensor:
      - name: Purple Air Temperature
        unique_id: Temperature
        device_class: temperature
        unit_of_measurement: °F
        state_class: measurement
        state: "{{ trigger.json.current_temp_f }}"
      - name: Purple Air Pressure
        unique_id: Pressure
        device_class: pressure
        unit_of_measurement: mbar
        state_class: measurement
        state: "{{ trigger.json.pressure }}"
      - name: Purple Air Humidity
        device_class: humidity
        unit_of_measurement: "%"
        state_class: measurement
        state: "{{ trigger.json.current_humidity }}"
      - name: Purple Air PM0.3 count concentration
        icon: mdi:blur
        unit_of_measurement: particles/100mL
        state_class: measurement
        state: "{{ (trigger.json.p_0_3_um + trigger.json.p_0_3_um_b) / 2 }}"
      - name: Purple Air PM0.5 count concentration
        icon: mdi:blur
        unit_of_measurement: particles/100mL
        state_class: measurement
        state: "{{ (trigger.json.p_0_5_um + trigger.json.p_0_5_um_b) / 2 }}"
      - name: Purple Air PM1.0 count concentration
        icon: mdi:blur
        unit_of_measurement: particles/100mL
        state_class: measurement
        state: "{{ (trigger.json.p_1_0_um + trigger.json.p_1_0_um_b) / 2 }}"
      - name: Purple Air PM2.5 count concentration
        icon: mdi:blur
        unit_of_measurement: particles/100mL
        state_class: measurement
        state: "{{ (trigger.json.p_2_5_um + trigger.json.p_2_5_um_b) / 2 }}"
      - name: Purple Air PM5.0 count concentration
        icon: mdi:blur
        unit_of_measurement: particles/100mL
        state_class: measurement
        state: "{{ (trigger.json.p_5_0_um + trigger.json.p_5_0_um_b) / 2 }}"
      - name: Purple Air PM1.0 count concentration
        icon: mdi:blur
        unit_of_measurement: particles/100mL
        state_class: measurement
        state: "{{ (trigger.json.p_10_0_um + trigger.json.p_10_0_um_b) / 2 }}"
      - name: Purple Air PM1.0 mass concentration
        device_class: pm1
        unit_of_measurement: µg/m³
        state_class: measurement
        state: "{{ (trigger.json.pm1_0_atm + trigger.json.pm1_0_atm_b) / 2 }}"
      - name: Purple Air PM2.5 mass concentration
        device_class: pm25
        unit_of_measurement: µg/m³
        state_class: measurement
        state: "{{ (trigger.json.pm2_5_atm + trigger.json.pm2_5_atm_b) / 2 }}"
      - name: Purple Air PM10 mass concentration
        device_class: pm10
        unit_of_measurement: µg/m³
        state_class: measurement
        state: "{{ (trigger.json.pm10_0_atm + trigger.json.pm10_0_atm_b) / 2 }}"
      - name: Purple Air RSSI
        device_class: signal_strength
        unit_of_measurement: dBm
        state: "{{ trigger.json.rssi }}"
      - name: Purple Air Uptime
        device_class: duration
        unit_of_measurement: s
        state_class: total_increasing
        state: "{{ trigger.json.uptime }}"
      - name: Purple Air AQI
        device_class: aqi
        state_class: measurement
        state: >-
          {% macro aqi(val, val_l, val_h, aqi_l, aqi_h) -%}
            {{(((aqi_h-aqi_l)/(val_h - val_l) * (val - val_l)) + aqi_l)|round(0)}}
          {%- endmacro %}
          {% set v = (trigger.json.pm2_5_atm + trigger.json.pm2_5_atm_b) / 2 %}
          {% if v <= 12.0 %}
            {{aqi(v, 0, 12.0, 0, 50)}}
          {% elif 12.0 < v <= 35.4 %}
            {{aqi(v, 12.1, 35.4, 51, 100)}}
          {% elif 35.4 < v <= 55.4 %}
            {{aqi(v, 35.5, 55.4, 101, 150)}}
          {% elif 55.4 < v <= 150.5 %}
            {{aqi(v, 55.5, 150.4, 151, 200)}}
          {% elif 150.4 < v <= 250.4 %}
            {{aqi(v, 150.4, 250.4, 201, 300)}}
          {% elif 250.5 < v <= 500.4 %}
            {{aqi(v, 250.5, 500.4, 301, 500)}}
          {% else %}
            500
          {% endif %}

It’s not exactly the same as what I’d get back from the official API, but it’s pretty close. And because the Purple Air sensor is sending the requests (rather than some centralized service), I don’t have to rely on cloud access to get up to date data.

And for some bonus configuration, here’s a Lovelace gauge configuration to show it, complete with the EPA AQI ranges:

type: gauge
entity: sensor.purple_air_aqi
name: Outdoor AQI
max: 500
needle: true
segments:
  - from: 0
    color: var(--success-color)
  - from: 51
    color: yellow
  - from: 101
    color: orange
  - from: 151
    color: var(--error-color)
  - from: 201
    color: purple
  - from: 301
    color: maroon