Introduction#
When you’re developing a Modbus master (a router, SCADA system, or a desktop tool like Modbus Poll) you eventually need something on the other end of the wire that behaves like a real industrial device — not just a register dump, but something with alarms that latch, a process that drifts and responds to commands, and read-only vs. read-write access rules that actually get enforced.
ESP-MB-Simulator is an ESP32-S3 firmware project that does exactly that: a fully data-driven Modbus RTU slave with 200 simulated points across coils, discrete inputs, input registers, and holding registers, designed to stand in for a commercial Modbus controller while testing NowData routers, SCADA systems, and Modbus master tools.
GitHub Repository: github.com/iam-vivekus/esp-modbus-simulator
Architecture#
flowchart TB
subgraph MasterSide [Modbus Master]
ND[NowData Router]
MP[Modbus Poll / QModMaster]
end
subgraph HW [Hardware]
RS485[MAX485 / MAX3485]
ESP[ESP32-S3]
end
subgraph FW [Firmware Modules]
UART[uart_rs485]
MB[modbus_slave]
REG[register_map]
SIM[simulation_engine]
ALM[alarm_engine]
CFG[configuration]
LOG[logger]
STATS[statistics]
end
ND --> RS485
MP --> RS485
RS485 <--> UART
UART --> MB
MB --> REG
SIM --> REG
ALM --> REG
CFG --> REG
MB --> LOG
MB --> STATS
SIM --> ALM
The design goal was: the register map is the only thing you touch to simulate a new device. Adding a device profile means editing tables in register_map.c — the Modbus protocol handling, alarm engine, and simulation loop stay untouched.
Memory Map (200 Points)#
PDU offsets are 0-based internally in firmware storage, but the driver JSON and Modbus tooling use documentation addresses, same as a real PLC:
| Type | Address range | RO | RW |
|---|---|---|---|
| Coils | 00001–00050 | 00001–00025 | 00026–00050 |
| Discrete | 10001–10050 | all | — |
| Input | 30001–30050 | all | — |
| Holding | 40001–40050 | 40001–40025 | 40026–40050 |
Every point is a row in a static descriptor table:
typedef struct {
const char *name; /**< Human-readable identifier. */
mb_sim_obj_type_t obj_type; /**< Coil, discrete, input or holding. */
uint16_t modbus_addr; /**< Documentation address (e.g. 40001, 00001). */
mb_sim_access_t access; /**< Read-only or read-write. */
bool signed_value; /**< Signed interpretation for master drivers. */
float scale; /**< Engineering scale factor. */
const char *unit; /**< Engineering unit string. */
const char *description; /**< Point description. */
mb_sim_bind_t bind; /**< Simulation/alarm binding. */
mb_sim_alarm_id_t alarm_id; /**< Associated alarm when applicable. */
} mb_sim_point_desc_t;
The bind field is what ties a register to the simulation engine (e.g. MB_SIM_BIND_TEMPERATURE) or to an alarm (e.g. MB_SIM_ALARM_HIGH_TEMP) without any hardcoded addresses scattered through the codebase — the simulation and alarm engines just search the table for a binding and write wherever it shows up, whether that’s an input register, a holding register mirror, or both.
Modbus Slave Layer#
The Modbus protocol handling is built on Espressif’s esp-modbus component (mbcontroller.h), with two callback hooks wired in for coils/discrete bits and a background FreeRTOS task that watches for read/write events:
mb_err_enum_t mbc_reg_coils_slave_cb(mb_base_t *inst, uint8_t *reg_buffer, uint16_t address,
uint16_t n_coils, mb_reg_mode_enum_t mode)
{
if (mode == MB_REG_READ) {
return mb_sim_bits_slave_read(reg_buffer, address, n_coils, false);
}
return mb_sim_bits_slave_write(reg_buffer, address, n_coils, false);
}
Every bit write is checked against the descriptor table’s access field before it’s applied:
if (!mb_sim_register_map_is_write_allowed(obj_type, protocol_addr, 1)) {
mb_sim_stats_record_exception();
return MB_ENOREG;
}
Writes to read-only points return Modbus exception 0x02 Illegal Data Address — the same behavior you’d get from a real PLC, which matters when you’re testing that a router correctly handles rejected writes instead of assuming everything succeeds.
A background event task logs every request (function code, address, quantity, processing time) into a ring buffer via logger.c, and tracks per-FC counters and packets-per-second in statistics.c — both surfaced back onto holding/input registers so a master can poll the simulator’s own health.
Supported Function Codes#
| FC | Description | Support |
|---|---|---|
| 01 | Read Coils | Yes |
| 02 | Read Discrete Inputs | Yes |
| 03 | Read Holding Registers | Yes |
| 04 | Read Input Registers | Yes |
| 05 | Write Single Coil | Yes (RW coils only) |
| 06 | Write Single Holding Register | Yes (RW holding only) |
| 15 | Write Multiple Coils | Yes (RW coils only) |
| 16 | Write Multiple Holding Registers | Yes (RW holding only) |
Simulation Engine#
A FreeRTOS task ticks on a configurable interval and drifts ~30 process variables — temperature, pressure, voltage, current, motor RPM, tank level, battery voltage — using a small xorshift-style PRNG seeded from esp_random():
static uint32_t sim_rand(void)
{
s_state.rng_state = s_state.rng_state * 1664525UL + 1013904223UL;
return s_state.rng_state;
}
Values respond to commands instead of drifting in isolation. For example, motor current and RPM depend on whether coil motor_enable (offset 25) is set:
const bool motor_on = mb_sim_bit_get(storage->coils, 25);
const float load = motor_on ? 650.0f : 50.0f;
s_state.current = mb_sim_lerp(load, 0.0f, 1000.0f, c_min, c_max);
...
s_state.motor_rpm = motor_on ? 1450.0f + sim_randf(-10.0f, 10.0f) : 0.0f;
Each simulated value is written to storage through sim_write_reg(), which looks up every point bound to that variable (it can be an input register and a holding register mirror) and writes both in one call — the table drives the fan-out, not a hardcoded list of addresses:
static void sim_write_reg(mb_sim_bind_t bind, uint16_t value)
{
mb_sim_storage_t *storage = mb_sim_register_map_storage();
size_t count = 0;
const mb_sim_point_desc_t *table = mb_sim_register_map_input_regs(&count);
for (size_t i = 0; i < count; i++) {
if (table[i].bind == bind) {
storage->input_regs[i] = value;
}
}
// ... repeats for holding_regs
}
At the end of every tick, the engine calls mb_sim_alarm_evaluate() so alarms react to the freshly-simulated values in the same cycle.
Alarm Engine#
Alarms are latched coils backed by a static table of 23 alarm definitions, each with a coil offset, a bitmask, and a severity (WARNING, ALARM, CRITICAL):
static mb_sim_alarm_entry_t s_alarms[] = {
{ MB_SIM_ALARM_ACTIVE, 0, 1UL << 0, MB_SIM_SEV_ALARM, false },
{ MB_SIM_ALARM_WARNING, 1, 1UL << 1, MB_SIM_SEV_WARNING, false },
{ MB_SIM_ALARM_CRITICAL, 2, 1UL << 2, MB_SIM_SEV_CRITICAL, false },
{ MB_SIM_ALARM_HIGH_TEMP, 3, 1UL << 3, MB_SIM_SEV_ALARM, false },
{ MB_SIM_ALARM_LOW_TEMP, 4, 1UL << 4, MB_SIM_SEV_ALARM, false },
// ...
};
mb_sim_alarm_evaluate() reads the current process values and thresholds from the (user-writable) configuration block and drives each alarm coil:
mb_sim_alarm_set_active(MB_SIM_ALARM_HIGH_TEMP, temp > cfg->temp_alarm_high, MB_SIM_SEV_ALARM);
mb_sim_alarm_set_active(MB_SIM_ALARM_LOW_TEMP, temp < cfg->temp_alarm_low, MB_SIM_SEV_ALARM);
mb_sim_alarm_set_active(MB_SIM_ALARM_ESTOP, estop, MB_SIM_SEV_CRITICAL);
mb_sim_alarm_set_active(MB_SIM_ALARM_FAN_FAIL, fan_on && !fan_fb, MB_SIM_SEV_ALARM);
Three “roll-up” coils — alarm_active, warning_active, critical_active — are recalculated from the combined alarm/warning bitmasks on every change, and every coil transition is logged:
ESP_LOGI(TAG, "Alarm coil %05u (%s) -> %s", doc_addr, name, active ? "TRUE" : "FALSE");
There’s also a built-in test pulse: writing 2 to holding register 40041 (AlZ) forces the high-temperature alarm TRUE for ~10 seconds and then auto-clears — handy for demoing alarm behavior to a router without waiting for the simulated process to actually drift out of range.
NowData Driver JSON Generation#
Since the whole point of this simulator is to test a Modbus master, the register map needs to be provisioned on that master as a driver definition. Rather than hand-maintain a JSON file that can drift out of sync with the firmware’s register map, driver/gen_driver.py generates it directly from the same coil/register tables used in register_map.c:
coils_ro = [
("AlA", 0, False, "Any alarm active"),
("WnA", 1, False, "Any warning active"),
("CrA", 2, False, "Any critical alarm active"),
("HtA", 3, False, "High temperature alarm"),
...
]
coils_rw = [
("MtE", 25, True, "Motor run enable"),
("FnE", 26, True, "Fan enable"),
("AkA", 32, True, "Alarm acknowledge command"),
("ArZ", 33, True, "Alarm reset command"),
...
]
Names use short 7-segment display labels (HtA, MtE) since that’s what fits on the router’s own display — the full text lives in description. A companion script, validate_driver.py, lints the generated JSON before it gets provisioned onto a router, and the router itself routes each coil to a different MQTT topic based on its writeable flag:
| Coil range | writeable | Router MQTT path |
|---|---|---|
| 00001–00023 | false | .../metrics/{name} |
| 00026–00050 | true | .../settings/{name} |
| 00001–00023 (alarm) | — | .../alarms/{name} on state change |
If alarm coils start showing up on .../settings/ instead of .../metrics/ and .../alarms/, that’s the signal the router’s driver JSON is stale and needs re-provisioning.
UART / RS485 Wiring#
ESP32-S3 MAX485/MAX3485 RS485 Bus
GPIO17 (TX) -------- DI
GPIO18 (RX) -------- RO
GPIO16 (RTS) ------- DE/~RE
A ------------------------ A (+)
B ------------------------ B (-)
GND ----------------- GND
- Default: 9600 8E1, slave ID 1, UART2
- Twisted-pair cable for A/B, with 120 Ω termination at each bus end
~REandDEtied together for automatic direction control via UART RTS — the firmware toggles RTS around each transmit/receive turnaround
Testing With Real Modbus Tools#
Modbus Poll / QModMaster#
Connection: RTU, COM port, 9600 8N1, Slave ID 1
Read: FC03 address 0 quantity 10 (holding), FC04 address 0 quantity 10 (input)
Write: FC05 coil 25 (motor_enable), FC06 holding 25 (simulation_enable = 1)
FC01 coil read (alarm verification)#
python3 tools/fc01_test.py /dev/cu.usbserial-XXXX --addr 4 --parity E
Reads coil 00004 (HtA — high temperature alarm). After forcing the test pulse or letting temperature drift high, this flips TRUE, then FALSE again once cleared.
Extending for a New Device#
Because the whole system is table-driven, simulating a different commercial device profile — the repo already ships an Eliwell IDPlus 974 refrigeration controller profile alongside the generic simulator — only requires:
- Editing the tables in
main/registers/register_map.c. - Adding simulation bindings in
simulation_engine.cif the new device needs process variables the generic profile doesn’t have. - Regenerating the NowData JSON with
python3 driver/gen_driver.py.
No changes to modbus_slave.c’s protocol handling are required — it only ever talks in terms of object type + protocol address + access rights, never in terms of a specific device.
Conclusion#
Testing a Modbus master against a hand-rolled register dump only gets you so far — it doesn’t tell you whether your router handles a rejected write correctly, whether it reacts to an alarm transition in real time, or whether its driver JSON actually matches what’s on the wire. Building a simulator that enforces read-only/read-write rules, latches real alarms, and drifts a believable process turns those into things you can actually exercise and watch happen, on a $10 ESP32-S3 board instead of a real (and expensive) industrial controller.
Key takeaways:
- Data-driven register tables mean the protocol layer, alarm engine, and simulation loop never need to know about a specific device — only the table does
- Generating the driver JSON from the same tables as the firmware keeps the router’s understanding of the device and the device itself from drifting apart
- Enforcing RO/RW access and Modbus exceptions makes the simulator behave like a real PLC instead of a register dump that accepts anything
The full source code is available at github.com/iam-vivekus/esp-modbus-simulator.
