Skip to main content

Plantower PMS9003 Particle Sensor UART Debugging

The Plantower PMS9003 is a high-precision laser scattering particulate matter sensor designed for environmental monitoring. This report details the successful debugging session using CycBox to capture, decode, and control the sensor over a 3.3V UART interface.

Device under test

The Plantower PMS9003 (often identified as the PM2S-3 in customized variants) is a digital particle concentration sensor. It utilizes laser scattering technology to measure PM1.0, PM2.5, and PM10 concentrations in atmospheric environments.

  • Role in the system: Provides real-time air quality metrics, including particulate mass concentration ($\mu g/m^3$) and particle counts for various size bins ($>0.3\mu m$ to $>10\mu m$).
  • Electrical interface: 3.3V TTL UART for communication. Notably, it requires a 5V supply (VCC) to power the internal laser and fan, but all signal pins are limited to 3.3V.
  • Key specs:
    • Baud Rate: 9600 bps.
    • Active Current: $\le 100 mA$.
    • Response Time: $< 1 s$.
    • Warm-up Time: $\ge 30 s$ (required for fan stabilization).
  • Source documents: 824336484SS-盛世物联关于攀藤PMS9003颗粒物传感器的中文说明书-20180517.pdf.

Wire protocol and CycBox stack

The PMS9003 uses a structured framing protocol characterized by a fixed 2-byte header and a 16-bit summation checksum.

Transport and Codec

  • Transport: serial_port_transport configured at 9600 8N1 (None parity, 8 data bits, 1 stop bit).
  • Codec: structured_frame_codec. This codec is ideal for the PMS9003 as it handles the 0x42 4D sync bytes and dynamically calculates the sum16_be checksum based on the frame payload.

Protocol Detail

The table below maps the 32-byte active measurement frame used by the PMS9003 to its field offsets and units.

OffsetFieldTypeDescription
0HeaderconstFixed 0x42 4D
2Lengthu16beRemaining frame length (usually 28 bytes)
4PM1.0 (Std)u16beStandard Particle (CF=1), $\mu g/m^3$
6PM2.5 (Std)u16beStandard Particle (CF=1), $\mu g/m^3$
8PM10 (Std)u16beStandard Particle (CF=1), $\mu g/m^3$
10PM1.0 (Atmos)u16beAtmospheric Environment, $\mu g/m^3$
12PM2.5 (Atmos)u16beAtmospheric Environment, $\mu g/m^3$
14PM10 (Atmos)u16beAtmospheric Environment, $\mu g/m^3$
16-27Particle Countsu16be[6]Counts per 0.1L air for 0.3, 0.5, 1.0, 2.5, 5.0, 10 $\mu m$
28Versionu8Hardware version
29Error Codeu8Device status (0 = Normal)
30Checksumu16beSum of bytes 0 through 29

CycBox configuration walkthrough

The system was configured with three variants in the structured_frame_codec: measurement (RX), command (TX), and ack (RX).

Transport & Codec Config

[
{
"app": {
"app_transport": "serial_port_transport",
"app_codec": "structured_frame_codec",
"app_transformer": "disable_transformer",
"app_encoding": "UTF-8"
},
"serial_port_transport": {
"serial_port_transport_parity": "none",
"serial_port_transport_baud_rate": 9600,
"serial_port_transport_port": "/dev/ttyUSB0",
"serial_port_transport_data_bits": 8,
"serial_port_transport_stop_bits": "1",
"serial_port_transport_flow_control": "none"
},
"structured_frame_codec": {
"structured_frame_codec_schema": {
"variants": [
{ "name": "measurement", "direction": "rx", "fields": [...] },
{ "name": "command", "direction": "tx", "fields": [...] },
{ "name": "ack", "direction": "rx", "fields": [...] }
]
}
}
}
]

Structured Frame Codec Config Schema:

{
"schema_version": 1,
"variants": [
{
"name": "measurement",
"direction": "rx",
"description": "32-byte active/passive measurement frame (device -> host)",
"fields": [
{
"name": "header",
"kind": "const",
"role": "sync",
"bytes": "42 4d",
"size_adjust": 0
},
{
"name": "length",
"kind": "u16be",
"size_adjust": 0,
"description": "Remaining length (28 bytes)"
},
{
"name": "payload",
"kind": "bytes",
"role": "payload",
"size_from": "length",
"size_adjust": -2,
"description": "Concentration and particle count data"
},
{
"name": "checksum",
"kind": "u16be",
"role": "checksum",
"size_adjust": 0,
"algo": "sum16_be",
"scope": [
"header",
"length",
"payload"
],
"description": "Sum of all preceding bytes"
}
]
},
{
"name": "command",
"direction": "tx",
"description": "7-byte host command frame (host -> device)",
"fields": [
{
"name": "header",
"kind": "const",
"role": "sync",
"bytes": "42 4d",
"size_adjust": 0
},
{
"name": "cmd",
"kind": "u8",
"size_adjust": 0,
"description": "Command byte (e.g., 0xE2)"
},
{
"name": "datah",
"kind": "u8",
"size_adjust": 0,
"description": "Parameter high byte"
},
{
"name": "datal",
"kind": "u8",
"size_adjust": 0,
"description": "Parameter low byte"
},
{
"name": "checksum",
"kind": "u16be",
"role": "checksum",
"size_adjust": 0,
"algo": "sum16_be",
"scope": [
"header",
"cmd",
"datah",
"datal"
],
"description": "Sum of all preceding bytes"
}
]
},
{
"name": "ack",
"direction": "rx",
"description": "8-byte command acknowledgment frame (device -> host)",
"fields": [
{
"name": "header",
"kind": "const",
"role": "sync",
"bytes": "42 4d",
"size_adjust": 0
},
{
"name": "length",
"kind": "u16be",
"size_adjust": 0,
"description": "Remaining length (4 bytes)"
},
{
"name": "payload",
"kind": "bytes",
"role": "payload",
"size_from": "length",
"size_adjust": -2,
"description": "Command echo and status/mode"
},
{
"name": "checksum",
"kind": "u16be",
"role": "checksum",
"size_adjust": 0,
"algo": "sum16_be",
"scope": [
"header",
"length",
"payload"
],
"description": "Sum of all preceding bytes"
}
]
}
]
}

Lua Script

The following Lua script extracts concentrations and particle counts, using the message:add_int_value API for visualization.

-- PMS9003 Particle Concentration Sensor Monitor
-- Connects to a PMS9003 sensor via Serial (9600 8N1) using a structured frame codec.
-- Parses 32-byte measurement frames for PM1.0/2.5/10 concentrations and particle counts,
-- and handles 8-byte command acknowledgment frames.
--
-- Device: Plantower PMS9003 (Laser Particulate Matter Sensor)
-- Frame: [0x42 0x4D] [Length (16-bit)] [Payload] [Checksum (16-bit sum)]
-- Measurement Payload (26 bytes): 6 concentration values (u16be), 6 count values (u16be), version (u8), error (u8).
-- ACK Payload (2 bytes): Command echo (u8), Data/Status (u8).
--

function on_receive()
-- Only process messages from the Serial connection (ID 0)
if message.connection_id ~= 0 then
return false
end

-- The structured_frame_codec handles the 0x42 0x4D sync and sum16 checksum validation
if not message.checksum_valid then
log("warn", "PMS9003: Checksum mismatch detected")
return false
end

local payload = message.payload
if not payload then return false end

-- Distinguish between Measurement (26-byte payload) and ACK (2-byte payload) frames
if #payload == 26 then
-- Concentration (Standard Particles CF=1)
local pm1_0_std = read_u16_be(payload, 1)
local pm2_5_std = read_u16_be(payload, 3)
local pm10_std = read_u16_be(payload, 5)

-- Concentration (Atmospheric Environment)
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)

-- Particle Counts (per 0.1 Liters)
local count_0_3 = read_u16_be(payload, 13)
local count_0_5 = read_u16_be(payload, 15)
local count_1_0 = read_u16_be(payload, 17)
local count_2_5 = read_u16_be(payload, 19)
local count_5_0 = read_u16_be(payload, 21)
local count_10 = read_u16_be(payload, 23)

-- Metadata
local version = read_u8(payload, 25)
local error_code = read_u8(payload, 26)

-- Add values for UI visualization and logging
message:add_int_value("pm1_0_std", pm1_0_std)
message:add_int_value("pm2_5_std", pm2_5_std)
message:add_int_value("pm10_std", pm10_std)
message:add_int_value("pm1_0_atm", pm1_0_atm)
message:add_int_value("pm2_5_atm", pm2_5_atm)
message:add_int_value("pm10_atm", pm10_atm)
message:add_int_value("count_0_3um", count_0_3)
message:add_int_value("count_0_5um", count_0_5)
message:add_int_value("count_1_0um", count_1_0)
message:add_int_value("count_2_5um", count_2_5)
message:add_int_value("count_5_0um", count_5_0)
message:add_int_value("count_10um", count_10)
message:add_int_value("version", version)
message:add_int_value("error_code", error_code)

