Skip to main content

PMS9103M Air Quality Monitor with Multi-Channel Notifications

Overview

This example demonstrates how to parse data from a PMS9103M particulate matter sensor and send alerts via multiple notification channels when PM2.5 levels exceed safe thresholds. It showcases CycBox's capabilities for:

  • Frame-based protocol decoding using the built-in frame codec with prefix, length field, and checksum validation
  • Binary payload parsing — extracting multiple uint16 big-endian values from raw sensor data
  • Multi-channel notifications — sending alerts simultaneously via Discord webhook, ntfy push notification, and SMTP email
  • Hysteresis-based alerting — using separate high/low thresholds to avoid notification flapping

This is a common pattern in environmental monitoring where air quality data needs to trigger real-time alerts across multiple communication channels.

Scenario

A PMS9103M particulate matter sensor is connected via serial port (e.g., /dev/ttyUSB0) and continuously streams data frames at 9600 baud. The requirements are:

  1. Receive and validate sensor data frames using prefix detection, length field parsing, and checksum verification
  2. Parse all PM concentration values (PM1.0, PM2.5, PM10) and particle counts from the binary payload
  3. Send alerts via Discord, ntfy, and email when PM2.5 exceeds 75 ug/m3
  4. Send recovery notifications when PM2.5 drops below 35 ug/m3
  5. Use hysteresis to prevent repeated notifications when values fluctuate near a threshold

PMS9103M Frame Format

FieldSizeDescription
Prefix2 bytes0x42 0x4D ("BM")
Length2 bytesuint16 big-endian, length of payload + checksum
Payload26 bytesSensor data (13 x uint16 big-endian values)
Checksum2 bytesSum16 big-endian over prefix + length + payload

PMS9103M Payload Structure

All values are uint16 big-endian:

Byte OffsetDescription
0-1PM1.0 concentration (CF=1) in ug/m3
2-3PM2.5 concentration (CF=1) in ug/m3
4-5PM10 concentration (CF=1) in ug/m3
6-7PM1.0 concentration (atmospheric) in ug/m3
8-9PM2.5 concentration (atmospheric) in ug/m3
10-11PM10 concentration (atmospheric) in ug/m3
12-13Particles >0.3um per 0.1L air
14-15Particles >0.5um per 0.1L air
16-17Particles >1.0um per 0.1L air
18-19Particles >2.5um per 0.1L air
20-21Particles >5.0um per 0.1L air
22-23Particles >10um per 0.1L air

CF=1 values are factory-calibrated standard particle concentrations. Atmospheric values are corrected for ambient conditions and are typically used for air quality assessment.

Configuration

CycBox is configured with a single serial connection using the frame codec to handle the PMS9103M binary protocol:

{
"version": "1.8.1",
"name": "PMS9103M Air Quality Monitor with Notifications",
"description": "Parses PMS9103M sensor data and sends PM2.5 alerts via Discord, ntfy, and SMTP",
"configs": [
{
"app": {
"app_transport": "serial",
"app_codec": "frame_codec",
"app_transformer": "disable",
"app_encoding": "UTF-8"
},
"serial": {
"serial_port": "/dev/ttyUSB0",
"serial_baud_rate": 9600,
"serial_data_bits": 8,
"serial_parity": "none",
"serial_stop_bits": "1",
"serial_flow_control": "none"
},
"frame_codec": {
"frame_codec_prefix": "42 4d",
"frame_codec_header_size": 0,
"frame_codec_tailer_length": 0,
"frame_codec_suffix": "",
"frame_codec_length_mode": "u16_be",
"frame_codec_fixed_payload_size": 32,
"frame_codec_length_meaning": "payload_checksum",
"frame_codec_checksum_algo": "sum16_be",
"frame_codec_checksum_scope": "prefix_header_length_payload"
}
}
]
}

Frame Codec Settings

ParameterValueDescription
frame_codec_prefix42 4dPMS "BM" start bytes
frame_codec_length_modeu16_be2-byte big-endian length field
frame_codec_length_meaningpayload_checksumLength covers payload + checksum bytes
frame_codec_checksum_algosum16_beSum16 big-endian checksum
frame_codec_checksum_scopeprefix_header_length_payloadChecksum covers entire frame except checksum itself

The frame codec handles all framing automatically — prefix detection, length-based extraction, and checksum validation. The Lua script receives only the validated 26-byte payload.

Lua Script Logic

The script parses sensor data in on_receive() and uses hysteresis-based threshold checking to trigger notifications.

Notification Configuration

