Bare Conductive Expert Article 3

Large-Scale Touch Installations

Design, engineering, and operational practices for gallery-scale capacitive touch works that survive thousands of visitors.

⏱ 100 min installation gallery visitor-proof OSC networking durability

Scaling a capacitive touch interaction from a prototype to a gallery installation that operates for months without failure requires a fundamentally different engineering mindset. A prototype succeeds when it works in front of you. An installation succeeds when it works for ten thousand people you will never meet, over 90 days, while you are not there. This tutorial covers the full arc: hardware design, software architecture, network infrastructure, failure recovery, and operational monitoring.

Scoping the Installation

Before writing a line of code, answer these questions:

Physical scale: How large are the touch surfaces? How many? What material? What is the electrode-to-board wire run length?

Visitor behaviour: Will visitors be invited to touch? Will they touch unexpectedly? Will children climb on it? Will people touch it wet (from rain at an outdoor venue)?

Operational duration: Days? Weeks? Months? Does it need to run 24/7 or only during gallery hours?

Failure tolerance: Can a single board failure take down the whole work, or must it degrade gracefully?

Monitoring access: Will you have remote SSH access to the host computer? Can you view logs remotely? Is there someone on-site who can power-cycle hardware if needed?

These answers shape every subsequent design decision.

Hardware Architecture for Scale

Multi-board Topology

A single Touch Board provides 12 electrodes. A large work might need 48, 96, or more. Options:

USB hub + multiple boards: Each Touch Board connects as a USB CDC serial device. A single-board computer (Raspberry Pi 4) enumerates them as /dev/ttyACM0 through /dev/ttyACMN. Works well up to 8–10 boards (limited by USB hub reliability and enumeration stability).

I²C address expansion: The MPR121 supports four I²C addresses (0x5A–0x5D via ADDR pin). Connect four MPR121 breakouts to a single Arduino Mega or Raspberry Pi. This eliminates per-board MCUs but requires custom firmware.

Ethernet/WiFi bridge: Use ESP32 nodes (each with an MPR121) reporting over MQTT to a central broker. Scales to hundreds of electrodes with no USB at all.

Electrode Design for Durability

Material choices by durability:

Material Durability Sensitivity Notes
Copper sheet Excellent High Heavy, expensive, beautiful finish
Aluminium sheet Excellent High Lighter, anodise for colour
Stainless steel Excellent Medium Lower conductivity but very hard-wearing
Electric Paint on MDF Good Medium Seal with polyurethane for moisture resistance
Conductive fabric Fair Medium Tears at edges; needs backing board
Copper foil tape Poor High Peels at edges; OK for short runs

For a multi-year installation, use metal. For a temporary exhibition, Electric Paint on sealed MDF is faster to fabricate and cost-effective.

Electrode isolation: Each electrode must be electrically isolated from its neighbours and from any grounded metal structural elements. Use G10 fibreglass sheet, acrylic, or HDPE as mounting surfaces. Avoid using the venue’s structural steel as ground unless explicitly tested — floating ground loops create interference.

Wire routing: Use shielded cable (audio microphone cable, Belden 8723 or similar) for electrode runs over 50 cm. Ground the shield at the board end only (single-end grounding prevents ground loops). Longer runs increase parasitic capacitance and reduce sensitivity; compensate by raising the MPR121’s CDC (charge current) setting.

Calculating Maximum Wire Length

The MPR121 can tolerate up to ~150 pF of cable capacitance per electrode channel. Standard microphone cable is approximately 100 pF/metre. This gives a practical maximum run of about 1.5 metres before sensitivity degrades significantly. For longer runs:

  1. Reduce cable capacitance with lower-capacitance Ethernet twisted pair (50 pF/m) → up to 3 m
  2. Increase MPR121 charge current (CDC register bits) to drive higher capacitance loads
  3. Use a capacitive touch controller with higher drive capability (e.g. Cypress CY8CMBR3)
  4. Place the MPR121 close to the electrode (in a waterproofed enclosure behind the panel) and use a long serial/I²C run back to the main computer instead

Software Architecture

The Three-Process Model

For a robust installation, separate concerns into three independent processes that communicate over local sockets:

