Skip to main content

Senseair S8 CO2 Sensor Modbus RTU Debugging Guide

In this case study, we successfully establish communication with a Senseair S8 Commercial NDIR CO2 sensor over Modbus RTU using a 3.3V UART TTL connection. By leveraging CycBox's serial transport and Modbus RTU codec, we unpack the exact device initialization sequence, validate the continuous measurement polling loop, and decode the CO2 ppm readings alongside critical system diagnostic flags.

CycBox Senseair S8 Debugging Dashboard

Device under test

  • Model / manufacturer / category: Senseair S8 Commercial (Article No. 004-0-0010, 004-0-0075) / Senseair / NDIR CO2 Sensor
  • Role in the system: Measures ambient carbon dioxide concentrations for HVAC control, indoor air quality monitoring, and safety systems.
  • Electrical interface: UART 3.3V CMOS logic (requires external RS-485 transceiver if used on a long bus).
  • Power requirements: Supply voltage (VCC/G+) of 4.5V – 5.25V. The sensor draws a 30 mA average but requires handling for a 300 mA peak current during the measurement lamp cycle.
  • Key datasheet specs:
    • Measurement range: 400 – 2000 ppm (standard accuracy), reports up to 10000 ppm (extended).
    • Accuracy: ±30 ppm ±3% of reading.
    • Measurement interval: 2 seconds.
    • Automatic Baseline Correction (ABC): 8 days default.
  • Source documents: PSP103.pdf (Hardware Specification), TDE2067.pdf (Modbus Protocol Reference).

Wire protocol and CycBox stack

The Senseair S8 operates strictly as a Modbus RTU slave over a serial connection.

  • Transport: serial_port_transport configured for /dev/ttyACM0 at 9600 baud, 8 data bits, no parity, and 1 stop bit. Note: The datasheet specifies the sensor receives with 1 stop bit but transmits with 2. In practice, configuring the CycBox (or a host MCU) for 1 stop bit works flawlessly because the extra stop bit sent by the sensor simply blends into the inter-frame idle time.
  • Codec: modbus_rtu_codec with a 20 ms receive timeout.

CycBox Senseair S8

Protocol deep-dive: Modbus RTU on the Senseair S8

The sensor implements a subset of Modbus RTU with strict payload constraints. It supports reading holding registers ( 0x03), reading input registers (0x04), and writing single holding registers (0x06). Crucially, the maximum packet length the sensor will process is 39 bytes (including the address and CRC). Any frame exceeding this length is silently discarded.

While individual sensors have a specific node address (1–247), address 254 (0xFE) acts as the "Any Sensor" broadcast address. This is incredibly useful for point-to-point debugging or initializing a sensor before its factory address is known.

The table below maps the vital Senseair S8 Modbus registers to their functions, types, and scaling factors.

Address (Hex)Modbus TypeField NameDescriptionScale / Unit
0x0000Input Reg (04)MeterStatusSystem fault and error bitfieldBitfield (0x0000 = OK)
0x0002Input Reg (04)Output StatusDiscrete alarm and PWM statesBitfield
0x0003Input Reg (04)Space CO2Active carbon dioxide measurement1 ppm
0x001BInput Reg (04)Map VersionFirmware memory map versionRaw integer
0x001CInput Reg (04)Firmware VersionMain.Sub version (High/Low bytes)High=Main, Low=Sub
0x001DInput Reg (04)Sensor ID HighUpper 16 bits of serial numberRaw integer
0x001EInput Reg (04)Sensor ID LowLower 16 bits of serial numberRaw integer
0x0000Holding Reg (03)AcknowledgementCalibration execution flagsBitfield
0x0001Holding Reg (03)Special CommandTrigger background/zero calibration0x7C06 = Background
0x001FHolding Reg (03)ABC PeriodAuto Baseline Correction intervalHours (0 = Disabled)

CycBox configuration walkthrough

Transport & codec config

