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.
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.
| Item | Qty | Notes |
|---|---|---|
| ESP32 dev board | 1 | Any common WROOM-32 devkit. Powered and flashed over USB. |
| WS2811 12V bullet pixel string, 50 count | 2 | The 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, 2A | 1 | Barrel 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 buffer | 1 | Level-shifts both 3.3V data lines to 5V logic. Powered from the ESP32's 5V (VIN/USB) pin. |
| Resistor, ~300 ohm | 2 | In series with each data line, close to the first pixel. |
| Electrolytic capacitor, 1000 uF | 2 | Across 12V and ground at each strand's power input. |
| Breadboard, jumper wires, USB cable | 1 set | For the ESP32 and buffer. |
The ESP32 stays on USB power. The 12V supply powers only the pixels. All grounds tie together.
| From | To |
|---|---|
| 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 GND | 74AHCT125 GND |
| ESP32 GPIO 16 | 74AHCT125 input 1A; output 1Y through a 300 ohm resistor to strand A data-in |
| ESP32 GPIO 17 | 74AHCT125 input 2A; output 2Y through a 300 ohm resistor to strand B data-in |
| 74AHCT125 pins 1OE, 2OE | GND (enables the two buffers) |
Two rules that prevent dead hardware:
Never connect the 12V rail to the ESP32. It is a 3.3V part with a 5V-tolerant USB input only.
Common ground is mandatory. Data is referenced to ground; without the shared ground the pixels see garbage.
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.
Setup is four steps:
Install Arduino IDE 2.x.
Boards Manager: install "esp32 by Espressif Systems".
Library Manager: install FastLED.
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);
}
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.
| Symptom | Cause | Fix |
|---|---|---|
| Colors are wrong (red shows as green, etc.) | Pixel channel order differs by batch | Change the RGB template parameter in the two addLeds lines to GRB or BRG |
| First pixel glitches, or a strand freezes randomly | Marginal 3.3V data into a 5V-referenced pixel | Confirm the 74AHCT125 is in the data path and its OE pins are grounded |
| A strand is completely dark | Data fed into the output end, or missing common ground | Feed 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 out | Supply undersized or brightness raised | Keep BRIGHTNESS at 96 or size the supply up |
| Upload fails | Wrong port or boot mode | Reselect the serial port; hold the BOOT button during upload on boards that need it |
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.