Bare Conductive Expert Article 5

Multi-Board Networking

Synchronise multiple Touch Boards over USB, ESP32 WiFi bridges, and MQTT for large distributed touch systems.

⏱ 95 min networking multi-board ESP32 MQTT OSC synchronisation distributed

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