Skip to main content

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:

  1. Poll the sensor every 10 seconds using Modbus RTU Read Input Registers (function code 0x04)
  2. Parse the CO2 concentration value (in ppm) from Input Register IR4
  3. 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 AddressDescription
IR10x0000Meter Status (error flags)
IR20x0001Alarm Status
IR30x0002Output Status (alarm/PWM)
IR40x0003Space CO2 (ppm)
IR220x0015PWM Output
IR260x0019Sensor Type ID High
IR270x001ASensor Type ID Low
IR290x001CFirmware Version (Main.Sub)
IR300x001DSensor Serial Number High
IR310x001ESensor 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

ParameterValue
TransportSerial
Codecmodbus_rtu_codec
Port/dev/ttyACM0
Baud Rate9600
Data Bits8
ParityNone
Stop Bits1
Receive Timeout20 (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:

RegisterLogical KeyProtocol-Type KeyDescription
IR4modbus_rtu_254:30004modbus_rtu_254:input_0003CO2 value (ppm)

TimescaleDB Output

Each reading is inserted as a row in the co2_readings hypertable:

timesensorco2
2026-03-06 10:00:00+00senseair_s8412
2026-03-06 10:00:10+00senseair_s8415
2026-03-06 10:00:20+00senseair_s8408

View Full example script on GitHub.