MAC Collapse Christmas Lights Demo: Build Guide

What this demo is

Run the browser version — the same model in a web page, no hardware required.

This demo puts EDCA on two 50-light Christmas light strands driven by one ESP32, with identical traffic offered to both. It is an animation of one offered load seen from the receiver: fifty client stations transmitting to one radio head, which is the receiver and is not drawn. Both strands are fed the same arrivals — the same station gets the same burst at the same instant on each — so the only thing that differs is how the two systems handle identical traffic. What the head sees each slot is a bundle received, a bundle missed, or nothing. That is the direction where contention has something to collapse — downlink from a single head has one transmitter and nothing to contend with — and it is also why the held-back packets on strand B are held in the client's stack, above the client's own 802.11 driver, which is the thing a grant protocol has to reach. On strand A every light is a station contending independently: uniform backoff, freeze while the medium is busy, transmit at zero, and a contention window that doubles on every collision. On strand B the coordinator grants each light in turn on a fixed round-robin cadence, one transmit opportunity per slot, whether or not that light has a frame posted.

Blue is an A-MPDU the stack has handed to the 802.11 driver: released on its grant and now contending. Green is transmitting on the air. Red is a collision. Pale blue is a grant that opened onto an A-MPDU: the moment the whole block is released out of the stack. Yellow is a grant that opened onto nothing, and it is a single light because it carries no packets at all. Both appear only on the granted strand — strand A runs no grants, so it has neither, and no blue either. Dark is a station with nothing to send. The colours are the stages of one pipeline, which is the point: held in the stack, released by a grant, in the MAC arbitrating, then on the air. On strand A a station's blue warms toward orange as its contention window doubles, dim blue at CW 15 to bright orange at CW 1023, so backoff growth is visible per light. Strand B never leaves blue, because a grant it wins uncontested never doubles anything. As load rises, strand A shifts from blue toward orange and red while its green rate dies; strand B trades yellow for green.

How the model works

The firmware advances a shared slot clock, 120 ms per slot, so channel capacity is 1000 / SLOT_MS, about 8.3 packets per second per strand at the default pace. Offered load can be expressed either way: as packets per second aggregate across the strand, or as an arrival probability per station per slot. The two convert by probability = (pkt/s / 50) * (SLOT_MS / 1000). Each slot, every light without a frame gets one with that probability, and the same arrival process feeds both strands. Left alone, offered load ramps as a sawtooth from 0.5 to 12 packets per second over 180 seconds, deliberately running past capacity; the Serial Monitor pins it instead.

Strand A contends. A backlogged light draws a backoff uniformly in [0, CW] and counts down only in idle slots, freezing whenever the medium is busy. At zero it transmits. One transmitter in the slot is a delivered frame: green, and its CW resets to 15. Two or more collide: red, and every collider doubles its CW (15, 31, 63, up to 1023) and redraws its backoff. Doubling windows mean longer waits, and the strand spends a growing share of its airtime on collisions and recovery, so per-frame service time grows without bound.

Strand B is granted. The coordinator grants one light per slot on a fixed round-robin cadence over all 50 lights. A grant to a light holding a frame delivers it: green, a collision-free transmit opportunity. A grant to a light with no frame posted is wasted: yellow. Grants remove contention entirely, so CW never doubles and stays pinned at 15; a queued light's wait is grant latency, worst case one full cycle of 50 slots, and its glow holds the dim blue stage-0 color.

The CW display is the point of the color scheme: each waiting light glows at a hue and brightness set by its backoff stage, in log2 steps from CW 15 (dim blue) to CW 1023 (bright orange). Watching strand A warm from blue to orange is watching every station's contention window balloon. Strand B never leaves blue.

Bill of materials