[
{
"app": {
"app_transport": "serial_port_transport",
"app_codec": "modbus_rtu_codec",
"app_transformer": "disable_transformer",
"app_encoding": "UTF-8"
},
"serial_port_transport": {
"serial_port_transport_parity": "none",
"serial_port_transport_port": "/dev/ttyACM0",
"serial_port_transport_stop_bits": "1",
"serial_port_transport_flow_control": "none",
"serial_port_transport_baud_rate": 9600,
"serial_port_transport_data_bits": 8
},
"modbus_rtu_codec": {
"with_receive_timeout": 20
}
}
]

Lua script

The following Lua script orchestrates the Modbus flow. On startup, it queries the static device identity and the ABC calibration period. It then kicks off a recurring 2-second timer to fetch the active CO2 measurements and system health status.

local SLAVE_ADDR = 254
local POLL_MS = 2000
local timer_ms = 0

function on_start()
log("info", "Starting Senseair S8 Modbus polling script.")

-- Poll device information and configuration once at startup
-- Read 4 registers starting at 0x001B: Map Ver, FW Ver, Sensor ID Hi, Sensor ID Lo
modbus_rtu_read_input_registers(SLAVE_ADDR, 0x001B, 4, 100, 0)
-- Read 1 holding register at 0x001F: ABC Period
modbus_rtu_read_holding_registers(SLAVE_ADDR, 0x001F, 1, 200, 0)
end

function on_timer(now_ms)
timer_ms = timer_ms + 100

-- Periodically read active data (CO2, status, alarms) every 2 seconds
if timer_ms >= POLL_MS then
-- Read 4 registers starting at 0x0000: MeterStatus, AlarmStatus, OutputStatus, SpaceCO2
modbus_rtu_read_input_registers(SLAVE_ADDR, 0x0000, 4, 0, 0)
timer_ms = 0
end
end

function on_receive()
if message.connection_id ~= 0 then return false end
local modified = false

-- Parse Space CO2 (Protocol address 0x0003)
local co2 = message:get_value(string.format("modbus_rtu_%d:input_0003", SLAVE_ADDR))
if co2 then
message:add_int_value("CO2_ppm", co2)
modified = true
end

-- Parse MeterStatus (Protocol address 0x0000)
local status = message:get_value(string.format("modbus_rtu_%d:input_0000", SLAVE_ADDR))
if status then
message:add_int_value("MeterStatus", status)
if status ~= 0 then
local fatal = bit.band(status, 0x01)
local out_of_range = bit.band(status, 0x20)
if fatal > 0 then log("error", "Sensor reported FATAL ERROR (bit 0)") end
if out_of_range > 0 then log("warn", "Sensor reading OUT OF RANGE (bit 5)") end
end
modified = true
end

-- Parse OutputStatus / Alarms (Protocol address 0x0002)
local out_status = message:get_value(string.format("modbus_rtu_%d:input_0002", SLAVE_ADDR))
if out_status then
-- Alarm state is inverted natively but output bit 0 reflects discrete alarm
local alarm = (bit.band(out_status, 0x01) > 0)
message:add_bool_value("Alarm_Active", alarm)
modified = true
end

-- Parse Firmware Version (Protocol address 0x001C)
local fw = message:get_value(string.format("modbus_rtu_%d:input_001C", SLAVE_ADDR))
if fw then
local main_ver = bit.rshift(fw, 8)
local sub_ver = bit.band(fw, 0xFF)
message:add_string_value("Firmware_Version", string.format("%d.%d", main_ver, sub_ver))
modified = true
end

-- Parse Sensor ID (Combined Protocol addresses 0x001D and 0x001E)
local id_hi = message:get_value(string.format("modbus_rtu_%d:input_001D", SLAVE_ADDR))
local id_lo = message:get_value(string.format("modbus_rtu_%d:input_001E", SLAVE_ADDR))
if id_hi and id_lo then
local sensor_id = id_hi * 65536 + id_lo
message:add_int_value("Sensor_ID", sensor_id)
modified = true
end

-- Parse ABC Period (Protocol address 0x001F)
local abc = message:get_value(string.format("modbus_rtu_%d:holding_001F", SLAVE_ADDR))
if abc then
message:add_int_value("ABC_Period_Hours", abc)
modified = true
end

return modified
end

