Advanced OOCSI: Designing Multi-Device Systems

So, you have worked through the OOCSI basics and intermediate guides. You know how to send messages between devices, share variables, and filter data on channels.

Now comes the exciting part of interactive design: designing systems of connected products.

When you prototype an interactive room, an escape game, an exhibition pavilion, or a collection of wearable devices, you are no longer connecting just one sensor to one computer. You are orchestrating a group of independent physical devices into a single, cohesive user experience.


The 5 System Design Patterns

flowchart TD
    subgraph S1["1. Distributed State Machine"]
        COORD["State Coordinator"] -->|room_state: 'SUCCESS'| B1["OOCSI Broker"] --> ACT1["Smart Lights, Audio & Lock"]
    end

    subgraph S2["2. Shared Heartbeats"]
        CONDUCTOR["Master Conductor"] -->|beat: 60 BPM| B2["OOCSI Broker"] --> ACT2["Pulsing Lamps & Wearables"]
    end

    subgraph S3["3. Dynamic Role Allocation"]
        ROSTER["Table Coordinator"] -->|assign: 'Drums'| B3["OOCSI Broker"] --> ACT3["Modular Smart Blocks"]
    end

    subgraph S4["4. Collective Sensing"]
        SENSORS["Table Sensors (1..3)"] -->|raw readings| AGG["Room Aggregator"] --> CHANDELIER["Ambient Chandelier"]
    end

    subgraph S5["5. Group Consensus & Voting"]
        VOTERS["Voter Stations (1..3)"] -->|submit ballots| TALLY["Poll Coordinator"] --> ALL["Synchronized Room Devices"]
    end

In the sections below, we explore five foundational patterns that help you tackle common multi-device challenges:

  1. Distributed State Machine: Keeping entire rooms and spaces synchronized across different interactive phases.
  2. Shared Heartbeats & Synchronization: Keeping lamps, wearables, and sounds pulsing in unison without drifting apart.
  3. Dynamic Role Allocation: Letting identical modular objects automatically discover each other and assign roles.
  4. Collective Sensing & Data Aggregation: Combining data from multiple dispersed sensors into a single ambient summary.
  5. Group Consensus & Voting: Allowing autonomous devices to propose, vote, and agree on a collective choice.

1. Distributed State Machine: Orchestrating Room Phases

The Design Scenario

Imagine you are building an interactive escape room, a museum exhibition, or an interactive smart meeting space. The experience moves through clear story phases:

  • STANDBY: Ambient blue light, gentle background music, sensors waiting for visitors.
  • EXPLORATION: Main room lights turn on, puzzle props become active, visitors interact with buttons and sensors.
  • SUCCESS: The puzzle is solved! Room lights pulse green, a celebratory chime sounds, and an electric magnetic lock opens a secret compartment.
  • RESET: The installation smoothly returns to standby for the next team.

When the room changes phase, you do not want to send individual instructions to dozens of lamps, speakers, and motors one by one. You want one global announcement that makes every device in the space adapt immediately.

stateDiagram-v2
    [*] --> STANDBY: System Powered On
    STANDBY --> EXPLORATION: Visitor triggers entry sensor
    EXPLORATION --> SUCCESS: Puzzle sensors solved
    SUCCESS --> RESET: Timeout (30s) or facilitator click
    RESET --> STANDBY: Ready for next visitors

The Design Pattern

A central device—such as a laptop, a Raspberry Pi, or a control tablet running Python or JavaScript—acts as the State Coordinator. It listens for puzzle completion events, decides when a state change happens, and broadcasts the new state name on a dedicated channel (e.g. room/state).

sequenceDiagram
    autonumber
    actor Player as Visitors
    participant Puzzle as Puzzle Sensor (ESP32)
    participant Coord as State Coordinator (Python)
    participant Broker as OOCSI Broker
    participant Actuators as Room Actuators (Lights, Audio, Lock)

    Player->>Puzzle: Solve final puzzle step
    Puzzle->>Broker: Send {"puzzle_solved": true} to 'room/events'
    Broker->>Coord: Forward event
    Note over Coord: Transition:<br/>EXPLORATION -> SUCCESS
    Coord->>Broker: Broadcast {"state": "SUCCESS", "_RETAIN": 3600} to 'room/state'
    par Coordinated Phase Transition
        Broker->>Actuators: Deliver to Lights -> Pulse green glow
    and
        Broker->>Actuators: Deliver to Audio -> Play victory fanfare
    and
        Broker->>Actuators: Deliver to Lock -> Release door magnet
    end