ItemQtyNotes
ESP32 dev board1Any common WROOM-32 devkit. Powered and flashed over USB.
WS2811 12V bullet pixel string, 50 count2The diffused bullet or C9-style nodes that read as Christmas lights. Search "WS2811 12V pixel string"; Amazon generics, HolidayCoro, Wired Watts, or Ray Wu on AliExpress all carry them.
12V DC power supply, 2A1Barrel jack plus a barrel-to-screw-terminal adapter. Budgeting rule for these pixels is 60 mA per node at full white; this demo runs mostly-dark patterns at reduced brightness, so 2A covers 100 nodes with headroom.
SN74AHCT125 quad buffer1Level-shifts both 3.3V data lines to 5V logic. Powered from the ESP32's 5V (VIN/USB) pin.
Resistor, ~300 ohm2In series with each data line, close to the first pixel.
Electrolytic capacitor, 1000 uF2Across 12V and ground at each strand's power input.
Breadboard, jumper wires, USB cable1 setFor the ESP32 and buffer.

Wiring

The ESP32 stays on USB power. The 12V supply powers only the pixels. All grounds tie together.

FromTo
12V supply +Both strands' 12V power wire, with a 1000 uF cap across +/- at each strand's input
12V supply -Both strands' ground wire, and ESP32 GND
ESP32 5V (VIN)74AHCT125 VCC
ESP32 GND74AHCT125 GND
ESP32 GPIO 1674AHCT125 input 1A; output 1Y through a 300 ohm resistor to strand A data-in
ESP32 GPIO 1774AHCT125 input 2A; output 2Y through a 300 ohm resistor to strand B data-in
74AHCT125 pins 1OE, 2OEGND (enables the two buffers)

Two rules that prevent dead hardware:

Pixel strings are directional. Data flows from the input end, usually marked with an arrow on the node or identified by the male pigtail. Feed data into that end.

Firmware and the sketch

Setup is four steps:

  1. Install Arduino IDE 2.x.

  2. Boards Manager: install "esp32 by Espressif Systems".

  3. Library Manager: install FastLED.

  4. Save the sketch below as mac_collapse_lights.ino inside a folder named mac_collapse_lights (the IDE requires the folder name to match), open it, select the ESP32 board and its serial port, and upload.

// MAC collapse demo, EDCA edition, on two Christmas-light pixel strands
//
// Both strands hold 50 stations fed by identical traffic arrivals.
// Offered load is set in packets per second (aggregate per strand) or as
// an arrival probability per station per slot; the two convert by
// probability = (pkt/s / NUM_LEDS) * (SLOT_MS / 1000).
//
// Strand A (contention): each station runs EDCA-style access on its own.
//   Uniform backoff in [0, CW], freeze while the medium is busy, transmit
//   at zero. One transmitter in a slot -> GREEN (delivered frame). Two or
//   more -> RED on all of them, and each collider doubles CW (15 up to
//   1023) and redraws its backoff.
//
// Strand B (granted): the coordinator grants one light per slot on a fixed
//   round-robin cadence over all lights. Grant with a frame posted -> GREEN
//   (delivered). Grant with no frame posted (no DMA post) -> YELLOW, a
//   wasted transmit opportunity. Grants remove contention, so CW never
//   grows; a queued light just waits for its grant.
//
// CW is visible: a waiting station's resting glow encodes its contention
// window, dim blue at CW 15 warming to bright orange at CW 1023. Lights
// with nothing to send stay dark.
//
// Serial Monitor at 115200:
//   4.0      pins offered load at 4.0 pkt/s
//   p 0.02   pins the arrival probability at 0.02 per station per slot
//   auto     resumes the ramp
// Every setting echoes back in both units. A stats line prints once per
// second: offered load, probability, delivered per strand, wasted grants.

#include <FastLED.h>

#define NUM_LEDS    50
#define PIN_EDCA    16      // contention strand data pin
#define PIN_GRANT   17      // granted strand data pin
#define SLOT_MS     120     // one MAC slot; capacity = 1000/SLOT_MS pkt/s
#define BRIGHTNESS  96

