Senseair S8 CO2 Sensor to TimescaleDB
Overview
This example demonstrates how to poll a Senseair S8 CO2 sensor via Modbus RTU and store readings in TimescaleDB. It showcases CycBox's capabilities for:
- Polling Modbus RTU devices using the built-in Modbus RTU codec
- Reading CO2 concentration from the Senseair S8 input register
- Storing time-series data in TimescaleDB with async inserts
- Periodic data acquisition with configurable poll intervals
This is a common pattern in indoor air quality monitoring where CO2 measurements need to be recorded in a time-series database for analysis and alerting.
Scenario
A Senseair S8 CO2 sensor is connected via serial port (e.g., /dev/ttyACM0) and communicates using Modbus RTU protocol
at 9600 baud. The requirements are:
- Poll the sensor every 10 seconds using Modbus RTU Read Input Registers (function code 0x04)
- Parse the CO2 concentration value (in ppm) from Input Register IR4
- Store each reading in a TimescaleDB hypertable with a sensor identifier
Senseair S8 Register Map
The Senseair S8 uses address 0xFE (254, "any sensor") by default. The CO2 value is in Input Register IR4:
| IR# | Register Address | Description |
|---|---|---|
| IR1 | 0x0000 | Meter Status (error flags) |
| IR2 | 0x0001 | Alarm Status |
| IR3 | 0x0002 | Output Status (alarm/PWM) |
| IR4 | 0x0003 | Space CO2 (ppm) |
| IR22 | 0x0015 | PWM Output |
| IR26 | 0x0019 | Sensor Type ID High |
| IR27 | 0x001A | Sensor Type ID Low |
| IR29 | 0x001C | Firmware Version (Main.Sub) |
| IR30 | 0x001D | Sensor Serial Number High |
| IR31 | 0x001E | Sensor Serial Number Low |
Communication parameters: 9600 baud, 8 data bits, no parity, 1 stop bit.
Configuration
CycBox is configured with a single serial connection using the Modbus RTU codec:
{
"version": "1.11.1",
"name": "Senseair S8 CO2 Sensor to TimescaleDB",
"description": "Modbus RTU with TimescaleDB Storage",
"configs": [
{
"app": {
"app_transport": "serial",
"app_codec": "modbus_rtu_codec",
"app_transformer": "disable",
"app_encoding": "UTF-8"
},
"serial": {
"serial_port": "/dev/ttyACM0",
"serial_baud_rate": 9600,
"serial_data_bits": 8,
"serial_parity": "none",
"serial_stop_bits": "1",
"serial_flow_control": "none"
},
"modbus_rtu_codec": {
"with_receive_timeout": 20
}
}
]
}
Connection Details
| Parameter | Value |
|---|---|
| Transport | Serial |
| Codec | modbus_rtu_codec |
| Port | /dev/ttyACM0 |
| Baud Rate | 9600 |
| Data Bits | 8 |
| Parity | None |
| Stop Bits | 1 |
| Receive Timeout | 20 (x100ms) |
TimescaleDB Schema
Create the hypertable before running the script:
CREATE TABLE IF NOT EXISTS co2_readings
(
time
TIMESTAMPTZ
DEFAULT
NOW
(
),
sensor TEXT NOT NULL,
co2 INTEGER NOT NULL
);
SELECT create_hypertable('co2_readings', 'time', if_not_exists = > TRUE);
Lua Script Logic
The Lua script handles three tasks: TimescaleDB connection, periodic polling, and response parsing with storage.
Script Breakdown
local SLAVE_ADDR = 0xFE -- 254, "any sensor" address
local CO2_REG_ADDR = 0x0003 -- IR4 starting address (register number - 1)
local CO2_REG_QTY = 1
local CONNSTR = "host=localhost port=5432 dbname=cycbox user=postgres password=xxxxxx sslmode=disable"
local POOL_SIZE = 3
local POLL_INTERVAL = 10000 -- 10 seconds in ms
How It Works
1. Initialization (on_start)
function on_start()
local ok, err = timescaledb_connect(CONNSTR, POOL_SIZE)
if not ok then
log("error", "Failed to connect to TimescaleDB: " .. (err or "unknown error"))
else
log("info", "Connected to TimescaleDB")
end
-- Send first read immediately
modbus_rtu_read_input_registers(SLAVE_ADDR, CO2_REG_ADDR, CO2_REG_QTY, 0, 0)
log("info", "Senseair S8 polling started")
end
- Establishes a connection pool to TimescaleDB with 3 connections
- Sends an immediate Modbus read request so data is available without waiting for the first timer tick
2. Response Parsing and Storage (on_receive)
function on_receive()
if message.connection_id ~= 0 then
return false
end
local co2 = message:get_value("modbus_rtu_254:30004")
if co2 == nil then
log("warn", "No CO2 value in response")
return false
end
log("info", string.format("CO2: %d ppm", co2))
message:add_int_value("CO2", co2)
-- Async insert to TimescaleDB
timescaledb_insert_async("co2_readings", {"sensor", "co2"}, {"senseair_s8", co2})
return true
end
- Filters messages to only process responses from connection 0 (serial port)
- Retrieves the CO2 value using the codec-assigned logical key
modbus_rtu_254:30004(30001 + 3) - Adds the value to message metadata for UI charting via
message:add_int_value() - Inserts the reading asynchronously into TimescaleDB — non-blocking so it doesn't delay the next poll
3. Periodic Polling (on_timer)
local timer_counter = 0
function on_timer(timestamp_ms)
timer_counter = timer_counter + 100
if timer_counter < POLL_INTERVAL then
return
end
timer_counter = 0
modbus_rtu_read_input_registers(SLAVE_ADDR, CO2_REG_ADDR, CO2_REG_QTY, 0, 0)
end
on_timer()is called every 100ms- Accumulates elapsed time and triggers a Modbus read every 10 seconds
- Sends a Read Input Registers request (function code 0x04) to read IR4 (CO2)
4. Cleanup (on_stop)
function on_stop()
timescaledb_disconnect()
log("info", "Senseair S8 polling stopped")
end
- Disconnects from TimescaleDB and releases the connection pool
Modbus RTU Codec Value IDs
The Modbus RTU codec automatically parses register values and assigns two keys per register:
- Logical key:
modbus_rtu_{slave_addr}:{30001 + register_address}(Modicon convention) - Protocol-type key:
modbus_rtu_{slave_addr}:input_{addr:04X}(hex address)
For this example with slave address 254 (0xFE) and register address 0x0003:
| Register | Logical Key | Protocol-Type Key | Description |
|---|---|---|---|
| IR4 | modbus_rtu_254:30004 | modbus_rtu_254:input_0003 | CO2 value (ppm) |
TimescaleDB Output
Each reading is inserted as a row in the co2_readings hypertable:
| time | sensor | co2 |
|---|---|---|
| 2026-03-06 10:00:00+00 | senseair_s8 | 412 |
| 2026-03-06 10:00:10+00 | senseair_s8 | 415 |
| 2026-03-06 10:00:20+00 | senseair_s8 | 408 |
View Full example script on GitHub.