The Prototyping Secret: Retained Messages (_RETAIN)

What happens if one of your ESP32 lamps loses power or reboots halfway through the game? If you only send the state transition once, the rebooted lamp will wake up not knowing what phase the room is in!

By adding " _RETAIN": 3600 (retained for 1 hour) to your state message, the OOCSI server remembers the current state. The instant any rebooted device reconnects and subscribes to room/state, the server immediately hands it the latest state message. Your devices never lose track of reality.

Code Examples

State Coordinator (Python)

This script manages the room phases and broadcasts retained updates:

from oocsi import OOCSI
import time

# Connect to OOCSI with a unique handle
oocsi = OOCSI('room_coordinator_####', 'localhost')

# Available room states
STATES = ['STANDBY', 'EXPLORATION', 'SUCCESS', 'RESET']
current_state = 'STANDBY'

def broadcast_state(new_state):
    global current_state
    current_state = new_state
    print(f"[*] Transitioning room state to: {current_state}")
    
    # Broadcast to 'room/state' with _RETAIN so rebooted nodes catch up immediately
    oocsi.send('room/state', {
        'state': current_state,
        '_RETAIN': 3600  # Remember for 1 hour on server
    })

def handle_room_events(sender, recipient, data):
    # React to sensors in the room
    if data.get('action') == 'player_entered' and current_state == 'STANDBY':
        broadcast_state('EXPLORATION')
    elif data.get('puzzle_solved') == True and current_state == 'EXPLORATION':
        broadcast_state('SUCCESS')
    elif data.get('action') == 'force_reset':
        broadcast_state('STANDBY')

# Subscribe to incoming sensor triggers
oocsi.subscribe('room/events', handle_room_events)

# Publish initial state on startup
broadcast_state('STANDBY')

# Keep running
while True:
    time.sleep(1)

Interactive Prop Node (ESP32 / Arduino C++)

This microcontroller controls an ambient indicator LED and an electric lock servo, listening for room state updates:

#include "OOCSI.h"

const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* hostserver = "oocsi.example.com";
const char* OOCSIName = "door_prop_####";

OOCSI oocsi = OOCSI();

// Pin definitions
const int LED_PIN = 2;

// Callback triggered whenever room state changes (or immediately on reconnect via _RETAIN!)
void handleStateChange() {
  String state = oocsi.getString("state", "STANDBY");
  Serial.print("Prop received new room state: ");
  Serial.println(state);

  if (state == "STANDBY") {
    // Dim light, lock mechanism
    digitalWrite(LED_PIN, LOW);
  } else if (state == "EXPLORATION") {
    // Normal ambient light, armed puzzle
    digitalWrite(LED_PIN, HIGH);
  } else if (state == "SUCCESS") {
    // Unlock door and flash light
    for (int i = 0; i < 5; i++) {
      digitalWrite(LED_PIN, HIGH); delay(100);
      digitalWrite(LED_PIN, LOW);  delay(100);
    }
    digitalWrite(LED_PIN, HIGH);
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);

  // Connect to WiFi and OOCSI
  oocsi.connect(OOCSIName, hostserver, ssid, password, handleStateChange);

  // Subscribe to the shared state channel
  oocsi.subscribe("room/state");
}

void loop() {
  // Service OOCSI network events
  oocsi.check();
  delay(20);
}

2. Shared Heartbeats & Synchronization: Marching to the Same Beat

The Design Scenario

Imagine you are designing five ambient lamps that gently pulse together like a calm collective heartbeat, or a set of wearable wristbands that buzz in unison during a group meditation workshop.

If you program each microcontroller with a simple loop like delay(1000); blink();, you will encounter a classic physical computing problem: clock drift. The tiny crystal timing oscillators on microcontrollers are never 100% identical. Even a difference of 0.05% means that after just three minutes, the lights will visibly fall out of step and flash haphazardly.

