Skip to main content

Modbus RTU CO2 Monitoring with MQTT, InfluxDB and Discord Alerts

This recipe demonstrates how to integrate a Senseair S8 NDIR CO2 sensor into a modern observability stack using CycBox. We poll the sensor every 2 seconds via Modbus RTU, publish the readings to a local MQTT broker and InfluxDB v3, and route critical alerts through ntfy and Discord webhooks.

Modbus RTU CO2 Monitoring Dashboard

What this recipe does​

  • Polls Senseair S8 registers: Automatically reads CO2 concentration, system status, and device identification (Serial/Firmware).
  • MQTT JSON Publishing: Dispatches real-time CO2 data to cycbox/sensor/co2.
  • InfluxDB v3 Integration: Writes high-precision time-series data to InfluxDB for long-term trend analysis.
  • Multi-channel Alerting: Sends high-priority notifications to ntfy and Discord when CO2 levels exceed 1000 ppm.
  • Hysteresis Logic: Prevents alert fatigue by requiring levels to drop below 700 ppm before clearing an alarm state.

End-to-end data flow​

The following diagram illustrates how a single CO2 sample travels from the physical sensor to your monitoring and alerting platforms.

Device and wire protocol​

The Senseair S8 is a miniature NDIR CO2 sensor. It operates as a Modbus RTU slave over a 3.3V CMOS UART interface.

  • Interface: 9600 bps, 8 data bits, no parity, 1 stop bit (the sensor transmits 2 stop bits).
  • Power: 4.5V to 5.25V DC. Warning: Peak current reaches 300 mA during the 2-second lamp cycle (Source: PSP103.pdf).
  • Modbus Address: Default 1–247. This recipe uses 254 (0xFE), which the S8 treats as a broadcast address to respond to any request in point-to-point configurations (Source: TDE2067.pdf).

The table below maps the primary Modbus Input Registers used in this recipe.

Wire AddressNameAccessUnit / Description
0x0000MeterStatusROSystem health (0x0000 = OK)
0x0003Space CO2ROCO2 concentration in ppm
0x001CFW VersionROMSB: Main, LSB: Sub version
0x001DSensor ID HiROSerial number upper 16 bits
0x001ESensor ID LoROSerial number lower 16 bits

CycBox configuration​

The setup requires three connections: the physical serial link to the sensor, a client connection to an MQTT broker, and a local MQTT server for internal routing.

Modbus RTU CO2 Monitoring Configuration

Connection index map​

The Lua script references these IDs to route messages to the correct transport.

connection_idRoleTransportCodec
0Sensor Linkserial_port_transportmodbus_rtu_codec
1Upstream MQTTmqtt_transporttimeout_codec
2Local Brokermqtt_server_transporttimeout_codec

Connection details​

[
{
"app": {
"app_transport": "serial_port_transport",
"app_codec": "modbus_rtu_codec",
"app_transformer": "disable_transformer"
},
"serial_port_transport": {
"serial_port_transport_port": "/dev/ttyACM0",
"serial_port_transport_baud_rate": 9600
},
"modbus_rtu_codec": {
"with_receive_timeout": 20
}
},
{
"app": {
"app_transport": "mqtt_transport",
"app_codec": "timeout_codec"
},
"mqtt_transport": {
"mqtt_transport_broker_url": "mqtt://localhost:1883",
"mqtt_transport_client_id": "cycbox-s8-monitor"
}
}
]

Lua pipeline walkthrough​

The script manages the sensor lifecycle: it queries identification on startup and polls for data every 2 seconds.

