Multi-Board Networking
Synchronise multiple Touch Boards over USB, ESP32 WiFi bridges, and MQTT for large distributed touch systems.
A single Touch Board gives you 12 electrodes. A network of Touch Boards gives you as many as you need, distributed across any physical space, all coordinated by a single software system. This tutorial builds progressively from the simplest multi-board setup (two boards on a USB hub) through to a fully wireless MQTT mesh where each board is an autonomous ESP32-based node.
Architecture Overview
Three networking topologies are covered, each suited to different scales:
| Topology | Scale | Latency | Complexity |
|---|---|---|---|
| USB hub + single host | 2–8 boards | < 5 ms | Low |
| USB + Ethernet (host on LAN) | 8–16 boards | < 15 ms | Medium |
| ESP32 WiFi + MQTT broker | 16–100+ boards | 10–50 ms | High |
For interactive art installations latency under 50 ms is imperceptible to most visitors; for live performance, under 15 ms is ideal.
Topology 1: USB Hub with Multiple Touch Boards
The simplest approach: plug multiple Touch Boards into a powered USB hub connected to a single host computer.
Enumerating All Boards
On Linux/macOS, each Touch Board appears as /dev/ttyACM* (Linux) or /dev/cu.usbmodem* (macOS). In Python:
import serial.tools.list_ports
import serial, threading, time, json
from queue import Queue
BAUD = 115200
def find_touch_boards():
"""Find all connected Touch Boards by USB vendor/product ID."""
boards = []
for port in serial.tools.list_ports.comports():
# Bare Conductive Touch Board: ATmega32U4 presents as Arduino
# VID 0x2341 (Arduino) or 0x2A03 (Arduino.org)
if port.vid in (0x2341, 0x2A03) or \
'Arduino' in (port.manufacturer or '') or \
'Bare' in (port.description or ''):
boards.append(port.device)
return boards
event_queue = Queue()
def board_reader(port_path, board_id):
"""Thread: read one Touch Board and push events to the queue."""
while True:
try:
with serial.Serial(port_path, BAUD, timeout=1) as ser:
print(f'Board {board_id}: connected on {port_path}')
buf = ''
while True:
data = ser.read(ser.in_waiting or 1)
buf += data.decode('ascii', errors='ignore')
while '\n' in buf:
line, buf = buf.split('\n', 1)
line = line.strip()
if line.startswith('T:'):
event_queue.put({'type':'touch','board':board_id,
'electrode':int(line[2:]),'t':time.time()})
elif line.startswith('R:'):
event_queue.put({'type':'release','board':board_id,
'electrode':int(line[2:]),'t':time.time()})
elif line.startswith('D:'):
vals = [int(v) for v in line[2:].split(',') if v.strip().isdigit()]
event_queue.put({'type':'proximity','board':board_id,
'values':vals,'t':time.time()})
except serial.SerialException as e:
print(f'Board {board_id}: {e} — retrying in 3s')
time.sleep(3)
def main():
ports = find_touch_boards()
if not ports:
print('No Touch Boards found.')
return
threads = []
for i, port in enumerate(ports):
t = threading.Thread(target=board_reader, args=(port, i), daemon=True)
t.start()
threads.append(t)
time.sleep(0.5) # stagger startup to avoid USB enumeration collision
print(f'Listening to {len(ports)} boards. Press Ctrl+C to stop.')
while True:
try:
event = event_queue.get(timeout=1)
handle_event(event)
except:
pass
def handle_event(event):
# Global electrode address = board_id * 12 + electrode
global_id = event['board'] * 12 + event.get('electrode', 0)
print(f"[{event['type']}] global_id={global_id} board={event['board']} t={event['t']:.3f}")
# Forward to p5.js / renderer via OSC or WebSocket
if __name__ == '__main__':
main()
Stable Board Identity
USB paths change on reconnect. Use the USB bus+port position (which is stable as long as you don’t change the physical USB port) to give each board a persistent ID:
import pyudev
def build_stable_id_map():
ctx = pyudev.Context()
id_map = {}
for dev in ctx.list_devices(subsystem='tty'):
parent = dev.find_parent('usb', 'usb_device')
if parent and parent.get('ID_VENDOR_ID') in ('2341', '2a03'):
# Use bus + devpath as stable key
key = parent.get('DEVPATH', '')
id_map[key] = dev.get('DEVNAME')
# Sort by key to get consistent ordering
return {i: path for i, (_, path) in enumerate(sorted(id_map.items()))}
Now board 0 is always the board in USB port 1, regardless of what /dev/ttyACM* number the OS assigns.
Topology 2: OSC Over LAN
For installations that span multiple rooms or buildings, run the event aggregator on a server and have display nodes connect over UDP/OSC.
Python OSC Publisher
from pythonosc import udp_client
import socket
# Discover display nodes from a configuration file
DISPLAY_NODES = [
('192.168.1.101', 7400),
('192.168.1.102', 7400),
]
clients = [udp_client.SimpleUDPClient(ip, port) for ip, port in DISPLAY_NODES]
def broadcast_event(event):
for client in clients:
if event['type'] == 'touch':
client.send_message('/touch',
[event['board'], event['electrode'], event['t']])
elif event['type'] == 'proximity':
client.send_message('/proximity',
[event['board']] + event['values'])
p5.js OSC Receiver (via node-osc bridge)
Browsers cannot receive UDP directly. Use a small Node.js process as a bridge that receives OSC and forwards events to the browser via WebSocket:
// bridge.js — run with: node bridge.js
const osc = require('osc');
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8081 });
const clients = new Set();
wss.on('connection', ws => {
clients.add(ws);
ws.on('close', () => clients.delete(ws));
});
const udpPort = new osc.UDPPort({ localAddress: '0.0.0.0', localPort: 7400 });
udpPort.on('message', msg => {
const data = JSON.stringify({ address: msg.address, args: msg.args });
for (const ws of clients) {
if (ws.readyState === WebSocket.OPEN) ws.send(data);
}
});
udpPort.open();
console.log('OSC bridge running — UDP 7400 → WS 8081');
// In p5.js sketch
let ws;
function setup() {
createCanvas(windowWidth, windowHeight);
ws = new WebSocket('ws://localhost:8081');
ws.onmessage = e => {
let msg = JSON.parse(e.data);
if (msg.address === '/touch') {
let [board, electrode, t] = msg.args;
onGlobalTouch(board * 12 + electrode);
} else if (msg.address === '/proximity') {
let board = msg.args[0];
let values = msg.args.slice(1);
onProximityUpdate(board, values);
}
};
}
Topology 3: ESP32 + MQTT Wireless Mesh
For installations where running USB cables is impractical, replace each Touch Board with an ESP32 wired to an MPR121 breakout. Each ESP32 connects to a WiFi access point and publishes events to an MQTT broker.
ESP32 Firmware
#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <Adafruit_MPR121.h>
// Configuration — store in NVS or a config header
const char* WIFI_SSID = "InstallationAP";
const char* WIFI_PASS = "your-password";
const char* MQTT_HOST = "192.168.4.1";
const int MQTT_PORT = 1883;
const int BOARD_ID = 0; // unique per board
Adafruit_MPR121 cap;
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
char topicBuf[64];
void connectWifi() {
WiFi.begin(WIFI_SSID, WIFI_PASS);
Serial.print("Connecting WiFi");
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print('.'); }
Serial.println(" connected. IP: " + WiFi.localIP().toString());
}
void connectMQTT() {
char clientId[32];
snprintf(clientId, sizeof(clientId), "touchboard-%d", BOARD_ID);
while (!mqtt.connected()) {
Serial.print("MQTT connect…");
if (mqtt.connect(clientId)) {
Serial.println(" ok");
// Publish "online" status
snprintf(topicBuf, sizeof(topicBuf), "tb/%d/status", BOARD_ID);
mqtt.publish(topicBuf, "online", true);
} else {
Serial.printf(" failed (state %d), retry in 5s\n", mqtt.state());
delay(5000);
}
}
}
void setup() {
Serial.begin(115200);
Wire.begin(21, 22); // SDA, SCL on ESP32 DevKit
if (!cap.begin(0x5A)) {
Serial.println("MPR121 not found");
while (1);
}
connectWifi();
mqtt.setServer(MQTT_HOST, MQTT_PORT);
}
uint16_t lastTouched = 0;
unsigned long lastProximity = 0;
void loop() {
if (!mqtt.connected()) connectMQTT();
mqtt.loop();
// Touch/release events
uint16_t touched = cap.touched();
if (touched != lastTouched) {
uint16_t changed = touched ^ lastTouched;
for (int i = 0; i < 12; i++) {
if (!(changed & (1 << i))) continue;
snprintf(topicBuf, sizeof(topicBuf), "tb/%d/touch", BOARD_ID);
char payload[32];
snprintf(payload, sizeof(payload), "%s:%d",
(touched & (1 << i)) ? "T" : "R", i);
mqtt.publish(topicBuf, payload);
}
lastTouched = touched;
}
// Proximity at 20 Hz
if (millis() - lastProximity >= 50) {
snprintf(topicBuf, sizeof(topicBuf), "tb/%d/proximity", BOARD_ID);
char payload[128];
int pos = 0;
for (int i = 0; i < 12; i++) {
int prox = max(0, (int)cap.baselineData(i) - (int)cap.filteredData(i));
pos += snprintf(payload + pos, sizeof(payload) - pos,
"%d%s", prox, i < 11 ? "," : "");
}
mqtt.publish(topicBuf, payload);
lastProximity = millis();
}
}
MQTT Broker Setup (Raspberry Pi)
# Install Mosquitto on RPi
sudo apt install mosquitto mosquitto-clients -y
sudo systemctl enable mosquitto
# /etc/mosquitto/conf.d/local.conf
# listener 1883
# allow_anonymous true
Python Aggregator (MQTT → ZMQ)
import paho.mqtt.client as mqtt
import zmq, json, time, re
ZMQ_ADDR = 'tcp://127.0.0.1:5555'
ctx = zmq.Context()
pub = ctx.socket(zmq.PUB)
pub.bind(ZMQ_ADDR)
def on_message(client, userdata, msg):
topic = msg.topic
payload = msg.payload.decode()
m = re.match(r'tb/(\d+)/(\w+)', topic)
if not m: return
board_id = int(m.group(1))
event_type = m.group(2)
if event_type == 'touch':
kind, electrode = payload.split(':')
pub.send_json({'type': 'touch' if kind=='T' else 'release',
'board': board_id, 'electrode': int(electrode),
't': time.time()})
elif event_type == 'proximity':
vals = [int(v) for v in payload.split(',')]
pub.send_json({'type': 'proximity', 'board': board_id,
'values': vals, 't': time.time()})
client = mqtt.Client()
client.on_message = on_message
client.connect('127.0.0.1', 1883)
client.subscribe('tb/#') # subscribe to all boards
client.loop_forever()
Synchronising Multiple Boards
When boards are on different computers or ESP32 nodes, timestamps drift. Use NTP on all nodes and tag every event with a Unix timestamp to correlate events across boards:
import ntplib, time
def sync_time():
try:
c = ntplib.NTPClient()
response = c.request('pool.ntp.org', version=3)
offset = response.offset
print(f'NTP offset: {offset*1000:.1f} ms')
return offset
except: return 0.0
TIME_OFFSET = sync_time()
def adjusted_time():
return time.time() + TIME_OFFSET
For real-time synchronisation (e.g. triggering sounds simultaneously across boards), use the timestamp to schedule events for the future and play them at a known wall-clock time using PTP (IEEE 1588) if sub-millisecond sync is needed.
Global Electrode Addressing
With N boards, define a global electrode address scheme:
global_id = board_id * 12 + electrode_id
This maps to a 1D address space: board 0 → 0–11, board 1 → 12–23, etc. For 2D layouts, define a physical position map:
# For a 4×3 grid of boards (48 electrodes total)
# Board 0 = top-left, board 3 = top-right, etc.
BOARD_POSITIONS = {
0: (0, 0), 1: (1, 0), 2: (2, 0), 3: (3, 0),
}
ELECTRODE_POSITIONS = {} # global_id → (x, y) in physical space
for board, (bx, by) in BOARD_POSITIONS.items():
for e in range(12):
row = e // 4
col = e % 4
global_id = board * 12 + e
ELECTRODE_POSITIONS[global_id] = (bx * 4 + col, by * 3 + row)
This lets you compute physical centroid, visualise touch on a spatial map, and define spatial gestures like “swipe across the whole wall”.
Network Resilience
Handling Board Dropout
In a live installation, boards may disconnect due to power cycling, cable faults, or WiFi dropouts. The system must not crash or hang:
class BoardMonitor:
def __init__(self, n_boards, timeout=5.0):
self.last_seen = {i: 0.0 for i in range(n_boards)}
self.online = {i: False for i in range(n_boards)}
self.timeout = timeout
def heartbeat(self, board_id):
self.last_seen[board_id] = time.time()
if not self.online[board_id]:
self.online[board_id] = True
print(f'Board {board_id} came online')
def check(self):
now = time.time()
for board_id, last in self.last_seen.items():
was_online = self.online[board_id]
is_online = (now - last) < self.timeout
if was_online and not is_online:
self.online[board_id] = False
print(f'Board {board_id} went offline')
elif is_online:
self.online[board_id] = True
Call monitor.heartbeat(board_id) on every received event and monitor.check() on a 1-second timer.
Next Steps
- Add end-to-end encryption to the MQTT channel with TLS client certificates for secure public installations
- Implement a distributed consensus algorithm (Raft) for master election when multiple Raspberry Pi nodes share processing responsibilities
- Explore Time-Sensitive Networking (IEEE 802.1Qbv) for deterministic Ethernet timing when sub-millisecond cross-board synchronisation is required
- Build a web-based configuration interface for setting board IDs, electrode thresholds, and topic subscriptions without reflashing firmware