message.highlighted = (error_code > 0)

return true

elseif #payload == 2 then
-- Handle Acknowledgment frames (e.g. response to mode switch or sleep command)
local cmd_echo = read_u8(payload, 1)
local status = read_u8(payload, 2)

message:add_int_value("ack_cmd", cmd_echo)
message:add_int_value("ack_status", status)

log("info", string.format("PMS9003 ACK: Command 0x%02X, Status 0x%02X", cmd_echo, status))
return true
end

return false
end

Debugging journey

Step 1 — Passive vs Active Mode Verification

The sensor arrived in Active Mode, broadcasting data every 200–800ms. We verified this by observing the RX stream without sending any commands.

Observed broadcast frame (Active Mode):

42 4D 00 1C 00 18 00 24 00 26 01 A5 00 23 00 28 0F 1B 04 81 00 B3 00 0C 00 02 00 00 9A 00 04 08

This 32-byte frame was successfully parsed, showing a PM2.5 (Atmospheric) concentration of $0x0023$ (35 $\mu g/m^3$).

Step 2 — Sending Mode Control Commands

To test the sensor's command parser, we sent a "Set Active Mode" command (0xE1). Even though it was already in Active Mode, this confirmed the bidirectional link.

TX Command (Mode Switch 0xE1 to Active):

42 4D E1 00 01 01 71

The sensor responded immediately with an 8-byte acknowledgment:

42 4D 00 04 E1 01 01 75

Diagnosis: The response confirms the command byte 0xE1 and the status 0x01 (Active). The checksum 0x0175 matched the sum of 0x42+0x4D+0x00+0x04+0xE1+0x01+0x01.

Observed behaviour and validation

The sensor demonstrated extremely stable reporting in a low-particulate indoor environment.

  • Data Consistency: In "Stable Mode" (low concentration change), the sensor repeats the same measurement three times every 2 seconds. This was observed in the message history where values for PM2.5 fluctuated by less than 2 $\mu g/m^3$ over a 10-second window.
  • Timing: Response to the 0xE1 command occurred within 100ms.
  • Checksum Reliability: The summation checksum successfully filtered several early frames during the manual wiring stage where floating logic levels caused bit-shifts.

Porting to MCU firmware (design reference)

Peripheral Initialization

For an ESP32 or STM32, the UART peripheral should be configured as follows:

SettingValue
Baud Rate9600
Data Bits8
Stop Bits1
ParityNone
Logic Level3.3V TTL
DMA / InterruptRecommended (due to 32-byte bursts)

Message Parsing Logic

  1. Sync: Scan the incoming stream for 0x42 followed by 0x4D.
  2. Buffering: Read the next 30 bytes into a buffer (Total 32).
  3. Validation: Calculate the sum of bytes 0 through 29. Compare with the 16-bit big-endian value at bytes 30–31.
  4. Extraction:
    • PM2.5_Atmos = (Buffer[12] << 8) | Buffer[13]
    • Version = Buffer[28]
    • Error = Buffer[29]

State Machine and Timing

Frequently asked questions

Why does the PMS9003 return all zeros for concentration?

The sensor requires at least 30 seconds of "warm-up" time for the internal fan to stabilize airflow across the laser. If the fan is not spinning (due to lack of 5V supply or the SET pin being low), the laser cannot detect particles, resulting in zeroed-out concentration fields while the header and checksum remain valid.

How do I verify the PMS9003 checksum?

The checksum is a simple 16-bit sum of all bytes in the frame excluding the checksum field itself. For a 32-byte frame, sum bytes 0 to 29. For the 8-byte ACK, sum bytes 0 to 5.

What is the difference between CF=1 and Atmospheric readings?

"Standard Particle" (CF=1) values are used for laboratory calibration environments. "Atmospheric Environment" values are corrected for typical outdoor air compositions and are the standard metrics used for residential air quality monitoring.

Gotchas and recommendations

  • Voltage Mismatch: Do not connect the RX/TX pins directly to a 5V MCU (like an Arduino Uno) without level shifters. While the VCC is 5V, the logic is strictly 3.3V.
  • Fan Clearance: Ensure the sensor's outlet has at least 2cm of clearance. Obstruction leads to inaccurate readings and premature laser degradation.
  • Sleep Management: Use the software 0xE4 command or the hardware SET pin (Pin 3) to put the sensor to sleep between readings if taking measurements at intervals > 5 minutes. This extends the MTBF of the laser beyond the rated 15,000 hours.