-- Senseair S8 CO2 Sensor Monitor
-- Connects a Senseair S8 CO2 sensor via Modbus RTU over serial port, an MQTT client, and an MQTT broker.
-- Polls Senseair S8 CO2 sensor via Modbus RTU over serial, publishes readings to MQTT and InfluxDB v3,
-- and sends ntfy/Discord alerts on CO2 threshold breach.
--
-- Device: Senseair S8 CO2 Sensor (Modbus RTU Slave)
-- Slave Address: 1–247; 0xFE = broadcast "any sensor" (point-to-point test)
-- Communication: 9600 baud, 8 data bits, no parity, 1 stop bit (2 on TX)
-- Response timeout: 180 ms max; poll interval: 2 s (lamp cycle period)
-- Max 8 registers per read, max packet 39 bytes (including CRC)
--
-- Input Register Map (Function 0x04, 0-based wire addresses):
-- 0x0000 MeterStatus RO System fault flags (0x0000 = healthy)
-- Bit 0: ERR_FATAL
-- Bit 1: ERR_OFFSET_REG
-- Bit 2: ERR_ALGORITHM
-- Bit 3: ERR_OUTPUT
-- Bit 4: ERR_SELF_DIAG
-- Bit 5: ERR_OUT_OF_RANGE
-- Bit 6: ERR_MEMORY
-- 0x0001 AlarmStatus RO Alarm flags (reserved)
-- 0x0002 Output Status RO Bit 0: ALARM_OUT (inverted, open-collector)
-- Bit 1: PWM_OUT (1 = full output)
-- 0x0003 Space CO2 RO Measured CO2 concentration (ppm)
-- 0x0019 Sensor Type ID Hi RO Device Type ID upper 16 bits
-- 0x001A Sensor Type ID Lo RO Device Type ID lowest 8 bits (in high byte)
-- 0x001B Memory Map Ver RO Memory map structure version
-- 0x001C FW Version RO Firmware Main (bits 15:8) . Sub (bits 7:0)
-- 0x001D Sensor ID High RO Serial number upper 16 bits
-- 0x001E Sensor ID Low RO Serial number lower 16 bits
--
-- Holding Register Map (Function 0x03 read / 0x06 write):
-- 0x0000 Acknowledgement R/W Calibration completion flags
-- Bit 5: ACK_BG_CAL, Bit 6: ACK_ZERO_CAL
-- 0x0001 Special Command WO 0x7C06 = background cal, 0x7C07 = zero cal
-- 0x001F ABC Period R/W Auto Baseline Correction interval (hours; 0 = suspend)

local SLAVE_ADDR = 254
local POLL_MS = 2000
local last_poll_ms = 0

-- External Services Config
local MQTT_CONN_ID = 1
local MQTT_TOPIC = "cycbox/sensor/co2"

local INFLUX_URL = get_env("INFLUX_URL") or "http://localhost:8181"
local INFLUX_TOKEN = get_env("INFLUX_TOKEN") or "<REDACTED>"
local INFLUX_DB = "cycbox"

local NTFY_TOPIC = "cycbox_alerts"
local DISCORD_WEBHOOK = get_env("DISCORD_WEBHOOK") or "<REDACTED>"

local CO2_THRESHOLD_HIGH = 1000
local CO2_THRESHOLD_LOW = 700
local is_co2_high = false

function on_start()
log("info", "Starting Senseair S8 Modbus script with MQTT, InfluxDB and Alerts.")
-- Query device info on startup: 6 registers starting at 0x0019
modbus_rtu_read_input_registers(SLAVE_ADDR, 0x0019, 6, 100, 0)
end

function on_timer(now_ms)
-- Poll for sensor values every POLL_MS (2 seconds)
if now_ms - last_poll_ms >= POLL_MS then
-- Query sensor active data: 4 registers starting at 0x0000
modbus_rtu_read_input_registers(SLAVE_ADDR, 0x0000, 4, 0, 0)
last_poll_ms = now_ms
end
end

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

-- 1. Parse active sensor values
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

-- MQTT Publish
local mqtt_payload = string.format('{"co2_ppm": %d}', co2)
mqtt_publish(MQTT_TOPIC, mqtt_payload, 0, false, 0, MQTT_CONN_ID)

-- InfluxDB v3 Publish
local line_data = string.format("senseair_s8 co2_ppm=%d", co2)
influxdb_write_v3_async(INFLUX_URL, INFLUX_TOKEN, INFLUX_DB, line_data, "auto", true, false)

-- Alerts Logic
if co2 > CO2_THRESHOLD_HIGH and not is_co2_high then
is_co2_high = true
local alert_msg = string.format("High CO2 Alert! Level at %d ppm", co2)
log("warn", alert_msg)
ntfy_send_async({topic = NTFY_TOPIC, message = alert_msg, title = "CO2 Alert", priority = "high"})
discord_send_async(DISCORD_WEBHOOK, alert_msg)
elseif co2 <= CO2_THRESHOLD_LOW and is_co2_high then
is_co2_high = false
local recovery_msg = string.format("CO2 returned to normal. Level at %d ppm", co2)
log("info", recovery_msg)
ntfy_send_async({topic = NTFY_TOPIC, message = recovery_msg, title = "CO2 Normal", priority = "default"})
discord_send_async(DISCORD_WEBHOOK, recovery_msg)
end
end

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 FATAL ERROR") end
if out_of_range > 0 then log("warn", "Sensor OUT OF RANGE") end
end
modified = true
end