sequenceDiagram
    autonumber
    participant Conductor as Master Conductor (Python)
    participant Broker as OOCSI Broker ('tempo/pulse')
    participant Lamp as Ambient Lamps (ESP32)
    participant Wearable as Haptic Wearables (ESP32)

    Note over Conductor: 60 BPM = 1 beat every 1000ms
    loop Periodic Heartbeat
        Conductor->>Broker: Broadcast {"beat": 1, "bpm": 60}
        par Synchronized Realignment
            Broker->>Lamp: Pulse arrived -> Snap animation phase to 0
        and
            Broker->>Wearable: Pulse arrived -> Trigger haptic tap in rhythm
        end
    end

The Design Pattern (The Conductor)

Instead of letting each device count time independently, designate one device as the Conductor (or use a server-generated timer).

  • The Conductor broadcasts a short pulse message at regular musical intervals (e.g. 60 BPM = 1 pulse per second) to tempo/pulse.
  • Each follower device runs its own smooth local animation loop (e.g. fading an LED brightness smoothly using a sine wave).
  • The trick: Every time an OOCSI beat message arrives, the follower snaps its animation counter back to 0. If a device was running slightly too fast or slow, it gently realigns on every beat. All devices stay marching to the exact same rhythm indefinitely!

Code Examples

Master Conductor (Python)

Broadcasts steady, rhythmic heartbeat pulses at a configurable tempo (BPM):

from oocsi import OOCSI
import time

oocsi = OOCSI('conductor_clock_####', 'localhost')

BPM = 60                       # 60 beats per minute
INTERVAL = 60.0 / BPM          # 1.0 second per beat
beat_counter = 0

print(f"[*] Conductor running at {BPM} BPM (1 beat every {INTERVAL:.2f}s)")

while True:
    beat_counter += 1
    
    # Broadcast beat pulse
    oocsi.send('tempo/pulse', {
        'beat': beat_counter,
        'bpm': BPM,
        'timestamp': int(time.time() * 1000)
    })
    
    print(f"  -> Beat {beat_counter} sent")
    time.sleep(INTERVAL)

Synchronized Follower Lamp (ESP32 / Arduino C++)

Smoothly animates its light and resets its animation phase whenever a pulse arrives:

#include "OOCSI.h"
#include <math.h>

const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* hostserver = "oocsi.example.com";
const char* OOCSIName = "sync_lamp_####";

OOCSI oocsi = OOCSI();

const int LED_PIN = 2;
unsigned long lastBeatTime = 0;
float beatPeriodMs = 1000.0;    // Default 1000ms (60 BPM)

// Callback triggered whenever the Conductor sends a beat
void onBeatPulse() {
  int beat = oocsi.getInt("beat", 0);
  int bpm = oocsi.getInt("bpm", 60);
  beatPeriodMs = (60.0 / bpm) * 1000.0;

  // The secret: reset local timing phase to match the conductor!
  lastBeatTime = millis();
  Serial.print("Beat in step: ");
  Serial.println(beat);
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);

  oocsi.connect(OOCSIName, hostserver, ssid, password, onBeatPulse);
  oocsi.subscribe("tempo/pulse");
}

void loop() {
  oocsi.check();

  // Calculate where we are in the current breath cycle (0.0 to 1.0)
  unsigned long elapsed = millis() - lastBeatTime;
  if (elapsed > beatPeriodMs) elapsed = beatPeriodMs;
  float progress = (float)elapsed / beatPeriodMs;

  // Smooth sinusoidal breathing curve: 0 -> 1 -> 0
  float brightness = (sin(progress * 2.0 * PI - (PI / 2.0)) + 1.0) / 2.0;

  // Drive LED (PWM / analog output)
  analogWrite(LED_PIN, (int)(brightness * 255));
  delay(15);
}

3. Dynamic Role Allocation: Interchangeable Smart Objects

The Design Scenario

Suppose you build four identical smart wooden blocks with an RGB LED and an accelerometer for a tangible music sequencer table.

  • Rather than permanently flashing “Block #1 is Drums, Block #2 is Bass, Block #3 is Melody” into their chips, you want the blocks to be completely interchangeable.
  • If a visitor picks up any block and places it on the table, it automatically gets assigned the next available role.
  • If a block runs out of battery or is removed from the table, its role is freed up for another block.
