Repository navigation
SEN5x UART Protocol
Many thanks to Michael Lažan for providing this initial protocol description!
Sensirion does not publish a UART interface description for the SEN5x family — all official SEN5x drivers are I²C only. The protocol below was mapped by probing a SEN55, firmware 2.0, over a plain USB-UART converter in September 2026. It is what the Sensirion SEN55 UART entry in sensors.json is built on, and that config has been verified working.
⚠️ This is reverse-engineered, undocumented behaviour. It may change with other firmware versions. If you test another firmware or a SEN50/SEN54, please report your results in an issue.
| Parameter | Value |
|---|---|
| Serial settings | 115200 baud, 8 data bits, no parity, 1 stop bit |
| Supply (VDD) | 5 V |
| Logic level | RX/TX are 3.3 V LVTTL, 5 V tolerant |
| Connector | JST ZH, 1.5 mm pitch, 6 pins |
| Interface select | SEL (pin 5) floating at power-up = UART; SEL tied to GND = I²C (address 0x69) |
| SEN55 pin | Function | Connect to |
|---|---|---|
| 1 | VDD | 5 V |
| 2 | GND | GND |
| 3 | RX (sensor input) | adapter TX |
| 4 | TX (sensor output) | adapter RX |
| 5 | SEL | leave unconnected |
| 6 | — | not connected |
SEL is only read at power-up. If you change it, power-cycle the sensor.
The SEN55 uses standard Sensirion SHDLC, the same framing as the SPS30 and SVM4x.
Host → sensor (MOSI): 7E | adr | cmd | len | data… | chk | 7E
Sensor → host (MISO): 7E | adr | cmd | state | len | data… | chk | 7E
-
adris always0x00. -
Checksum:
chk = 0xFF − (sum of all bytes between the 7E delimiters, excluding chk) & 0xFF, i.e.0xFF - (sum & 0xFF). It is computed on the un-stuffed bytes. -
Byte stuffing:
0x7E,0x7D,0x11and0x13inside a frame are sent as0x7Dfollowed by the byte XOR0x20:Original Sent as 7E7D 5E7D7D 5D117D 31137D 33 -
Multi-byte values are big-endian. Unlike the I²C interface, there is no per-word CRC.
| State | Meaning |
|---|---|
0x00 |
OK |
0x01 |
Wrong data length |
0x04 |
Illegal parameter |
0x43 |
Command not allowed in current state |
| Cmd | Data | Function | Response data | Full request frame |
|---|---|---|---|---|
0x00 |
0x02 |
Start measurement | none (state 0x43 if already running — harmless) |
7E 00 00 01 02 FC 7E |
0x01 |
none | Stop measurement | none | 7E 00 01 00 FE 7E |
0x03 |
0x0C |
Read measured values | 16 bytes, see below | 7E 00 03 01 0C EF 7E |
0x03 |
0xFD |
Raw/debug dump | 94 bytes, mostly IEEE float32, 0xFFFFFFFF placeholders |
7E 00 03 01 FD FE 7E |
0xD0 |
0x03 |
Read serial number | ASCII string, null-terminated | 7E 00 D0 01 03 2B 7E |
0xD1 |
none | Read version | 7 bytes; bytes 0–1 = firmware major.minor | 7E 00 D1 00 2E 7E |
Parameter sweep notes
- Cmd
0x03accepts only0x0Cand0xFD; every other value returns state0x04. - Cmd
0x02exists but rejects a 1-byte parameter with state0x01, so it expects a different length. It isn't needed for readout, because0x03 / 0x0Calways returns the latest values.
16 bytes = 8 big-endian words, with the same channel order and scaling as the documented I²C command 0x03C4:
| Payload bytes | Frame bytes* | Type | Scale | Channel |
|---|---|---|---|---|
| 0–1 | 5–6 | u16 | ÷10 | PM1.0 [µg/m³] |
| 2–3 | 7–8 | u16 | ÷10 | PM2.5 [µg/m³] |
| 4–5 | 9–10 | u16 | ÷10 | PM4.0 [µg/m³] |
| 6–7 | 11–12 | u16 | ÷10 | PM10 [µg/m³] |
| 8–9 | 13–14 | i16 | ÷100 | Relative humidity [%] |
| 10–11 | 15–16 | i16 | ÷200 | Temperature [°C] |
| 12–13 | 17–18 | i16 | ÷10 | VOC index |
| 14–15 | 19–20 | i16 | ÷10 | NOx index |
* Index in the full un-stuffed response frame as seen by polluSensWeb (data[0] = 7E, then adr, cmd, state, len, payload…). The full response is 23 bytes: 7E + 4 header bytes + 16 payload + chk + 7E.
Unknown values are reported as 0xFFFF (u16) or 0x7FFF (i16) during the first seconds after start. In polluSensWeb these show up as a brief spike (e.g. 6553.5 µg/m³ or 163.8 °C) until the sensor has valid data.
Request:
7E 00 03 01 0C EF 7E
Checksum check: 00 + 03 + 01 + 0C = 0x10, and 0xFF − 0x10 = 0xEF ✓
Response payload:
00 52 00 58 00 5A 00 5B 11 4C 12 A4 01 A4 00 0A
| Word | Raw | Decimal | Scaled |
|---|---|---|---|
| PM1.0 | 0052 |
82 | 8.2 µg/m³ |
| PM2.5 | 0058 |
88 | 8.8 µg/m³ |
| PM4.0 | 005A |
90 | 9.0 µg/m³ |
| PM10 | 005B |
91 | 9.1 µg/m³ |
| RH | 114C |
4428 | 44.28 % |
| Temperature | 12A4 |
4772 | 23.86 °C |
| VOC index | 01A4 |
420 | 42.0 |
| NOx index | 000A |
10 | 1.0 |
The Sensirion SEN55 UART entry is a multi-command sensor:
| Step | Config | Purpose |
|---|---|---|
Init (repeat: 0) |
7E 00 00 01 02 FC 7E, 2000 ms delay, 7-byte reply frame |
Start measurement; checksum passes whether state is 0x00 or 0x43
|
Loop (repeat: 1) |
7E 00 03 01 0C EF 7E, 1000 ms delay, 23-byte reply frame |
Read measured values every second |
| Disconnect |
stop_command: 7E 00 01 00 FE 7E
|
Stop measurement |
Key parts of the read command:
"frame": {
"startByte": "0x7E", "endByte": "0x7E", "length": 23,
"stuffing": [["7D 5E", "0x7E"], ["7D 5D", "0x7D"], ["7D 31", "0x11"], ["7D 33", "0x13"]]
},
"checksum": {
"eval": "0xFF-(data.slice(1,21).reduce((a,b)=>a+b,0)&0xFF)",
"compare": "data[21]"
},
"data": {
"PM1.0": { "value": "(dv=new DataView(new Uint8Array(data).buffer),dv.getUint16(5,false)/10)", "unit": "µg/m³" },
"PM2.5": { "value": "dv.getUint16(7,false)/10", "unit": "µg/m³" },
"PM4.0": { "value": "dv.getUint16(9,false)/10", "unit": "µg/m³" },
"PM10.0": { "value": "dv.getUint16(11,false)/10", "unit": "µg/m³" },
"RH": { "value": "dv.getInt16(13,false)/100", "unit": "%RH" },
"Temp": { "value": "dv.getInt16(15,false)/200", "unit": "°C" },
"VOC": { "value": "dv.getInt16(17,false)/10", "unit": "index" },
"NOx": { "value": "dv.getInt16(19,false)/10", "unit": "index" }
}The first signal creates a DataView (dv) over the frame, and the following signals reuse it — see Reading multi-byte values with DataView.
To build your own SHDLC frames (e.g. to try commands in a serial terminal):
function shdlc(cmd, data = []) {
const body = [0x00, cmd, data.length, ...data];
const chk = 0xFF - (body.reduce((a, b) => a + b, 0) & 0xFF);
const stuffed = [...body, chk].flatMap(b =>
[0x7E, 0x7D, 0x11, 0x13].includes(b) ? [0x7D, b ^ 0x20] : [b]);
return [0x7E, ...stuffed, 0x7E]
.map(b => b.toString(16).padStart(2, "0").toUpperCase()).join(" ");
}
shdlc(0x03, [0x0C]); // "7E 00 03 01 0C EF 7E"Contributions welcome on any of these:
-
Start mode byte:
0x02is confirmed working; other values are untested. An RHT/gas-only mode may exist, as on I²C. -
Cmd
0x02: the expected parameter length and meaning (possibly data ready). -
0xFDdebug dump: the structure of the 94-byte response. - SEN50 / SEN54: whether they answer identically (likely, minus their missing channels).
polluSensWeb · MIT License · Repository · Report an issue
Using the app
Sensor definitions
Sensor notes
Deployment
Help