#define CW_MIN      15      // EDCA best-effort defaults
#define CW_MAX      1023

// Offered-load ramp, packets per second aggregate per strand (sawtooth).
// Capacity at 120 ms slots is about 8.3 pkt/s; the ramp runs past it.
#define L_MIN_PPS    0.5f
#define L_MAX_PPS    12.0f
#define RAMP_SECONDS 180

// Optional: a potentiometer on an ADC pin drives offered load by hand.
// #define LOAD_POT_PIN 34

struct Station {
  bool     backlogged;   // holds a frame waiting to go out
  uint16_t cw;           // current contention window
  uint16_t backoff;      // idle slots left before it transmits
};

Station  edca[NUM_LEDS];
bool     grantQueue[NUM_LEDS];   // frame-posted flags on the granted strand
bool     tx[NUM_LEDS];
CRGB     stripA[NUM_LEDS];
CRGB     stripB[NUM_LEDS];
uint32_t slotCount = 0;

float    manualProb = -1.0f;     // arrival probability per station per slot; <0 = auto
uint32_t aDeliv = 0, bDeliv = 0, bWaste = 0, lastReport = 0;

float ppsToProb(float pps) { return (pps / NUM_LEDS) * (SLOT_MS / 1000.0f); }
float probToPps(float p)   { return p * NUM_LEDS * (1000.0f / SLOT_MS); }

uint8_t stageOf(uint16_t cw) {          // 0..6 for CW 15..1023
  uint8_t  s = 0;
  uint16_t c = CW_MIN;
  while (c < cw && s < 6) { c = c * 2 + 1; s++; }
  return s;
}

CRGB waitColor(uint8_t stage) {
  // stage 0 = dim blue, stage 6 = bright orange
  uint8_t hue = 160 - stage * 21;
  uint8_t val = 40  + stage * 23;
  return CHSV(hue, 255, val);
}

void echoSetting(const char *what, float prob) {
  Serial.print(what);
  Serial.print(": probability ");
  Serial.print(prob, 4);
  Serial.print(" per station per slot = ");
  Serial.print(probToPps(prob), 2);
  Serial.println(" pkt/s offered");
}

void pollSerial() {
  if (!Serial.available()) return;
  String s = Serial.readStringUntil('\n');
  s.trim();
  if (s.length() == 0) return;

  if (s.equalsIgnoreCase("auto")) {
    manualProb = -1.0f;
    Serial.println("offered load: auto ramp");
  } else if (s.charAt(0) == 'p' || s.charAt(0) == 'P') {
    float v = s.substring(1).toFloat();       // "p 0.02" or "p0.02"
    if (v > 0.0f && v <= 1.0f) {
      manualProb = v;
      echoSetting("probability set", manualProb);
    } else {
      Serial.println("probability must be in (0, 1]");
    }
  } else {
    float v = s.toFloat();                    // bare number = pkt/s
    if (v > 0.0f) {
      manualProb = ppsToProb(v);
      if (manualProb > 1.0f) manualProb = 1.0f;
      echoSetting("offered load set", manualProb);
    }
  }
}

float arrivalProbability() {
#ifdef LOAD_POT_PIN
  float pps = L_MIN_PPS + (analogRead(LOAD_POT_PIN) / 4095.0f) * (L_MAX_PPS - L_MIN_PPS);
  return ppsToProb(pps);
#else
  if (manualProb > 0.0f) return manualProb;
  float t     = slotCount * (SLOT_MS / 1000.0f);
  float phase = fmodf(t, RAMP_SECONDS) / RAMP_SECONDS;   // 0..1 sawtooth
  return ppsToProb(L_MIN_PPS + phase * (L_MAX_PPS - L_MIN_PPS));
#endif
}

