zam@zsbahtiar:~$

cat ~/notes/touch-ot.md

building a small ot/ics lab with a raspberry pi and esp32

[id]

building a small ot/ics lab with a raspberry pi and esp32

touch-ot logo

I needed a network full of OT/ICS devices to test a security scanner. Real PLCs, gateways, and field devices get expensive quickly, so I built the smallest useful lab from a Raspberry Pi 5 and an esp32.

The raspi impersonates several industrial systems at once. The esp32 provides the physical side: a temperature sensor, relays, and its own Modbus TCP server over Ethernet.

The point was not to reproduce a plant. It was to make protocol discovery fail in the same ways it does against real devices.

esp32 and w5500 ethernet connected with jumper wires on a desk

The lab in one table

I bought the parts from a local electronics store in Indonesia:

itemqtyprice
esp32 devkitc v4 wroom-32d + micro USB181k
w5500 ethernet LAN TCP/IP module165k
relay module 5V 4ch 30A optocoupler1110k
dht22 am2302 temperature and humidity sensor122k
LCD 1602 I2C green backlight127k
RGB LED 5mm 3-color53k
breadboard 830p112k
15cm dupont jumper wire pack136k

The subtotal was 359k IDR. Discounts and shipping brought the final price to 314k IDR. I already owned the raspi.

One raspi, several industrial identities

raspberry pi 5 running several OT protocols

The raspi runs three small protocol servers:

  • Modbus TCP on port 502, with PLC holding registers and coils
  • Siemens S7 on port 102, emulating a Siemens CPU 315-2 PN/DP through snap7
  • BACnet/IP on port 47808, emulating a building automation controller through bac0

I added mosquitto as an MQTT broker on port 1883 and snmpd on port 161. Every component runs as a systemd service.

port 22    - SSH
port 102   - Siemens S7
port 161   - SNMP
port 502   - Modbus TCP
port 1883  - MQTT
port 5353  - mDNS
port 47808 - BACnet/IP

From the scanner’s perspective, one raspi now looks like a small rack of industrial equipment. That is useful because scanners cannot assume one host has one protocol or one identity.

Giving the lab physical I/O

The esp32 connects to a w5500 Ethernet module over SPI. A dht22 supplies real temperature and humidity values, while a four-channel relay gives the scanner physical outputs to detect and control.

The wiring uses female-to-female jumpers. The esp32 devkitc is wide enough to cover every breadboard row from A through J, so the breadboard only distributes power and the controller sits beside it.

Two servers on one microcontroller

The first firmware used the Arduino Ethernet library for the w5500. An HTTP server worked. A Modbus server worked. Running both together crashed the esp32.

The library was not thread-safe under FreeRTOS, which allowed both server tasks to collide on the SPI bus.

I replaced it with ETH.h, the native ESP-IDF w5500 driver backed by lwIP. Both servers came up without the SPI race.

The firmware exposes:

  • a Modbus TCP server on port 1234
  • an HTTP status page on port 1111
  • temperature and humidity readings from the dht22
  • relay control over Modbus or HTTP
  • MEI with function code 0x2b
  • Report Slave ID with function code 0x11
  • Modbus exception responses for unsupported function codes

esp32 OT simulator web interface showing sensor data and relay control

Flashing the firmware on Ubuntu

I used the Arduino IDE with an upload speed of 115200.

On Ubuntu, brltty can claim the cp210x USB serial device before the IDE connects. Stopping and masking its udev service released the port:

sudo systemctl stop brltty-udev
sudo systemctl disable brltty-udev
sudo systemctl mask brltty-udev

Checking each protocol directly

The examples below use documentation-only IP addresses, not addresses from the lab.

Modbus to the esp32

from pymodbus.client import ModbusTcpClient

client = ModbusTcpClient('192.0.2.10', port=1234)
client.connect()
result = client.read_holding_registers(address=0, count=3)
print(f'temp: {result.registers[0] / 10.0} C')
# 31.9 C - real data from the dht22

S7 to the raspi

import snap7

client = snap7.Client()
client.connect('192.0.2.20', 0, 1)
info = client.get_cpu_info()
# CPU 315-2 PN/DP

BACnet to the raspi

import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(2)
packet = bytes([0x81, 0x0a, 0x00, 0x08, 0x01, 0x00, 0x10, 0x08])
sock.sendto(packet, ('192.0.2.20', 47808))
data, addr = sock.recvfrom(1024)
# 21 bytes - BACnet I-Am response

Each protocol gave the scanner a different identity and response shape.

The first Modbus scan failed

The scanner tried MEI, Report Slave ID, holding registers, and coils against the esp32. Every probe failed.

The firmware did not return a Modbus exception for an unsupported function code. It stayed silent and closed the connection.

The scanner tried MEI first, waited for a timeout, lost the socket, then hit a broken pipe on every later request. One unsupported probe poisoned the entire fingerprint sequence.

The fix belonged on both sides:

  • the firmware now returns an exception with function_code | 0x80
  • the scanner reconnects when a device drops the socket

After those changes, both hosts identified themselves:

esp32:
  method: report_slave_id
  slave_id: ESP32-IoT-Controller-v1.0.0

raspi:
  method: mei
  manufacturer: RaspberryPi-OT-Sim
  label: RPi5-PLC
  firmware_version: 1.0.0

The lab exposed a useful scanner rule: a failed optional probe must not prevent later probes from running.

The crash on the SPI bus

The original Ethernet stack failed with:

assert failed: xQueueSemaphoreTake queue.c:1709

FreeRTOS preemption let both server tasks access an unprotected SPI path. Moving to the native ETH.h driver removed that race and kept HTTP and Modbus alive together.

Inspecting the wire

Wireshark can decode every protocol in the lab.

The esp32 runs Modbus on port 1234 instead of the standard port, so Wireshark needs an explicit decoder mapping: Analyze, Decode As, TCP port 1234, then Modbus/TCP.

modbus  - Modbus TCP
s7comm  - Siemens S7
bacnet  - BACnet/IP
mqtt    - MQTT

This small setup exercises more than happy-path handshakes. It covers multiple protocols on one host, physical Ethernet I/O, unsupported function codes, dropped connections, reconnect behavior, and sensor values that actually change.

That is enough realism to make a scanner reveal bad assumptions before it reaches an industrial network.

The code is available at github.com/zsbahtiar/touch-ot.


← back to blog