Industrial I/O Bridging: Modbus RTU to MQTT with Local Toggles
This recipe demonstrates how to transform a standard Modbus RTU I/O module into an intelligent MQTT gateway. Using the MA01-AXCX4020, we implement both remote cloud control and local hardware button logic, featuring edge-triggered toggles and safety-critical mutual exclusion for indicator LEDs.
What this recipe does
- Polls 2 Discrete Inputs and 2 Coils every 500ms with a 200ms stagger to respect the 100ms device response time.
- Implements Edge Detection for local hardware toggles: DI1 (Normally Open) toggles the Green LED; DI2 (Normally Closed) toggles the Red LED.
- Enforces Mutual Exclusion: Ensures only one LED can be active at a time by sequencing
OFF-before-ONModbus writes. - Bridges to MQTT: Publishes real-time I/O states as JSON to
cycbox/stateand listens for remote string commands oncycbox/commands. - Maintains Local State: Synchronizes the Lua script's internal variables with actual hardware register reads to ensure consistency between remote and local actions.
End-to-end data flow
The flowchart below shows how a local button press triggers a sequence of Modbus commands while simultaneously notifying the MQTT broker.
Device and wire protocol
The MA01-AXCX4020 is an industrial RS485 I/O module with 4 Digital Inputs and 2 Relay Outputs.
- Physical Layer: RS485, 9600 bps, 8-N-1.
- Slave Address: 32 (0x20). According to the datasheet, this is calculated from Hardware DIP
31+ Software Offset1. - I/O Mapping:
- DI1 (NO Button): Discrete Input
0x0000. - DI2 (NC Button): Discrete Input
0x0001. - DO1 (Green LED): Coil
0x0000. - DO2 (Red LED): Coil
0x0001.
- DI1 (NO Button): Discrete Input
- Timing: The device requires approximately 100ms to process a request; the configuration uses a 200ms stagger between Modbus requests to prevent collisions.
CycBox configuration
Connection 0: Serial Port (Modbus RTU)
This connection communicates directly with the MA01-AXCX4020 module hardware.
{
"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_data_bits": 8,
"serial_port_transport_port": "/dev/ttyUSB0",
"serial_port_transport_baud_rate": 9600,
"serial_port_transport_parity": "none",
"serial_port_transport_stop_bits": "1",
"serial_port_transport_flow_control": "none"
},
"modbus_rtu_codec": {
"with_receive_timeout": 20
}
}
Connection 1: MQTT Client
This connection acts as the bridge to the downstream MQTT broker for automation and monitoring.
{
"app": {
"app_transport": "mqtt_transport",
"app_codec": "timeout_codec",
"app_transformer": "disable_transformer",
"app_encoding": "UTF-8"
},
"mqtt_transport": {
"mqtt_transport_subscribe_qos": 1,
"mqtt_transport_client_id": "cycbox-300JSYB6",
"mqtt_transport_broker_url": "mqtt://localhost:1883",
"mqtt_transport_subscribe_topics": "cycbox/commands",
"mqtt_transport_use_tls": false
},
"timeout_codec": {
"with_receive_timeout": 100
}
}
Connection Index Map
The Lua script references these IDs to route data between the field bus and the cloud.
| connection_id | Role | Protocol |
|---|---|---|
| 0 | Field Device | Modbus RTU (RS485) |
| 1 | Upstream Bridge | MQTT Client |
| 2 | Internal Broker | MQTT Server |
Lua pipeline walkthrough
The script manages three distinct tasks: periodic polling, command parsing, and the local hardware toggle state machine.
-- MA01-AXCX4020 DI/DO Polling, MQTT & Hardware Toggle Script
-- Uses serial_port_transport with modbus_rtu_codec to poll IO states,
-- and mqtt_transport to publish states and receive commands.
-- Polls 2 DIs and 2 DOs every 500ms, publishes to "cycbox/state" as JSON.
-- Adds local hardware toggle: DI1 (NO) toggles Green LED, DI2 (NC) toggles Red LED.
-- LEDs are mutually exclusive (only one on at a time).
--
-- Commands recognized (string payload in MQTT message):
-- "green_on" : Turns Green LED (DO1) ON, Red LED (DO2) OFF
-- "green_off" : Turns Green LED (DO1) OFF
-- "red_on" : Turns Red LED (DO2) ON, Green LED (DO1) OFF
-- "red_off" : Turns Red LED (DO2) OFF
--
-- MA01-AXCX4020 Device
-- Slave Address: 32 (0x20)
-- Discrete 0x0000: DI1 (NO Button)
-- Discrete 0x0001: DI2 (NC Button)
-- Coil 0x0000: DO1 (Green LED)
-- Coil 0x0001: DO2 (Red LED)
local SLAVE_ID = 32
local POLL_INTERVAL_MS = 500
local timer_ms = 0
local MQTT_CONN_ID = 1
-- State tracking for edge detection and toggling
local last_di1 = nil
local last_di2 = nil
local current_do1 = false
local current_do2 = false
function on_start()
log("info", "Starting MA01-AXCX4020 MQTT polling and toggle script")
end
function on_timer(now_ms)
timer_ms = timer_ms + 100
if timer_ms >= POLL_INTERVAL_MS then
-- Read 2 Discrete Inputs (DI1-DI2) starting at address 0x0000
modbus_rtu_read_discrete_inputs(SLAVE_ID, 0x0000, 2, 0, 0)
-- Read 2 Coils (DO1-DO2) starting at address 0x0000
-- Stagger by 200ms to allow the device to respond to the previous request
modbus_rtu_read_coils(SLAVE_ID, 0x0000, 2, 200, 0)
timer_ms = 0
end
end
function on_receive()
-- 1. Handle MQTT Commands from connection 1
if message.connection_id == MQTT_CONN_ID then
local topic = message:get_metadata("mqtt_topic")
if topic == "cycbox/commands" and message.payload then
local cmd = string.lower(message.payload)
log("info", "Received MQTT command: " .. cmd)
-- Mutually exclusive logic: if turning one ON, turn the other OFF first
if string.find(cmd, "green_on") then
modbus_rtu_write_single_coil(SLAVE_ID, 0x0001, false, 0, 0) -- DO2 (Red) Off
modbus_rtu_write_single_coil(SLAVE_ID, 0x0000, true, 100, 0) -- DO1 (Green) On
current_do2 = false
current_do1 = true
elseif string.find(cmd, "green_off") then
modbus_rtu_write_single_coil(SLAVE_ID, 0x0000, false, 0, 0) -- DO1 (Green) Off
current_do1 = false
elseif string.find(cmd, "red_on") then
modbus_rtu_write_single_coil(SLAVE_ID, 0x0000, false, 0, 0) -- DO1 (Green) Off
modbus_rtu_write_single_coil(SLAVE_ID, 0x0001, true, 100, 0) -- DO2 (Red) On
current_do1 = false
current_do2 = true
elseif string.find(cmd, "red_off") then
modbus_rtu_write_single_coil(SLAVE_ID, 0x0001, false, 0, 0) -- DO2 (Red) Off
current_do2 = false
end
end
return false
end
-- 2. Handle Modbus Responses from connection 0
if message.connection_id == 0 then
local modified = false
-- Codec automatically parses valid Modbus responses into values mapped by string ID
local di1 = message:get_value(string.format("modbus_rtu_%d:discrete_0000", SLAVE_ID))
local di2 = message:get_value(string.format("modbus_rtu_%d:discrete_0001", SLAVE_ID))
local do1 = message:get_value(string.format("modbus_rtu_%d:coil_0000", SLAVE_ID))
local do2 = message:get_value(string.format("modbus_rtu_%d:coil_0001", SLAVE_ID))
-- Keep local state synced with actual hardware reads
if do1 ~= nil then current_do1 = do1 end
if do2 ~= nil then current_do2 = do2 end
-- Local Hardware Toggle: DI1 (Normally Open) Edge Detection (false -> true)
if di1 ~= nil then
if last_di1 ~= nil and last_di1 == false and di1 == true then
log("info", "DI1 NO button pressed, toggling Green LED")
if current_do1 then
-- If ON, turn OFF
modbus_rtu_write_single_coil(SLAVE_ID, 0x0000, false, 0, 0)
current_do1 = false
else
-- If OFF, enforce mutual exclusion (turn Red OFF, Green ON)
modbus_rtu_write_single_coil(SLAVE_ID, 0x0001, false, 0, 0)
modbus_rtu_write_single_coil(SLAVE_ID, 0x0000, true, 100, 0)
current_do2 = false
current_do1 = true
end
end
last_di1 = di1
end
-- Local Hardware Toggle: DI2 (Normally Closed) Edge Detection (true -> false)
if di2 ~= nil then
if last_di2 ~= nil and last_di2 == true and di2 == false then
log("info", "DI2 NC button pressed, toggling Red LED")
if current_do2 then
-- If ON, turn OFF
modbus_rtu_write_single_coil(SLAVE_ID, 0x0001, false, 0, 0)
current_do2 = false
else
-- If OFF, enforce mutual exclusion (turn Green OFF, Red ON)
modbus_rtu_write_single_coil(SLAVE_ID, 0x0000, false, 0, 0)
modbus_rtu_write_single_coil(SLAVE_ID, 0x0001, true, 100, 0)
current_do1 = false
current_do2 = true
end
end
last_di2 = di2
end
local json_parts = {}
if di1 ~= nil then
message:add_bool_value("Button_NO_DI1", di1)
table.insert(json_parts, string.format('"button_di1":%s', di1 and "true" or "false"))
modified = true
end
if di2 ~= nil then
message:add_bool_value("Button_NC_DI2", di2)
table.insert(json_parts, string.format('"button_di2":%s', di2 and "true" or "false"))
modified = true
end
if do1 ~= nil then
message:add_bool_value("LED_Green_DO1", do1)
table.insert(json_parts, string.format('"led_green":%s', do1 and "true" or "false"))
modified = true
end
if do2 ~= nil then
message:add_bool_value("LED_Red_DO2", do2)
table.insert(json_parts, string.format('"led_red":%s', do2 and "true" or "false"))
modified = true
end
-- Publish a combined JSON message to MQTT if any new values were parsed
if modified and #json_parts > 0 then
local json_payload = "{" .. table.concat(json_parts, ",") .. "}"
mqtt_publish("cycbox/state", json_payload, 0, false, 0, MQTT_CONN_ID)
end
return modified
end
return false
end
Callback Logic
on_timer: Triggers every 100ms. Every 500ms, it initiates a DI read followed by a DO read staggered by 200ms to avoid overlapping frames.on_receive(MQTT): Parses commands likegreen_on. It forces anOFFcommand to the opposing LED before issuing theONcommand, ensuring the mutual exclusion rule is never violated.on_receive(Modbus): Performs edge detection by comparing the new DI state againstlast_diX.- DI1 (NO): Toggles when transitioning from
falsetotrue(rising edge). - DI2 (NC): Toggles when transitioning from
truetofalse(falling edge). - Once a toggle is confirmed, it executes the same mutual exclusion logic as the MQTT commands.
- DI1 (NO): Toggles when transitioning from
Downstream service contracts
MQTT
The following topics and schemas are used for communication.
- Topic (State):
cycbox/state - Topic (Command):
cycbox/commands - Payload Schema (State): JSON object containing boolean values for all 4 I/O points.
- Example Payload:
{
"button_di1": false,
"button_di2": true,
"led_green": true,
"led_red": false
}
Toggle and alerting logic
The table below defines the state transitions used to enforce mutual exclusivity during toggle events.
| Trigger | Current State | Action 1 | Action 2 | Result |
|---|---|---|---|---|
| DI1 Press | Green ON | DO1 -> OFF | - | Both OFF |
| DI1 Press | Green OFF | DO2 -> OFF | DO1 -> ON | Green ON |
| DI2 Press | Red ON | DO2 -> OFF | - | Both OFF |
| DI2 Press | Red OFF | DO1 -> OFF | DO2 -> ON | Red ON |
Operational concerns
- Mechanical Relay Wear: Frequent toggling (e.g., button mashing) can lead to premature failure of the mechanical relays on the MA01.
- Polling Latency: The 500ms polling interval means a button press must be held for at least 500ms to guarantee detection. For faster response, the polling interval can be reduced to 250ms, provided the stagger is maintained.
- NC Logic Inversion: Users must remember that
button_di2is Normally Closed. Atruestatus in the JSON means the button is IDLE, andfalsemeans it is PRESSED.
Reproducing this recipe
- Hardware: One MA01-AXCX4020 module, one USB-to-RS485 adapter, one NO button (DI1), and one NC button (DI2).
- Wiring: Connect DI1 between
DI1andGND; connect DI2 betweenDI2andGND. - Config: Paste the JSON configuration blocks for Serial and MQTT into the CycBox engine.
- Script: Paste the Lua script provided above.
- Environment: Ensure a local MQTT broker (like Mosquitto) is running at
localhost:1883.
Gotchas and recommendations
- Startup Synchronization: On the first poll after a restart, the script will update
last_diXandcurrent_doX. It does not enforce mutual exclusion until the first command is received. - Modbus Address Calculation: If the device does not respond, verify the DIP switches. A setting of all
ONresults in hardware address 31, which when added to the default software offset of 1, equals the Slave ID 32 used in this recipe. - Timeout Tuning: The
with_receive_timeoutfor themodbus_rtu_codecis set to 20ms. If using a high-latency wireless RS485 bridge, increase this to 100ms.