-- PM2.5 thresholds (ug/m3)
local PM25_HIGH_THRESHOLD = 75 -- Alert when PM2.5 exceeds this value
local PM25_LOW_THRESHOLD = 35 -- Recovery when PM2.5 drops below this value

-- Discord webhook
local DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN"
local DISCORD_USERNAME = "CycBox Air Monitor"

-- ntfy push notification
local NTFY_TOPIC = "cycbox_air_quality"

-- SMTP email
local SMTP_CONFIG = {
server = "smtp.gmail.com",
port = 587,
tls = "starttls",
username = "your-email@gmail.com",
password = "your-app-password",
from = "your-email@gmail.com",
to = "recipient@example.com",
}

How It Works

1. Payload Parsing (on_receive)

function on_receive()
local payload = message.payload

if #payload ~= 26 then
return false
end

-- Parse PM concentrations (CF=1) in ug/m3
local pm1_0_cf1 = read_u16_be(payload, 1)
local pm2_5_cf1 = read_u16_be(payload, 3)
local pm10_cf1 = read_u16_be(payload, 5)

-- Parse PM concentrations (atmospheric) in ug/m3
local pm1_0_atm = read_u16_be(payload, 7)
local pm2_5_atm = read_u16_be(payload, 9)
local pm10_atm = read_u16_be(payload, 11)

-- Parse particle counts per 0.1L air
local particles_0_3um = read_u16_be(payload, 13)
local particles_0_5um = read_u16_be(payload, 15)
-- ... (6 particle size bins total)
end
  • Validates payload length (26 bytes expected after frame codec strips prefix, length, and checksum)
  • Extracts all 13 uint16 big-endian values using read_u16_be(payload, offset)
  • CF=1 values are factory-calibrated; atmospheric values are environment-corrected

2. UI Charting

    message:add_int_value("PM1.0-CF1", pm1_0_cf1)
message:add_int_value("PM2.5-CF1", pm2_5_cf1)
message:add_int_value("PM10-CF1", pm10_cf1)
message:add_int_value("PM2.5-ATM", pm2_5_atm)
-- ... all 12 values added for real-time charting
  • All parsed values are added to message metadata via message:add_int_value() for real-time UI charting

Air Quality Data

Air Quality Chart

3. Hysteresis-Based Alerting

local alert_state = "normal"

if alert_state == "normal" and pm2_5_atm > PM25_HIGH_THRESHOLD then
alert_state = "alert"
send_high_alert(pm2_5_atm)
elseif alert_state == "alert" and pm2_5_atm < PM25_LOW_THRESHOLD then
alert_state = "normal"
send_recovery_alert(pm2_5_atm)
end
  • Uses two thresholds (high=75, low=35) to create a hysteresis band
  • Alert triggers only once when PM2.5 rises above 75 ug/m3
  • Recovery triggers only once when PM2.5 drops below 35 ug/m3
  • No repeated notifications while the value stays between the two thresholds

4. Multi-Channel Notifications

Each alert is sent simultaneously via three channels:

-- Discord webhook
discord_send_async(DISCORD_WEBHOOK_URL, msg, DISCORD_USERNAME, nil)

-- ntfy push notification
ntfy_send_async({
topic = NTFY_TOPIC,
message = msg,
title = "PM2.5 High Alert",
priority = "high",
tags = "warning,skull",
})

-- SMTP email
smtp_send_async({
server = SMTP_CONFIG.server,
port = SMTP_CONFIG.port,
tls = SMTP_CONFIG.tls,
username = SMTP_CONFIG.username,
password = SMTP_CONFIG.password,
from = SMTP_CONFIG.from,
to = SMTP_CONFIG.to,
subject = string.format("PM2.5 Alert: %d μg/m³", pm2_5_value),
text = msg,
})
  • All three notifications are sent asynchronously (_async suffix) so they don't block sensor data processing
  • Discord uses a webhook URL with a custom username
  • ntfy supports priority levels and emoji tags
  • SMTP supports STARTTLS for secure email delivery

Alert Flow Diagram

PM2.5 value
|
| ──── 75 ug/m3 ──── HIGH THRESHOLD ──── trigger alert ────→ "alert" state
| │
| (no notifications in this band) │
| │
| ──── 35 ug/m3 ──── LOW THRESHOLD ───── trigger recovery ──→ "normal" state
|

Notification Examples

High alert message:

PM2.5 ALERT: 82 ug/m3 (threshold: 75 ug/m3). Air quality is unhealthy!

Recovery message:

PM2.5 RECOVERY: 28 ug/m3 (threshold: 35 ug/m3). Air quality restored to normal.

View Full example script on GitHub.