Skip to content

SEN5x UART Protocol

Aleksei Tertychnyi edited this page Sep 25, 2026 · 2 revisions

SEN5x UART Protocol (SEN55)

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.

Wiring (physical layer)

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.

Frame layer (SHDLC)

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
  • adr is always 0x00.

  • 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, 0x11 and 0x13 inside a frame are sent as 0x7D followed by the byte XOR 0x20:

    Original Sent as
    7E 7D 5E
    7D 7D 5D
    11 7D 31
    13 7D 33
  • Multi-byte values are big-endian. Unlike the I²C interface, there is no per-word CRC.

State byte (responses)

State Meaning
0x00 OK
0x01 Wrong data length
0x04 Illegal parameter
0x43 Command not allowed in current state

Commands confirmed on SEN55

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 0x03 accepts only 0x0C and 0xFD; every other value returns state 0x04.
  • Cmd 0x02 exists but rejects a 1-byte parameter with state 0x01, so it expects a different length. It isn't needed for readout, because 0x03 / 0x0C always returns the latest values.

Measured-values payload (cmd 0x03, data 0x0C)

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.

Worked example

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

How this maps to the polluSensWeb config

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.

SHDLC checksum in other tools

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"

Open questions

Contributions welcome on any of these:

  • Start mode byte: 0x02 is 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).
  • 0xFD debug dump: the structure of the 94-byte response.
  • SEN50 / SEN54: whether they answer identically (likely, minus their missing channels).

Clone this wiki locally