local out_status = message:get_value(string.format("modbus_rtu_%d:input_0002", SLAVE_ADDR))
if out_status then
local alarm = (bit.band(out_status, 0x01) > 0)
message:add_bool_value("Alarm_Active", alarm)
modified = true
end

-- 2. Parse startup device info
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

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

local type_hi = message:get_value(string.format("modbus_rtu_%d:input_0019", SLAVE_ADDR))
local type_lo = message:get_value(string.format("modbus_rtu_%d:input_001A", SLAVE_ADDR))
if type_hi and type_lo then
-- Type ID spans upper 16 bits in 0x0019, and lowest 8 bits in the high byte of 0x001A
local type_id = type_hi * 256 + bit.rshift(type_lo, 8)
message:add_int_value("Sensor_Type_ID", type_id)
modified = true
end

return modified
end

Downstream service contracts​

MQTT​

  • Topic: cycbox/sensor/co2
  • Payload Schema:
{
"co2_ppm": 450
}

InfluxDB v3​

  • Measurement: senseair_s8
  • Fields: co2_ppm (Integer)
  • Line Protocol Example:
senseair_s8 co2_ppm=450

Alerting (ntfy & Discord)​

  • ntfy: Published to the cycbox_alerts topic with priority="high" during breaches.
  • Discord: Pushed via webhook as a plain-text alert string.

Alerting logic​

To prevent repetitive alerts when the CO2 level fluctuates near the threshold, we use hysteresis.

The table below defines the state transition rules for the CO2 alarm.

Current StateConditionNew StateAction
IDLECO2 > 1000 ppmALARMSend Discord/ntfy Alert
ALARMCO2 < 1001 ppm and > 700 ppmALARMNone (Hysteresis window)
ALARMCO2 <= 700 ppmIDLESend Recovery Notification

Operational concerns​

  • Environment Variables: You must set INFLUX_URL, INFLUX_TOKEN, and DISCORD_WEBHOOK before running this script.
  • Rate Limits: Discord webhooks are rate-limited. The script handles this by only sending alerts on state transitions, not on every 2-second poll.
  • Sensor Warm-up: The S8 requires approximately 2 minutes to reach T90 accuracy (Source: PSP103.pdf). Early readings may be slightly unstable.

Reproducing this recipe​

  1. Hardware: Senseair S8 sensor connected via a USB-to-RS485 adapter (or direct UART if the host is 3.3V compatible).
  2. Wiring: Ensure common ground between the sensor and the adapter.
  3. Configuration: Paste the JSON configuration into CycBox.
  4. Environment: Set your secrets in the CycBox environment variables.
  5. Deployment: Upload the Lua script to the engine.

Frequently asked questions​

why does the script use slave address 254?​

Address 254 (0xFE) is the Modbus broadcast address for the Senseair S8, allowing communication with any single connected sensor regardless of its configured ID. This simplifies setup for single-sensor deployments.

how do i change the co2 alert thresholds?​

Modify the CO2_THRESHOLD_HIGH and CO2_THRESHOLD_LOW variables in the Lua script to set your desired alarm and recovery points.

can i power the sensor directly from a usb-rs485 adapter?​

Only if the adapter can provide the 300mA peak current required during the NDIR lamp cycle; otherwise, an external 5V supply is required. Insufficient current often causes Modbus checksum errors or device resets.

why is the measurement interval set to 2 seconds?​

The Senseair S8 performs a new internal measurement every 2 seconds matching its infrared lamp cycle; polling faster provides no new data.

Gotchas and recommendations​

  • Logic Levels: The S8 uses 3.3V CMOS logic. Connecting it directly to a 5V UART without a level shifter can damage the sensor's Rx/Tx pins.
  • ABC (Auto Baseline Correction): By default, the S8 assumes the lowest CO2 level seen over 8 days is 400 ppm (fresh air). If used in a 24/7 occupied space without fresh air, the sensor will eventually drift and report inaccurate (lower) values.
  • Data Persistence: Ensure your InfluxDB bucket cycbox exists before starting, as the async write will fail silently if the destination is missing.