Skip to main content

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-ON Modbus writes.
  • Bridges to MQTT: Publishes real-time I/O states as JSON to cycbox/state and listens for remote string commands on cycbox/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 Offset 1.
  • 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.
  • 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_idRoleProtocol
0Field DeviceModbus RTU (RS485)
1Upstream BridgeMQTT Client
2Internal BrokerMQTT 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

  1. on_timer: Triggers every 100ms. Every 500ms, it initiates a DI read followed by a DO read staggered by 200ms to avoid overlapping frames.
  2. on_receive (MQTT): Parses commands like green_on. It forces an OFF command to the opposing LED before issuing the ON command, ensuring the mutual exclusion rule is never violated.
  3. on_receive (Modbus): Performs edge detection by comparing the new DI state against last_diX.
    • DI1 (NO): Toggles when transitioning from false to true (rising edge).
    • DI2 (NC): Toggles when transitioning from true to false (falling edge).
    • Once a toggle is confirmed, it executes the same mutual exclusion logic as the MQTT commands.

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.

TriggerCurrent StateAction 1Action 2Result
DI1 PressGreen ONDO1 -> OFF-Both OFF
DI1 PressGreen OFFDO2 -> OFFDO1 -> ONGreen ON
DI2 PressRed ONDO2 -> OFF-Both OFF
DI2 PressRed OFFDO1 -> OFFDO2 -> ONRed 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_di2 is Normally Closed. A true status in the JSON means the button is IDLE, and false means it is PRESSED.

Reproducing this recipe

  1. Hardware: One MA01-AXCX4020 module, one USB-to-RS485 adapter, one NO button (DI1), and one NC button (DI2).
  2. Wiring: Connect DI1 between DI1 and GND; connect DI2 between DI2 and GND.
  3. Config: Paste the JSON configuration blocks for Serial and MQTT into the CycBox engine.
  4. Script: Paste the Lua script provided above.
  5. 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_diX and current_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 ON results 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_timeout for the modbus_rtu_codec is set to 20ms. If using a high-latency wireless RS485 bridge, increase this to 100ms.