void setup() {
  Serial.begin(115200);
  Serial.print("MAC collapse demo. Slot ");
  Serial.print(SLOT_MS);
  Serial.print(" ms, channel capacity ");
  Serial.print(1000.0f / SLOT_MS, 1);
  Serial.println(" pkt/s per strand.");
  Serial.println("Commands: <number> = offered load in pkt/s, p <0..1> = probability per station per slot, auto = ramp.");

  // Bullet pixels are usually WS2811; swap the color order template
  // parameter to GRB or BRG if the string shows wrong colors.
  FastLED.addLeds<WS2811, PIN_EDCA,  RGB>(stripA, NUM_LEDS);
  FastLED.addLeds<WS2811, PIN_GRANT, RGB>(stripB, NUM_LEDS);
  FastLED.setBrightness(BRIGHTNESS);

#ifdef ESP32
  randomSeed(esp_random());
#else
  randomSeed(analogRead(A0));
#endif

  for (int i = 0; i < NUM_LEDS; i++) {
    edca[i].backlogged = false;
    edca[i].cw         = CW_MIN;
    edca[i].backoff    = 0;
    grantQueue[i]      = false;
  }
}

void loop() {
  pollSerial();

  float prob = arrivalProbability();      // per station, per slot
  long  probScaled = (long)(prob * 10000);

  // Identical traffic offered to both strands
  for (int i = 0; i < NUM_LEDS; i++) {
    if (!edca[i].backlogged && random(10000) < probScaled) {
      edca[i].backlogged = true;
      edca[i].cw         = CW_MIN;
      edca[i].backoff    = random(edca[i].cw + 1);
    }
    if (!grantQueue[i] && random(10000) < probScaled) grantQueue[i] = true;
  }

  // ---- Strand A: independent EDCA contention ----
  int txCount = 0;
  for (int i = 0; i < NUM_LEDS; i++) {
    tx[i] = edca[i].backlogged && edca[i].backoff == 0;
    if (tx[i]) txCount++;
  }
  for (int i = 0; i < NUM_LEDS; i++) {
    if (!edca[i].backlogged)  stripA[i] = CRGB::Black;
    else if (tx[i])           stripA[i] = (txCount == 1) ? CRGB::Green : CRGB::Red;
    else                      stripA[i] = waitColor(stageOf(edca[i].cw));
  }
  if (txCount == 1) {
    for (int i = 0; i < NUM_LEDS; i++)
      if (tx[i]) { edca[i].backlogged = false; edca[i].cw = CW_MIN; aDeliv++; }
  } else if (txCount >= 2) {
    for (int i = 0; i < NUM_LEDS; i++) {
      if (tx[i]) {
        uint16_t ncw = edca[i].cw * 2 + 1;          // 15 -> 31 -> ... -> 1023
        if (ncw > CW_MAX) ncw = CW_MAX;
        edca[i].cw      = ncw;
        edca[i].backoff = random(edca[i].cw + 1);
      }
    }
  }
  if (txCount == 0) {                 // idle slot: backoff counters run
    for (int i = 0; i < NUM_LEDS; i++)
      if (edca[i].backlogged && edca[i].backoff > 0) edca[i].backoff--;
  }                                   // busy slot: everyone else freezes

  // ---- Strand B: fixed round-robin grants, one per slot ----
  for (int i = 0; i < NUM_LEDS; i++)
    stripB[i] = grantQueue[i] ? waitColor(0) : CRGB::Black;
  int granted = slotCount % NUM_LEDS;
  if (grantQueue[granted]) {
    stripB[granted]     = CRGB::Green;    // granted TXOP, frame delivered
    grantQueue[granted] = false;
    bDeliv++;
  } else {
    stripB[granted] = CRGB::Yellow;       // grant with no DMA post: wasted
    bWaste++;
  }

  FastLED.show();

  // Per-second stats on the serial console
  if (millis() - lastReport >= 1000) {
    Serial.print("offered ");
    Serial.print(probToPps(prob), 1);
    Serial.print(" pkt/s | probability ");
    Serial.print(prob, 4);
    Serial.print("/station/slot | A delivered ");
    Serial.print(aDeliv);
    Serial.print(" | B delivered ");
    Serial.print(bDeliv);
    Serial.print(" | B grants unused ");
    Serial.println(bWaste);
    aDeliv = bDeliv = bWaste = 0;
    lastReport = millis();
  }

  slotCount++;
  delay(SLOT_MS);
}

