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:
- Receive and validate sensor data frames using prefix detection, length field parsing, and checksum verification
- Parse all PM concentration values (PM1.0, PM2.5, PM10) and particle counts from the binary payload
- Send alerts via Discord, ntfy, and email when PM2.5 exceeds 75 ug/m3
- Send recovery notifications when PM2.5 drops below 35 ug/m3
- Use hysteresis to prevent repeated notifications when values fluctuate near a threshold
PMS9103M Frame Format
| Field | Size | Description |
|---|---|---|
| Prefix | 2 bytes | 0x42 0x4D ("BM") |
| Length | 2 bytes | uint16 big-endian, length of payload + checksum |
| Payload | 26 bytes | Sensor data (13 x uint16 big-endian values) |
| Checksum | 2 bytes | Sum16 big-endian over prefix + length + payload |
PMS9103M Payload Structure
All values are uint16 big-endian:
| Byte Offset | Description |
|---|---|
| 0-1 | PM1.0 concentration (CF=1) in ug/m3 |
| 2-3 | PM2.5 concentration (CF=1) in ug/m3 |
| 4-5 | PM10 concentration (CF=1) in ug/m3 |
| 6-7 | PM1.0 concentration (atmospheric) in ug/m3 |
| 8-9 | PM2.5 concentration (atmospheric) in ug/m3 |
| 10-11 | PM10 concentration (atmospheric) in ug/m3 |
| 12-13 | Particles >0.3um per 0.1L air |
| 14-15 | Particles >0.5um per 0.1L air |
| 16-17 | Particles >1.0um per 0.1L air |
| 18-19 | Particles >2.5um per 0.1L air |
| 20-21 | Particles >5.0um per 0.1L air |
| 22-23 | Particles >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
| Parameter | Value | Description |
|---|---|---|
frame_codec_prefix | 42 4d | PMS "BM" start bytes |
frame_codec_length_mode | u16_be | 2-byte big-endian length field |
frame_codec_length_meaning | payload_checksum | Length covers payload + checksum bytes |
frame_codec_checksum_algo | sum16_be | Sum16 big-endian checksum |
frame_codec_checksum_scope | prefix_header_length_payload | Checksum 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


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 (
_asyncsuffix) 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.