Skip to main content

Modbus Helpers

Codec Auto-Parsed Values​

Some codecs automatically parse protocol responses and attach typed values to messages. These values are available in on_receive() via message:get_value(id) and message.values_json without writing any Lua parsing code.

Modbus RTU Codec​

When modbus_rtu codec is selected, responses are automatically correlated with prior requests and parsed into values. Each register/coil produces two keys — a logical Modicon address and a protocol-type hex address:

Function CodeLogical KeyProtocol-Type KeyValue Type
0x01 Read Coilsmodbus_rtu_{slave}:{1+addr}modbus_rtu_{slave}:coil_{addr:04X}boolean
0x02 Read Discrete Inputsmodbus_rtu_{slave}:{10001+addr}modbus_rtu_{slave}:discrete_{addr:04X}boolean
0x03 Read Holding Registersmodbus_rtu_{slave}:{40001+addr}modbus_rtu_{slave}:holding_{addr:04X}uint16
0x04 Read Input Registersmodbus_rtu_{slave}:{30001+addr}modbus_rtu_{slave}:input_{addr:04X}uint16
0x05 Write Single Coilmodbus_rtu_{slave}:{1+addr}modbus_rtu_{slave}:coil_{addr:04X}boolean
0x06 Write Single Registermodbus_rtu_{slave}:{40001+addr}modbus_rtu_{slave}:holding_{addr:04X}uint16
  • {slave} is the Modbus slave address (decimal)
  • {addr} is the 0-based protocol address
  • Logical keys use the Modicon convention (decimal): coils start at 1, discrete inputs at 10001, input registers at 30001, holding registers at 40001
  • Protocol-type keys use 4-digit uppercase hex for the address (e.g., coil_0000, holding_0003)
  • Both keys are emitted for every parsed value; use whichever is more convenient
  • Values are only parsed when the codec can match a response to a prior request
  • Integer/float values can be visualized on charts in the UI

Modbus TCP Codec​

Same as RTU but with modbus_tcp prefix and unit ID from MBAP header. Each value also produces two keys:

Function CodeLogical KeyProtocol-Type KeyValue Type
0x01 Read Coilsmodbus_tcp_{unit}:{1+addr}modbus_tcp_{unit}:coil_{addr:04X}boolean
0x02 Read Discrete Inputsmodbus_tcp_{unit}:{10001+addr}modbus_tcp_{unit}:discrete_{addr:04X}boolean
0x03 Read Holding Registersmodbus_tcp_{unit}:{40001+addr}modbus_tcp_{unit}:holding_{addr:04X}uint16
0x04 Read Input Registersmodbus_tcp_{unit}:{30001+addr}modbus_tcp_{unit}:input_{addr:04X}uint16
0x05 Write Single Coilmodbus_tcp_{unit}:{1+addr}modbus_tcp_{unit}:coil_{addr:04X}boolean
0x06 Write Single Registermodbus_tcp_{unit}:{40001+addr}modbus_tcp_{unit}:holding_{addr:04X}uint16

Example: Senseair S8 CO₂ Sensor​

Auto-parsed values coexist with manually added values via message:add_int_value(), message:add_float_value(), etc. Use Lua to combine or transform auto-parsed uint16 registers into meaningful values (e.g., combining two registers into a 32-bit value). The example below polls a Senseair S8 CO₂ sensor and demonstrates periodic polling, value parsing, multi-register combination, and status/alarm decoding.

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

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

-- Read device info registers once at startup (Map Ver, FW Ver, Sensor ID Hi, Sensor ID Lo).
-- A non-zero delay is given to each subsequent call so the script waits for the previous
-- request's response before sending the next one. Without staggered delays, requests would
-- be queued back-to-back and the device may not respond correctly to rapid consecutive reads.
modbus_rtu_read_input_registers(SLAVE_ADDR, 0x001B, 4, 100, 0)
-- Send the next request 200 ms after the previous one, giving the device time to respond
modbus_rtu_read_holding_registers(SLAVE_ADDR, 0x001F, 1, 200, 0)
end

function on_timer(now_ms)
-- on_timer fires every 100 ms; accumulate elapsed time manually
timer_ms = timer_ms + 100

-- Periodically read active data (CO2, status, alarms) every POLL_MS milliseconds.
-- Delay is 0 here because this is the only request in the polling cycle.
if timer_ms >= POLL_MS then
-- Read 4 registers 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()
-- Only process messages from connection 0 (the primary serial port)
if message.connection_id ~= 0 then return false end
local modified = false