Bring-up and troubleshooting

First power-up: strand B shows the round-robin grant sweeping the string, mostly yellow at low load with green wherever a frame was waiting, over a faint blue field of queued lights. Strand A starts mostly dark, blinks green occasionally, then warms toward orange with red bursts as the traffic ramp builds. The serial console prints capacity at boot and a stats line every second.

SymptomCauseFix
Colors are wrong (red shows as green, etc.)Pixel channel order differs by batchChange the RGB template parameter in the two addLeds lines to GRB or BRG
First pixel glitches, or a strand freezes randomlyMarginal 3.3V data into a 5V-referenced pixelConfirm the 74AHCT125 is in the data path and its OE pins are grounded
A strand is completely darkData fed into the output end, or missing common groundFeed data at the input end (arrow on the node); verify 12V ground, pixel ground, and ESP32 ground are tied together
Whole rig resets or browns outSupply undersized or brightness raisedKeep BRIGHTNESS at 96 or size the supply up
Upload failsWrong port or boot modeReselect the serial port; hold the BOOT button during upload on boards that need it

Running the demo

Point the audience at the green rate, strand A's waiting-light color, and the yellow on strand B. At low load, strand A delivers everything while strand B runs mostly yellow: grants going out with no DMA post behind them, capacity spent on lights with nothing to send. As load rises, the strands trade places: strand A's collisions double windows, the strand warms toward orange, greens thin out, and per-frame service time blows up, while strand B's yellow turns green and it delivers one frame per slot. That crossover is the argument for demand-aware granting. Be precise about what a blind grant costs, though: because a grant is an eligibility window rather than a reservation, a station with nothing to send transmits nothing, sends no Duration field, and sets no NAV anywhere. The air stays available and the coordinator tries the next station. What the miss costs is delay for a station that did have a frame, not burnt airtime. That is the difference from TDMA, where the slot is reserved whether or not anyone uses it, and it is why the demo grants several times per slot rather than once.

Live control runs over the Serial Monitor at 115200: a bare number pins offered load in packets per second, p 0.02 pins the arrival probability per station per slot, and auto resumes the ramp. Every setting echoes back in both units, and a per-second stats line prints offered load, probability, delivered counts per strand, and wasted grants, which makes good on-screen telemetry during a talk. Alternatively, wire a potentiometer to an ADC pin and enable LOAD_POT_PIN to drive offered load by knob.

Tuning knobs: SLOT_MS sets the pace and with it the capacity (1000 / SLOT_MS packets per second), RAMP_SECONDS sets how long the collapse takes, L_MIN_PPS / L_MAX_PPS set the ramp range, and CW_MIN / CW_MAX set the backoff range (15 and 1023 are the EDCA best-effort defaults).

Framing for the talk: this is a slotted abstraction of EDCA. It keeps the mechanics that carry the story, per-station uniform backoff, freeze while the medium is busy, and binary exponential CW growth from 15 to 1023, and contrasts them with granted transmit opportunities on identical offered traffic. It leaves out AIFS differentiation, aggregation, and capture. It also treats every slot as the same length, which real 802.11 does not: an idle backoff slot is about 9 microseconds, a successful TXOP is a couple of hundred microseconds of overhead plus however long the data takes, and a collision costs the length of the frames that collided. So the demo under-punishes collisions and over-charges idle time, and the honest reading of the picture is the shape of the curves rather than the ratio between them. Present the behaviour as illustrative rather than as calibrated 802.11 numbers; the airtime-accurate version of this comparison is what the MAC arbitration rig is for.