Debugging journey

Step 1 — Verify device identity and firmware via broadcast address

  • Goal: Confirm the physical layer is functional and read the sensor's firmware version. Since the specific slave ID was unknown, we utilized the universal address 0xFE (254).
  • What we did: Sent a Read Input Registers (0x04) request to 0x001B with a quantity of 4 registers to capture the map version, firmware version, and 32-bit sensor ID.
  • What the device returned:
    TX: fe 04 00 1b 00 04 95 c1
    RX: fe 04 08 00 31 01 5c 07 54 46 74 94 e6
  • Diagnosis: Success. The payload (00 31 01 5C 07 54 46 74) decoded cleanly. The firmware version register ( 0x001C) contained 0x015C, which parsed to 1.92 (Main 0x01, Sub 0x5C). The Sensor ID (0x0754 concatenated with 0x4674) equated to decimal 122963572.

Step 2 — Read Automatic Baseline Correction (ABC) period

  • Goal: Validate the factory baseline calibration setting.
  • What we did: Requested a Read Holding Registers (0x03) to 0x001F for 1 register.
  • What the device returned:
    TX: fe 03 00 1f 00 01 a1 c3
    RX: fe 03 02 00 b4 ac 27
  • Diagnosis: The returned payload 0x00B4 equals 180 in decimal. Since the unit is hours, 180 hours translates to exactly 7.5 days, verifying that the ABC functionality is active and correctly configured.

Step 3 — Execute the 2-second telemetry polling loop

  • Goal: Continuously read the active CO2 ppm reading and the system status flags.
  • What we did: Requested a Read Input Registers (0x04) to 0x0000 with a quantity of 4 to fetch MeterStatus, AlarmStatus, OutputStatus, and Space CO2 in a single transaction.
  • What the device returned:
    TX: fe 04 00 00 00 04 e5 c6
    RX: fe 04 08 00 00 00 00 00 01 02 06 c7 b8
  • Diagnosis: Perfect execution.
    • Bytes 0-1 (00 00): MeterStatus is 0, indicating zero internal faults.
    • Bytes 4-5 (00 01): OutputStatus has bit 0 high, indicating normal operation.
    • Bytes 6-7 (02 06): Space CO2 equals 518 decimal. The sensor was reading an ambient 518 ppm CO2 environment. This sequence repeated predictably every 2 seconds.

Observed behaviour and validation

  • Timing constraints: The 2-second polling interval strictly adhered to the datasheet's measurement cycle. Polling faster does not yield new CO2 data and occupies unnecessary bus time. The sensor responded consistently within 40 ms to all valid queries.
  • Broadcast addressing: Using 0xFE allows communication without knowing the factory slave address. Note that if multiple Senseair S8 sensors are on the same RS-485 bus, querying 0xFE will cause packet collisions as all sensors answer simultaneously.
  • Voltage disparity: While the serial lines operate at 3.3V CMOS logic, the power supply must be an unregulated 5V source capable of absorbing brief 300 mA spikes.

Porting to MCU firmware (design reference, no code)

To build a production-grade driver for the Senseair S8, firmware engineers should design around the following guidelines.

  • Suggested MCU class: STM32G0/F4, ESP32, or NXP i.MX RT. The MCU needs one UART peripheral and a timer for the polling loop.

The table below details the specific MCU peripheral settings required for the serial interface.

Peripheral ParameterRequired SettingNotes
Baud Rate9600 bpsFixed by factory, non-negotiable.
Data Bits8Standard Modbus RTU.
ParityNoneStandard Modbus RTU.
Stop Bits1Sensor transmits 2, MCU can safely use 1.
Logic Level3.3V TTL5V tolerant pins recommended, but not mandatory.
DE / RE ControlGPIO toggleRequired only if driving an external RS-485 transceiver.

Device initialization sequence

  1. Power-On Delay: Wait at least 2 seconds after applying 5V power before sending the first frame to allow the sensor to boot and conduct its internal self-diagnostics.
  2. Identification Sync: Issue a Read Input Registers (0x04) request to address 0x001B to retrieve the firmware version and establish bus sanity. Use node ID 0xFE if the assigned ID is unknown.
  3. Verify ABC Configuration: Issue a Read Holding Registers (0x03) to 0x001F. If the application involves an environment that never reaches 400 ppm (like a greenhouse), disable ABC by writing 0x0000 to this register.
  4. Transition to Polling: Enter the operational state machine.