-- Space CO2 concentration (protocol address 0x0003, logical address 30004)
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

-- MeterStatus register (protocol address 0x0000): bit-field error flags
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
-- Bit 0: fatal internal error; Bit 5: measurement out of valid range
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

-- OutputStatus / alarm flags (protocol address 0x0002): bit 0 reflects active alarm state
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

-- Firmware version (protocol address 0x001C): high byte = major, low byte = minor
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

-- Sensor ID: two consecutive 16-bit registers combined into a 32-bit value.
-- id_hi (0x001D) holds the upper 16 bits; id_lo (0x001E) holds the lower 16 bits.
-- Both must be present in the same message (they come from a single multi-register read).
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

-- ABC (Automatic Baseline Correction) period in hours (holding register 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 true only when at least one value was added, so the engine
-- knows the message payload has been enriched and should be forwarded
return modified
end

Key patterns:

  • Staggered startup reads: Each modbus_rtu_read_* call in on_start uses a non-zero delay so the next request is not queued until the device has had time to respond to the previous one. Without staggered delays, rapid back-to-back requests can cause devices to drop or misframe responses.
  • Timer accumulation: on_timer fires every 100 ms; elapsed time is accumulated manually to achieve a polling period longer than the minimum callback interval.
  • Protocol-type hex keys: input_0003, holding_001F, etc. use the 4-digit uppercase hex form of the 0-based protocol address — easier to cross-reference with a device datasheet than Modicon addresses.
  • Multi-register 32-bit value: Two adjacent uint16 registers are combined with id_hi * 65536 + id_lo to reconstruct a 32-bit sensor ID.
  • return modified: Returning true only when values were added avoids unnecessary engine work for messages that belong to other devices or function codes.

Modbus RTU Helpers​

All RTU functions return true on success, false on failure. Invalid parameters raise a Lua runtime error. All accept an optional trailing connection_id (default: 0).

FunctionParameters
modbus_rtu_read_coils(slave, start, qty, delay, connection_id)slave: 0-255, start: 0-65535, qty: 1-2000, delay: ms
modbus_rtu_read_discrete_inputs(slave, start, qty, delay, connection_id)slave: 0-255, start: 0-65535, qty: 1-2000, delay: ms
modbus_rtu_read_holding_registers(slave, start, qty, delay, connection_id)slave: 0-255, start: 0-65535, qty: 1-125, delay: ms
modbus_rtu_read_input_registers(slave, start, qty, delay, connection_id)slave: 0-255, start: 0-65535, qty: 1-125, delay: ms
modbus_rtu_write_single_coil(slave, addr, value, delay, connection_id)slave: 0-255, addr: 0-65535, value: boolean, delay: ms
modbus_rtu_write_single_register(slave, addr, value, delay, connection_id)slave: 0-255, addr: 0-65535, value: 0-65535, delay: ms
modbus_rtu_write_multiple_coils(slave, start, values_table, delay, connection_id)slave: 0-255, start: 0-65535, values_table: boolean array (1-2000), delay: ms
modbus_rtu_write_multiple_registers(slave, start, values_table, delay, connection_id)slave: 0-255, start: 0-65535, values_table: integer array (1-125, each 0-65535), delay: ms

Modbus TCP Helpers​

Same as RTU but without slave parameter (unit ID is in MBAP header). All return true/false. All accept an optional trailing connection_id (default: 0).

FunctionParameters
modbus_tcp_read_coils(start, qty, delay, connection_id)start: 0-65535, qty: 1-2000, delay: ms
modbus_tcp_read_discrete_inputs(start, qty, delay, connection_id)start: 0-65535, qty: 1-2000, delay: ms
modbus_tcp_read_holding_registers(start, qty, delay, connection_id)start: 0-65535, qty: 1-125, delay: ms
modbus_tcp_read_input_registers(start, qty, delay, connection_id)start: 0-65535, qty: 1-125, delay: ms
modbus_tcp_write_single_coil(addr, value, delay, connection_id)addr: 0-65535, value: boolean, delay: ms
modbus_tcp_write_single_register(addr, value, delay, connection_id)addr: 0-65535, value: 0-65535, delay: ms
modbus_tcp_write_multiple_coils(start, values_table, delay, connection_id)start: 0-65535, values_table: boolean array (1-2000), delay: ms
modbus_tcp_write_multiple_registers(start, values_table, delay, connection_id)start: 0-65535, values_table: integer array (1-125, each 0-65535), delay: ms