sequenceDiagram
    autonumber
    actor User as Designer / User
    participant Block as New Smart Block (ESP32)
    participant Broker as OOCSI Broker ('blocks/lobby')
    participant Manager as Table Manager (Python)

    User->>Block: Powers on block
    Block->>Broker: Connects as 'block_7842' & subscribes to 'blocks/lobby'
    Broker->>Manager: Channel presence event (JOIN: 'block_7842')
    Note over Manager: Check available roles:<br/>['DRUMS', 'BASS', 'LEAD']<br/>Assign next: 'DRUMS'
    Manager->>Broker: Send direct message to 'block_7842':<br/>{"role": "DRUMS", "color": [255, 0, 0]}
    Broker->>Block: Deliver private role message
    Note over Block: Adopt 'DRUMS' role!<br/>Turn LED Red & activate drum samples
    Block->>Broker: Send to 'blocks/lobby':<br/>{"status": "READY", "role": "DRUMS"}

The Design Pattern

  1. Every device connects with random digits in its handle (e.g. block_####), preventing connection name collisions.
  2. All devices subscribe to a shared meeting channel (e.g. blocks/lobby).
  3. A Table Manager listens for channel presence events (join and leave).
  4. When a new node joins, the manager picks an available role and sends a private, direct OOCSI message specifically to that client handle (oocsi.message(client_handle, {"role": "..."})).
  5. When a node leaves or disconnects, the manager returns that role to the available pool.

Code Examples

Table Manager (Python)

Tracks available roles and assigns them dynamically to incoming smart blocks:

from oocsi import OOCSI
import time

oocsi = OOCSI('table_manager_####', 'localhost')

# Available roles to distribute
AVAILABLE_ROLES = {
    'DRUMS':  {'color': 'red',   'assigned_to': None},
    'BASS':   {'color': 'blue',  'assigned_to': None},
    'LEAD':   {'color': 'green', 'assigned_to': None},
    'CHORDS': {'color': 'amber', 'assigned_to': None}
}

def assign_role_to_client(client_handle):
    # Find the first unassigned role
    for role_name, info in AVAILABLE_ROLES.items():
        if info['assigned_to'] is None:
            info['assigned_to'] = client_handle
            print(f"[*] Assigning role '{role_name}' to client: {client_handle}")
            
            # Send private direct message to the specific device
            oocsi.send(client_handle, {
                'action': 'assign_role',
                'role': role_name,
                'color': info['color']
            })
            return
    print(f"[!] No roles left for client: {client_handle}")

def handle_lobby_events(sender, recipient, data):
    # Handle presence join events or manual registration greetings
    if data.get('action') == 'request_role':
        assign_role_to_client(sender)

oocsi.subscribe('blocks/lobby', handle_lobby_events)
print("[*] Table Manager listening for new smart blocks on 'blocks/lobby'...")

while True:
    time.sleep(1)

Modular Smart Block (ESP32 / Arduino C++)

Powers on without a fixed identity, requests a role, and adapts its personality:

#include "OOCSI.h"

const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* hostserver = "oocsi.example.com";
// Use #### so the server assigns random digits: e.g. "block_4821"
const char* OOCSIName = "block_####";

OOCSI oocsi = OOCSI();
String myRole = "UNASSIGNED";

// Callback for direct messages sent privately to THIS block
void onDirectMessage() {
  if (oocsi.has("role")) {
    myRole = oocsi.getString("role", "UNASSIGNED");
    String color = oocsi.getString("color", "white");
    
    Serial.print("[*] Role assigned! I am now: ");
    Serial.println(myRole);
    Serial.print("    LED color configured to: ");
    Serial.println(color);
  }
}

void setup() {
  Serial.begin(115200);

  // Connect and set onDirectMessage as the personal message handler
  oocsi.connect(OOCSIName, hostserver, ssid, password, onDirectMessage);

  // Join the lobby channel and request a role from the manager
  oocsi.subscribe("blocks/lobby");
  
  oocsi.newMessage("blocks/lobby");
  oocsi.addString("action", "request_role");
  oocsi.sendMessage();
}

void loop() {
  oocsi.check();

  // If we have an assigned role, perform role-specific interactions
  if (myRole == "DRUMS") {
    // Read accelerometer tap -> send drum beat
  } else if (myRole == "BASS") {
    // Read tilt angle -> send bass note
  }

  delay(50);
}

4. Collective Sensing & Data Aggregation: Room-Wide Awareness

The Design Scenario

Consider an interactive office, study lounge, or cafe where you place small sensor pucks on each table measuring noise and ambient light. Overhead in the center of the ceiling hangs a large dynamic chandelier.

  • When the whole space is quiet and dimly lit, the chandelier emits a gentle warm amber glow.
  • When multiple tables become noisy and active, the chandelier brightens and shifts to an invigorating crisp white.
flowchart TD
    subgraph Sensors["1. Distributed Table Sensors (Data Producers)"]
        T1["Table Puck 1 (ESP32)"]
        T2["Table Puck 2 (ESP32)"]
        T3["Table Puck 3 (ESP32)"]
    end

    subgraph Intake["2. Raw Intake Channel"]
        RAW["Channel: 'space/tables/raw'<br/>Frequent individual sensor bursts"]
    end

    subgraph Pipeline["3. Data Aggregator (Python / OOCSI Filter)"]
        AVG["Rolling 10s Window Buffer<br/>Calculates mean noise & light across room"]
    end

    subgraph Summary["4. Clean Summary Channel"]
        SUMM["Channel: 'space/room/summary'<br/>One calm, smoothed room metric every 5s"]
    end

    subgraph Actuators["5. Ambient Output (Consumers)"]
        CHAND["Ambient Chandelier (LEDs)"]
        DASH["Facilitator Web Dashboard"]
    end

    T1 & T2 & T3 -->|Post raw readings every 3s| RAW
    RAW --> AVG
    AVG -->|Publish smoothed average| SUMM
    SUMM --> CHAND & DASH

The Design Pattern

If every table sensor sent messages directly to the chandelier, the chandelier would suffer from message flooding and jittery light flickering.

Instead, separate sensing from acting:

  1. All sensor nodes publish their raw data to an intake channel (space/tables/raw).
  2. An Aggregator (which can be a Python script or an OOCSI server-side sliding window transform) collects readings from all tables over a rolling 10-second window.
  3. The Aggregator calculates smooth, room-wide averages and publishes a single, tidy message to space/room/summary.
  4. The chandelier only listens to the summary channel, creating calm, stable, organic ambient lighting.

Code Examples

Table Sensor Node (ESP32 / Arduino C++)

Periodically measures local sound and light and posts to the raw stream:

#include "OOCSI.h"

const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* hostserver = "oocsi.example.com";
const char* OOCSIName = "table_sensor_####";

OOCSI oocsi = OOCSI();
unsigned long lastSend = 0;

void setup() {
  Serial.begin(115200);
  oocsi.connect(OOCSIName, hostserver, ssid, password);
}

void loop() {
  oocsi.check();

  // Publish sensor reading every 2 seconds
  if (millis() - lastSend > 2000) {
    lastSend = millis();

    int soundLevel = analogRead(34);  // Microphone sensor pin
    int lightLevel = analogRead(35);  // Light sensor pin

    oocsi.newMessage("space/tables/raw");
    oocsi.addInt("sound", soundLevel);
    oocsi.addInt("light", lightLevel);
    oocsi.sendMessage();

    Serial.println("[*] Table reading sent");
  }
}

Ambient Aggregator & Chandelier Driver (Python)

Collects all table readings, calculates a smoothed average, and drives the room experience:

from oocsi import OOCSI
import time

oocsi = OOCSI('room_aggregator_####', 'localhost')

# Storage for recent readings across all tables
recent_sound_readings = []
recent_light_readings = []
WINDOW_SIZE = 20  # Keep latest 20 readings

def handle_raw_sensor(sender, recipient, data):
    global recent_sound_readings, recent_light_readings
    
    sound = data.get('sound')
    light = data.get('light')
    
    if sound is not None and light is not None:
        recent_sound_readings.append(sound)
        recent_light_readings.append(light)
        
        # Keep window bounded
        if len(recent_sound_readings) > WINDOW_SIZE:
            recent_sound_readings.pop(0)
            recent_light_readings.pop(0)

oocsi.subscribe('space/tables/raw', handle_raw_sensor)
print("[*] Aggregator calculating room environment...")

while True:
    if recent_sound_readings:
        avg_sound = sum(recent_sound_readings) / len(recent_sound_readings)
        avg_light = sum(recent_light_readings) / len(recent_light_readings)
        
        # Determine ambient lighting mood
        mood = 'CALM_WARM' if avg_sound < 1500 else 'ENERGETIC_WHITE'
        
        # Publish clean summary for the chandelier
        oocsi.send('space/room/summary', {
            'avg_sound': round(avg_sound, 1),
            'avg_light': round(avg_light, 1),
            'mood': mood
        })
        print(f"  -> Room mood: {mood} (Sound: {avg_sound:.1f}, Light: {avg_light:.1f})")

    time.sleep(3)  # Publish smooth summary every 3 seconds

5. Group Consensus & Negotiation: Taking a Group Vote

The Design Scenario

Imagine you are building a collaborative music jukebox, an installation where four visitors sit on interactive swings to agree on an atmosphere, or an interactive voting pedestal in an exhibition.

  • No single visitor or device gets to dictate what happens next.
  • The installation periodically prompts participants to cast a vote (e.g. choose between FOREST, OCEAN, or DESERT).
  • Once the voting countdown ends, the votes are tallied, and every device in the installation adopts the winning choice together!
sequenceDiagram
    autonumber
    participant Coord as Poll Coordinator (Python)
    participant Broker as OOCSI Broker
    participant Stations as Voting Stations (ESP32)
    participant Audio as Sound & Light Installation

    Coord->>Broker: Broadcast: {"action": "OPEN_POLL", "poll_id": 42, "timeout": 5}
    Broker->>Stations: Prompt visitors to vote

    Note over Stations,Coord: Voting Window (5 seconds)
    Stations->>Broker: Post ballots: {"poll_id": 42, "vote": "OCEAN"}
    Broker->>Coord: Collect ballots

    Note over Coord: Tally ballots:<br/>Winner: "OCEAN"
    Coord->>Broker: Broadcast: {"action": "WINNER", "choice": "OCEAN"}
    par Coordinated Experience Update
        Broker->>Stations: Flash Cyan (Confirmation glow)
    and
        Broker->>Audio: Crossfade into Ocean soundscape
    end

The Design Pattern

  1. Poll Invitation: A coordinator starts a voting round by broadcasting {"action": "OPEN_POLL", "poll_id": 42, "timeout": 5} to a shared channel.
  2. Voting Window: For the next 5 seconds, participant devices post ballots containing the poll_id and their chosen option to voting/ballots.
  3. Tallying & Tie-Breaking: When the countdown timer expires, the coordinator tallies the votes. If there is a tie, it can use a random pick or keep the previous state.
  4. Consensus Announcement: The coordinator announces the winning outcome ({"action": "WINNER", "choice": "OCEAN"}).
  5. Harmonious Action: Every station and actuator updates its display, sound, and lighting to reflect the winning collective decision.

Code Examples

Voting Coordinator (Python)

Opens polls, collects ballots during a deadline, and announces the winner:

from oocsi import OOCSI
from collections import Counter
import time

oocsi = OOCSI('voting_coordinator_####', 'localhost')

current_poll_id = 0
ballots = []
voting_open = False

def handle_ballot(sender, recipient, data):
    global ballots
    if voting_open and data.get('poll_id') == current_poll_id:
        choice = data.get('vote')
        if choice:
            ballots.append(choice)
            print(f"[*] Received ballot from {sender}: {choice}")

oocsi.subscribe('voting/ballots', handle_ballot)

def run_voting_round(options=['OCEAN', 'FOREST', 'DESERT'], duration=5):
    global current_poll_id, ballots, voting_open
    current_poll_id += 1
    ballots = []
    voting_open = True
    
    print(f"\n[===] STARTING VOTE #{current_poll_id} for {duration} seconds [===]")
    oocsi.send('voting/channel', {
        'action': 'OPEN_POLL',
        'poll_id': current_poll_id,
        'options': options,
        'timeout': duration
    })
    
    # Wait for the voting window to elapse
    time.sleep(duration)
    voting_open = False
    
    # Tally votes
    if ballots:
        tally = Counter(ballots)
        winner = tally.most_common(1)[0][0]
        print(f"[*] Vote complete! Tallies: {dict(tally)} -> Winner: {winner}")
    else:
        winner = options[0]  # Default if nobody voted
        print(f"[*] No votes cast. Defaulting to: {winner}")
        
    # Broadcast winning consensus to everyone
    oocsi.send('voting/channel', {
        'action': 'WINNER',
        'poll_id': current_poll_id,
        'choice': winner
    })

# Run a sample poll every 20 seconds
while True:
    run_voting_round()
    time.sleep(15)

Participant Voting Station (ESP32 / Arduino C++)

Allows visitors to press a button to vote, and responds when the winner is announced:

#include "OOCSI.h"

const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* hostserver = "oocsi.example.com";
const char* OOCSIName = "vote_station_####";

OOCSI oocsi = OOCSI();

int currentPollId = -1;
bool canVote = false;
const int BUTTON_PIN = 0;   // Boot button on most ESP32 boards
const int LED_PIN = 2;

void onVoteEvent() {
  String action = oocsi.getString("action", "");

  if (action == "OPEN_POLL") {
    currentPollId = oocsi.getInt("poll_id", 0);
    canVote = true;
    digitalWrite(LED_PIN, HIGH);  // Turn on LED: Voting is open!
    Serial.println("[*] Poll is open! Press button to vote.");
  } else if (action == "WINNER") {
    canVote = false;
    digitalWrite(LED_PIN, LOW);
    String winner = oocsi.getString("choice", "");
    Serial.print("[*] Group consensus reached! Winner is: ");
    Serial.println(winner);
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);

  oocsi.connect(OOCSIName, hostserver, ssid, password, onVoteEvent);
  oocsi.subscribe("voting/channel");
}

void loop() {
  oocsi.check();

  // If voting is open and user presses the button, cast a vote!
  if (canVote && digitalRead(BUTTON_PIN) == LOW) {
    oocsi.newMessage("voting/ballots");
    oocsi.addInt("poll_id", currentPollId);
    oocsi.addString("vote", "OCEAN");  // This station's vote
    oocsi.sendMessage();

    Serial.println("  -> Ballot cast: OCEAN");
    canVote = false; // Vote cast, prevent duplicate clicks
    digitalWrite(LED_PIN, LOW);
    delay(300);
  }

  delay(20);
}

Prototyping Checklist for Design Students

When building multi-device installations, keep these five golden rules in mind:

  1. Always use # hashes in client names (lamp_####): Adding four hashes guarantees that every time your ESP32 reboots or re-flashes, the OOCSI server gives it a fresh, unique name. This avoids “client handle already taken” errors.
  2. Use _RETAIN for persistent room state: If an experience has phases, colors, or modes that a newcomer needs to know immediately upon plugging in, always send it with _RETAIN.
  3. Pace your messages gracefully: Never put oocsi.sendMessage() inside an un-throttled loop without a delay! Send messages when an input changes, or at human-scale intervals (50–100ms for continuous sliders, 1–5 seconds for ambient room sensors).
  4. Design graceful fallbacks: Always ask yourself: “If the WiFi disconnects for 5 seconds, what will the user see?” Keep local animations running gracefully so your prototypes feel dependable even during network glitches.
  5. Use OOCSI debugging tools to see what’s happening: When multiple devices are talking, you cannot guess who sent what. Open the web browser tools:

Video Lecture: System Design with OOCSI

Watch Mathias’ lecture on multi-device systems from the Technologies for Connectivity course at Eindhoven University of Technology:


Next Steps

Now that you know how to architect multi-device interactive systems, explore:


Copyright © 2013-2026 Mathias Funk.

This site uses Just the Docs, a documentation theme for Jekyll.