Message transmission and framing

When assembling the Modbus frame for reading CO2, the firmware must generate the following exact byte sequence for a Read Input Registers command targeting address 0xFE:

  • Offset 0 (Slave ID): 0xFE
  • Offset 1 (Function): 0x04
  • Offset 2-3 (Start Address): 0x00, 0x00
  • Offset 4-5 (Quantity): 0x00, 0x04
  • Offset 6-7 (CRC): 0xE5, 0xC6 (Calculated using CRC16-IBM, polynomial 0xA001, transmitted LSB first).

Inter-frame silence: Ensure a minimum delay of 3.5 character times (roughly 4 ms at 9600 baud) before transmitting a new frame to reset the sensor's Modbus state machine.

Error handling and state machine

The table below outlines a simple application state machine to handle the 2-second CO2 polling lifecycle and exceptions.

StateEvent / TriggerNext State / Action
INITPower appliedWait 2000 ms, move to IDENTIFY
IDENTIFYFW read successfulMove to POLL_READY
POLL_READY2000 ms timer expiresSend Read Input Regs (0-3), move to WAIT_RX
WAIT_RXValid Modbus frame receivedParse MeterStatus. If OK, decode CO2 ppm. Move to POLL_READY
WAIT_RXRx timeout (> 180 ms)Increment failure counter. Move to POLL_READY
WAIT_RXMeterStatus bit 0 (Fatal) is 1Alarm system. Require physical power cycle.
WAIT_RXMeterStatus bit 5 (OOR) is 1Flag "Out of Range" to telemetry. Measurement is invalid.

Frequently asked questions

What Modbus address does the Senseair S8 use by default?

Out of the box, the specific address can vary, but the sensor always responds to the broadcast address 254 (0xFE). You can use this to communicate with it immediately, provided it is the only device on the UART/RS-485 line.

Why does the sensor silently ignore my Modbus read request?

The Senseair S8 has a strict 39-byte maximum buffer limit. If you attempt to read too many registers in a single transaction (for example, attempting to read 20 holding registers at once), the required response frame will exceed 39 bytes, and the sensor will drop the request entirely rather than issuing an exception. Limit your reads to quantities of 8 registers or fewer.

How do I check if the sensor is in an error state?

Read Input Register 0x0000 (MeterStatus). A value of 0x0000 means the sensor is healthy. If the value is non-zero, check the specific bits: Bit 0 indicates a fatal error requiring a restart, while Bit 5 indicates the CO2 reading is out of range.

Why is my CO2 reading slowly drifting upward over several weeks?

If the sensor never encounters fresh outdoor air (400 ppm), the Automatic Baseline Correction (ABC) algorithm will incorrectly recalibrate the baseline, causing the readings to skew. If your environment is constantly occupied or artificially enriched, you must disable ABC by writing 0 to Holding Register 0x001F.

Gotchas and recommendations

  • Stop bit mismatch: The datasheet specifies 1 stop bit for receiving and 2 for transmitting. If your MCU UART cannot handle asymmetrical stop bits, setting your peripheral to 8-N-1 is perfectly safe. The extra stop bit sent by the sensor merely acts as idle bus time.
  • Power supply current spikes: The NDIR lamp draws 300 mA every 2 seconds. If you power the sensor from an MCU's 5V pin that cannot supply this surge, the MCU may brown out. Ensure adequate bulk capacitance near the sensor's VCC pin.
  • Write constraints: The sensor only supports Modbus Function 0x06 (Write Single Register). It does not support 0x10 (Write Multiple Registers). Firmware must write configuration values one register at a time.
  • Field Calibration: If manual background calibration is performed (writing to Holding Register 0x0001), the firmware must poll the Acknowledgement Register (0x0000) continuously to verify the calibration procedure actually finished before resuming normal polling.