┌─────────────────────────────────────────────┐
│             Host Computer (RPi 4)           │
│                                             │
│  ┌──────────┐  ZMQ PUB  ┌──────────────┐   │
│  │  Sensor  │ ─────────▶│   State &    │   │
│  │  Reader  │           │   Logic      │   │
│  │ (Python) │           │  (Python)    │   │
│  └──────────┘           └──────┬───────┘   │
│                                │ OSC/ZMQ   │
│                         ┌──────▼───────┐   │
│                         │  Renderer    │   │
│                         │ (p5.js /     │   │
│                         │  openFrameworks│  │
│                         └──────────────┘   │
└─────────────────────────────────────────────┘

Sensor Reader: Reads raw serial data from all Touch Boards. Validates, parses, and publishes normalised touch + proximity events on a ZeroMQ PUB socket. Handles board reconnection automatically.

State & Logic: Subscribes to sensor events. Maintains the interaction state machine. Implements chord detection, gesture recognition, and hold logic. Publishes high-level events (OSC or ZMQ) to the renderer.

Renderer: Receives high-level events and renders visuals/audio. Is the only process that depends on display hardware. Can be restarted independently without losing sensor connectivity.

Python Sensor Reader

import serial
import serial.tools.list_ports
import threading
import zmq
import time
import re

BAUD = 115200
ZMQ_ADDR = 'tcp://127.0.0.1:5555'

context   = zmq.Context()
publisher = context.socket(zmq.PUB)
publisher.bind(ZMQ_ADDR)

def find_boards():
    """Return list of serial ports with Touch Board firmware."""
    boards = []
    for port in serial.tools.list_ports.comports():
        # Touch Board presents as Arduino (Bare Conductive) VID/PID
        if port.vid == 0x2341 or 'Arduino' in (port.manufacturer or ''):
            boards.append(port.device)
    return boards

def read_board(device_path, board_id):
    """Read from one Touch Board and publish events."""
    while True:
        try:
            with serial.Serial(device_path, BAUD, timeout=2) as ser:
                print(f'[Board {board_id}] Connected: {device_path}')
                buf = ''
                while True:
                    chunk = ser.read(64).decode('ascii', errors='ignore')
                    if not chunk:
                        continue
                    buf += chunk
                    while '\n' in buf:
                        line, buf = buf.split('\n', 1)
                        parse_and_publish(line.strip(), board_id)
        except serial.SerialException as e:
            print(f'[Board {board_id}] Error: {e} — retrying in 2s')
            time.sleep(2)

def parse_and_publish(line, board_id):
    if m := re.match(r'^T:(\d+)$', line):
        publisher.send_json({'type':'touch','board':board_id,'electrode':int(m[1]),'t':time.time()})
    elif m := re.match(r'^R:(\d+)$', line):
        publisher.send_json({'type':'release','board':board_id,'electrode':int(m[1]),'t':time.time()})
    elif m := re.match(r'^D:(.+)$', line):
        values = [int(v) for v in m[1].split(',') if v.strip().isdigit()]
        publisher.send_json({'type':'proximity','board':board_id,'values':values,'t':time.time()})

def main():
    boards = find_boards()
    print(f'Found {len(boards)} boards: {boards}')
    threads = []
    for i, path in enumerate(boards):
        t = threading.Thread(target=read_board, args=(path, i), daemon=True)
        t.start()
        threads.append(t)
    for t in threads:
        t.join()

if __name__ == '__main__':
    main()

Auto-reconnect and Board Identity

Touch Boards enumerate as different /dev/ttyACM* paths after USB reconnect. Pin board identities by USB bus position (which is stable) rather than device name:

import pyudev

def get_stable_path(vendor_id=0x2341):
    """Map USB bus+device position to stable identifier."""
    ctx = pyudev.Context()
    result = {}
    for dev in ctx.list_devices(subsystem='tty'):
        parent = dev.find_parent('usb', 'usb_device')
        if parent and parent.get('ID_VENDOR_ID') == hex(vendor_id)[2:].zfill(4):
            busnum  = parent.get('BUSNUM')
            devpath = parent.get('DEVPATH')
            stable_id = f'{busnum}-{devpath}'
            result[stable_id] = dev.get('DEVNAME')
    return result

Watchdog and Auto-restart

Run each process under systemd with Restart=always:

# /etc/systemd/system/touch-sensor.service
[Unit]
Description=Touch Board Sensor Reader
After=network.target

[Service]
ExecStart=/usr/bin/python3 /opt/installation/sensor_reader.py
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Enable with systemctl enable touch-sensor && systemctl start touch-sensor. Logs are collected with journalctl -u touch-sensor -f.

Visitor-proofing

Debounce and Rate Limiting

Public-facing installations receive far more touch events per second than a prototype ever will. Two children pressing the same electrode simultaneously, palms instead of fingertips, and gloved hands all generate unusual signals. Add per-electrode debounce and per-system event rate limiting:

import time
from collections import defaultdict

DEBOUNCE_MS = 80
MAX_EVENTS_PER_SEC = 30

class EventGuard:
    def __init__(self):
        self.last_touch_time = defaultdict(float)
        self.event_times     = []

    def allow_touch(self, board, electrode):
        key = (board, electrode)
        now = time.time()
        if now - self.last_touch_time[key] < DEBOUNCE_MS / 1000:
            return False
        # Global rate limit
        self.event_times = [t for t in self.event_times if now - t < 1.0]
        if len(self.event_times) >= MAX_EVENTS_PER_SEC:
            return False
        self.last_touch_time[key] = now
        self.event_times.append(now)
        return True

Idle and Attract Mode

If no touch events occur for N minutes, switch to an attract mode — ambient animation that invites interaction. Reset to full interactive mode on any touch:

// p5.js renderer
let lastInteractionTime = Date.now();
const ATTRACT_TIMEOUT   = 3 * 60 * 1000; // 3 minutes
let isAttractMode       = false;

function draw() {
  let idle = Date.now() - lastInteractionTime > ATTRACT_TIMEOUT;
  if (idle !== isAttractMode) {
    isAttractMode = idle;
    transitionToMode(isAttractMode ? 'attract' : 'interactive');
  }
  if (isAttractMode) drawAttract();
  else               drawInteractive();
}

function onTouch(board, electrode) {
  lastInteractionTime = Date.now();
  // ... interaction logic
}

Thermal Management

The Raspberry Pi 4 throttles under sustained load in a sealed enclosure. Monitor CPU temperature and take protective action:

import subprocess

def cpu_temp():
    result = subprocess.run(['vcgencmd', 'measure_temp'], capture_output=True, text=True)
    return float(result.stdout.replace('temp=','').replace("'C\n",''))

def thermal_check():
    temp = cpu_temp()
    if temp > 80:
        logging.warning(f'CPU temp {temp}°C — consider reducing visual complexity')
    if temp > 85:
        # Reduce render quality
        publish({'type': 'quality', 'level': 'low'})

Remote Monitoring

Health Dashboard

Run a lightweight Flask server on the installation computer that serves a status page accessible via the venue’s WiFi:

from flask import Flask, jsonify
import psutil, time

app   = Flask(__name__)
stats = {'boards_online': 0, 'events_today': 0, 'uptime': 0, 'cpu_temp': 0}

@app.route('/health')
def health():
    stats['cpu_temp']  = cpu_temp()
    stats['uptime']    = int(time.time() - start_time)
    stats['cpu_pct']   = psutil.cpu_percent()
    stats['mem_pct']   = psutil.virtual_memory().percent
    return jsonify(stats)

if __name__ == '__main__':
    start_time = time.time()
    app.run(host='0.0.0.0', port=8080)

Event Logging for Analytics

Log all touch events to SQLite for post-show analysis:

import sqlite3
from datetime import datetime

conn = sqlite3.connect('/var/log/installation/events.db')
conn.execute('''CREATE TABLE IF NOT EXISTS events
                (id INTEGER PRIMARY KEY, ts REAL, board INT,
                 electrode INT, type TEXT)''')
conn.commit()

def log_event(event):
    conn.execute('INSERT INTO events (ts, board, electrode, type) VALUES (?,?,?,?)',
                 (event['t'], event['board'], event['electrode'], event['type']))
    conn.commit()

After the show you can query: which electrode received the most touches? What time of day was the installation most active? How many unique interaction sessions occurred?

Pre-installation Checklist

  • All electrodes tested individually with a multimeter for resistance and connectivity
  • All wire runs tested with the MPR121 at full length; sensitivity confirmed acceptable
  • Board firmware tested with 48-hour continuous run on bench
  • All three processes running under systemd with Restart=always
  • Remote monitoring endpoint accessible from your phone
  • Power supply sized for 125% of measured peak current draw
  • All USB connections secured with zip ties or strain-relief clips
  • Raspberry Pi in a ventilated, dustproof enclosure
  • SD card replaced with a quality industrial card (SanDisk Industrial, Swissbit)
  • Emergency shutdown procedure documented and shared with venue staff
  • Spare Touch Boards, SD cards, and USB cables packed in the installation kit
  • Nightly cron job to restart processes and clear log files older than 30 days

Next Steps

  • Add a Grafana + InfluxDB stack for real-time telemetry visualisation accessible remotely
  • Implement A/B testing: randomly vary interaction mappings between sessions and compare engagement metrics
  • Use OpenCV on the Raspberry Pi to count visitors in front of the installation and correlate with touch data
  • Write a venue-friendly one-page operating manual with troubleshooting flowcharts for non-technical staff