Room Guardian · Bench Guide

ESP32 Starter Kit: Interactive Guide

One bench, six phases, every component in the kit. Pick a phase, then walk its step pills — the bench on the left always draws exactly what the card on the right asks you to build.

0 / 0 steps checked, all phases

Board 1 on top (ESP32 at its top edge, second pin row hanging past the rails), board 2 clipped to board 1's bottom edge — long sides interlock, columns aligned. Dashed wires travel between boards / to hanging pins. Hole names read row + column (C7 = row C, col 7); A–E share a column, F–J share a column. Wire modules by their printed pin names, not position.

wire color legend
  • ground
  • power (3V3/5V) & red
  • yellow
  • green
  • orange
  • blue
  • purple
  • white
  • 220Ω resistor (red·red·brown)

Phase 1 — GPIO & buttons. The ESP32 seats at board 1's top edge: the row printed 3V3 GND D15 … D22 D23 into row A, columns 4–18, USB pointing left; the other row hangs off the edge and connects through female→male jumpers. Board unplugged while wiring. Walk the pills in order — each shows its wiring on the bench. After flashing, the dashed </> pills walk you through the firmware you just ran — build it, then understand it, then tweak it. When all steps are checked, run the checkpoint below.

Seat the ESP32 as described in the phase overview — check from the side that the hanging pins touch nothing metal (tape over the rails there if they do). Then ground both − rails:

Wire (M-M)FromTo
black — groundB5 (under the GND pin)top − rail, at column 20
gray — rail bridgetop − rail, column 62bottom − rail, column 62

Press on the board's edges, not the middle — it takes real force the first time. Verify in software: flash and open the monitor, plug a spare jumper into E12 (pin D18's column — the firmware watches it as BTN1 with a pull-up) and touch its free end to each − rail: the monitor prints red LED toggled on contact. The + rails should print nothing. Test both ends of each − rail — some boards split their rails mid-board.

Each LED's chain: flying jumper from the hanging GPIO pin down into the breadboard → 220Ω resistor → long leg (anode); the short leg reaches the − rail directly.

LEDPinF-MLandsResistorAnodeShort leg
redD12redC20C20 → C22B22into − rail
yellowD13yellowC24C24 → C26B26into − rail
greenD14greenC28C28 → C30B30into − rail

Splay the LED legs — long leg in row B, short leg into the nearest rail hole. On the hanging row count from the USB end: VIN · GND · D13 · D12 · D14 · D27 · … and trust the printed names. 220Ω reads red–red–brown; long leg = anode = resistor side. Verify: with the monitor open, jumper E12 / E13 / E17 to a − rail — each touch toggles its LED for real.

One LED, four legs, columns 34–37 of row B. The longest leg is the shared ground (common cathode) — second from the colored-leg end.

LegGoes inPinF-MLandsResistor
redB34D32orangeC32C32 → C34
longest — commonB35no jumper — a spare 220Ω resistor runs A35 → − rail
greenB36D33whiteE33E33 → E36
blueB37D27blueC39C39 → C37

Wire color is just insulation — nothing electrical. The 220Ω standing in as the common-leg jumper only dims the LED slightly. Colors wrong later → the LED is flipped; longest leg must sit in B35. Flying jumpers used: 6 of 10 — brown, purple, gray, black stay spare for the sensor phases.

Buttons straddle the trench — legs land in rows E and F, two columns apart. Signal wire from the GPIO's column, ground drop to the bottom − rail. No resistors — internal pull-ups hold each pin HIGH until a press pulls it to ground.

BtnGPIOLegs atSignal (M-M)Ground link
BTN1D1840 + 42blue · E12 → D40brown · G42 → −
BTN2D1944 + 46purple · E13 → D44red · G46 → −
BTN3D2348 + 50yellow · E17 → D48green · G50 → −
BTN4D552 + 54orange · E11 → D52220Ω · G54 → −
BTN5D1556 + 58white · E6 → D56220Ω · G58 → −
BTN6BOOTalready on the board, next to USB — nothing to wire

Tactile-switch legs come in internally-joined pairs running along the board (E40 ↔ E42 one contact, F40 ↔ F42 the other) — a button that reads constantly pressed is rotated 90°. BTN4/BTN5 use 220Ω resistors as ground links (the M-M wires ran out; 220Ω is nothing against the ~45k pull-up). The kit's sixth physical button stays in the bag on purpose: every usable GPIO is allocated, and the board's own BOOT button covers the job.

No wiring — the bench shows the finished phase 1 build. Connect USB, then:

$ pio run -t upload
$ pio device monitor

You should see Room Guardian ready and the onboard blue LED blinking twice a second — the heartbeat that proves the code is alive.

Stuck at Connecting……? Hold the BOOT button until upload starts. No port found at all → see troubleshooting below the bench.

Open src/main.cpp — the whole firmware is this pump, run thousands of times per second:

void loop() { buttons::poll(); // inputs… dispatchButtons(); // …routed to actions sensors::loop(); alarmfsm::loop(); // decisions relays::loop(); leds::loop(); // outputs display::loop(); net::loop(); // comms mqttlink::loop(); cli::loop(); }

Every module gets a turn — inputs → decisions → outputs → comms — and none of them is ever allowed to wait. There is no delay() anywhere in this path: a module that has nothing to do returns instantly. That one rule is why the siren, the sensors, and (later) the web server can all share a single core without stepping on each other.

Try it: keep pio device monitor open and press BTN1. The printed line comes from leds::toggle(), called by dispatchButtons(), called from this loop — while the heartbeat never misses a beat. That's the loop working.

Open src/buttons.cpp — this is poll(), the most instructive 14 lines in the repo:

void poll() { uint32_t now = millis(); for (int i = 0; i < kCount; i++) { events[i] = false; bool reading = digitalRead(kPins[i]) == LOW; // pressed = LOW (pullup!) if (reading != lastReading[i]) { // contact moved: restart clock lastChange[i] = now; lastReading[i] = reading; } if (now - lastChange[i] > kDebounceMs && reading != stable[i]) { stable[i] = reading; // held steady 30 ms: accept it if (stable[i]) events[i] = true; // rising edge = one event } } }

Three ideas fused: active-LOW (with INPUT_PULLUP the pin rests HIGH and a press pulls it to GND — hence == LOW), debouncing (a real button's contacts bounce for a few ms; ignore changes until the reading holds steady), and edge detection (events[i] is true for exactly one loop pass per press — hold the button forever and it still fires once).

Try it: in src/buttons.cpp set kDebounceMs = 1, reflash, and press buttons until one double-fires (checkpoint box 2 breaks!). Then try 500 — feel the lag. Restore 30.

Open src/leds.cpp, function loop() — three unrelated rhythms run side by side, each with its own last-time variable:

if (now - lastBlink >= 500) { // timer 1: heartbeat, 2 Hz lastBlink = now; digitalWrite(PIN_LED_ONBOARD, !digitalRead(PIN_LED_ONBOARD)); } if (app.party && now - lastChase >= 120) { // timer 2: party chase lastChase = now; for (int i = 0; i < 3; i++) digitalWrite(kLedPins[i], i == chaseStep ? HIGH : LOW); chaseStep = (chaseStep + 1) % 3; } // …timer 3 flashes the RGB at 250 ms when the siren fires (phase 3)

This is why millis() beats delay(): the pattern if (now - lastX >= interval) lets any number of independent rates coexist. A single delay(500) for the heartbeat would freeze the chase, the buttons, and everything else. Also worth a look above: kColors[] — the RGB palette is a data table, not a pile of if-statements.

Try it: change the heartbeat's 500 to 100 and reflash — the chase speed doesn't change. Then add your own color to kColors[] (e.g. {128, 0, 255, "purple"}) and BTN4 through to it. Revert when done.

✓ Phase 1 checkpoint

All boxes must pass on real hardware before Phase 2. Watch the serial monitor while you test.

Board 2 — the module farm. Clip board 2 to board 1's bottom long edge — MB-102 boards interlock on their long sides — columns aligned, exactly as drawn. Its top rails carry 3V3 + ground, its bottom rails 5V (from VIN) + ground. Cross-board jumpers physically run around the boards' sides, matching the dashed routes. The firmware is already flashed and wakes up as each part is connected. Pick a step pill to see its card and wiring; the dashed </> pills open the matching code lesson once that part works.

Four feeds bring power over from board 1 — after this, every module just taps the rails:

FeedFromTo (board 2)
3V3board 1 hole B4 (under the 3V3 pin)top + rail
groundboard 1 top − railtop − rail
5Vhanging VIN pin (F-M)bottom + rail
groundboard 1 bottom − railbottom − rail

VIN carries the USB 5V — it exists only while the ESP32 is plugged in. Verify with the probe trick: jumper from any board 2 − rail touched to E12 on board 1 should print red LED toggled — that proves the two boards share ground (they must).

Plug the OLED's 4-pin header into board 2 row B, columns 5–8, then wire by silkscreen name:

OLED pinConnect
GNDits column, row C → board 2 top − rail
VCCits column, row C → board 2 top + rail (3V3)
SCL→ board 1 hole C17 (pin D22)
SDA→ board 1 hole C14 (pin D21)

Reboot after connecting — the firmware probes for the OLED at boot. The moment it's found, full mode engages: BTN4 cycles pages, BTN5 arms the alarm, the RGB becomes the status light. Screen stays black → SDA/SCL swapped is the usual suspect.

Open src/display.cpp. In begin(), one line decides the firmware's whole personality:

app.displayFound = oled.begin(SSD1306_SWITCHCAPVCC, 0x3C); // false if no I2C ACK void loop() { if (!app.displayFound || millis() - lastDraw < 250) return; // 4 fps is plenty lastDraw = millis(); oled.clearDisplay(); // 1. blank the framebuffer (in RAM) switch (app.page) { // 2. draw the current page onto it case 0: pageEnv(); break; /* …light, alarm, automation, system */ } oled.display(); // 3. push the buffer over I2C }

If the OLED never ACKs on the I²C bus, begin() returns false and every module quietly falls back to phase-1 behavior — a missing part becomes a mode, not a crash. The drawing itself is the standard framebuffer idiom: you never draw "to the screen", you draw to a RAM buffer and ship it in one transfer. The < 250 guard is the same non-blocking timer pattern as the LEDs — redrawing faster would just waste loop time.

Try it: change 250 to 1000, reflash, and turn the pot — the numbers now visibly stutter. Then pull the OLED's SDA wire and reboot: the serial banner says phase-1 (no OLED) and BTN4 cycles colors again. Reconnect and revert.

Part (board 2, row B)PowerSignal
DHT11 · cols 14–16VCC→+ · GND→−DATA → board 1 C8 (pin D4)
LDR module · cols 22–24VCC→+ · GND→−AO → hanging D34 (F-M)
pot · cols 30/32/34outer legs → + and −wiper → hanging D35 (F-M)

The pot's outer legs are interchangeable (swapping just reverses the turn direction). Both analog signals go to hanging pins because GPIO 34/35 are the ESP32's clean ADC inputs — the ones that keep working when WiFi is on.

Open src/sensors.cpp — 45 lines, and the densest file in the repo:

if (app.simSensors) return; // CLI is injecting values — real sensors stand down if (now - lastDht >= 2000) { // DHT11 physically can't go faster float t = dht.readTemperature(); if (!isnan(t)) app.tempC = t; // keep last good value on bad reads } if (now - lastAdc >= 100) { int ldr = analogRead(PIN_LDR); ldrEma = ldrEma < 0 ? ldr : ldrEma * 0.8f + ldr * 0.2f; // the magic line app.lightRaw = (int)ldrEma; }

The "magic line" is an exponential moving average: each new reading only moves the value 20% of the way — jitter cancels out, real changes still get through. The < 0 ? part self-seeds it on the first sample (it starts at −1, an impossible ADC value). And note the DHT11 line: those sensors fail reads routinely, so a failed read keeps the last good value instead of flashing nan on the display. The simSensors guard at the top is what makes the whole sim console work — two lines of guard turn the firmware into its own simulator.

Try it: change 0.8f / 0.2f to 0.5f / 0.5f — the light bar gets twitchy. Try 0.98f / 0.02f — now cover the LDR and watch it crawl. Restore 0.8/0.2. Bonus: type sim light 300 in the serial console and watch the real sensor get ignored.

✓ Phase 2 checkpoint

PartPowerSignal
HC-SR501 PIR · board 2 row I, cols 8–10VCC→bottom + (5V) · GND→−OUT → hanging VN (F-F)
IR obstacle · board 2 row B, cols 40–42VCC→top + (3V3) · GND→−OUT → hanging VP (F-F)

The PIR's two orange trim pots: left = sensitivity, right = hold time — start both fully counter-clockwise. Its output is 3.3V even on 5V supply, so the ESP32 is safe. It needs 60 s after power-up before it reports (the ALARM page shows the warm-up). Aim the IR sensor across a doorway gap; its onboard pot sets range.

Open include/state.h (the five states) and src/alarm.cpp. Every transition in the whole alarm goes through one choke-point:

void setState(AlarmState s, const char* why) { app.alarm = s; app.alarmSince = millis(); // stamp the entry time — this is the trick Serial.printf("alarm: %s (%s)\n", alarmStateName(s), why); if (s == AlarmState::SIREN && onSiren) onSiren(app.lastTrigger); } switch (app.alarm) { // in loop(), every pass case AlarmState::ARMING: if (now - app.alarmSince >= kExitDelayMs) setState(AlarmState::ARMED, "exit delay over"); break; /* …ARMED watches sensors, TRIGGERED counts down, SIREN warbles */ }

State + entry timestamp = timed transitions. Because every state change stamps alarmSince, any state can express "after N seconds, move on" as one comparison — that's the entire exit-delay countdown. And because every change passes through setState(), the serial log narrates the machine for free. This shape — an enum, a switch, one mutator — is the backbone of most embedded firmware you'll ever read.

Try it: in include/config.h set kExitDelayMs = 3000 for faster testing, and change kAlarmCode to your own PIN. No PIR wired yet? Test the whole machine from the console: arm, wait, sim motion, watch TRIGGERED → SIREN, then disarm with the buttons: 3·1·2·4.

Part (board 2, row I)+ leg− leg
passive buzzer · cols 20–21→ hanging D25 (F-M)→ bottom − rail
active buzzer · cols 26–27→ hanging D26 (F-M)→ bottom − rail

Passive = plays tones (the siren); active = fixed beep (keypad clicks, countdown). They look alike — passive usually has an exposed circuit board underneath, active a sticker on top. Faint click instead of siren → they're swapped. Board resets when the siren fires → the passive buzzer draws too much; put a spare 220Ω in series with its + leg (quieter, but safe).

Still in src/alarm.cpp — the kit's two buzzers need opposite treatment, and both are here:

void beep(uint32_t ms) { // ACTIVE buzzer: just switch it on… digitalWrite(PIN_BUZZER_ACTIVE, HIGH); beepUntil = millis() + ms; // …and set a deadline; loop() switches it off } case AlarmState::SIREN: // PASSIVE buzzer: needs a real waveform if (now - lastSirenStep >= 350) { lastSirenStep = now; sirenHigh = !sirenHigh; tone(sirenHigh ? 2400 : 1600); // two tones alternating = the warble }

The active buzzer has its own oscillator — a HIGH pin is a fixed beep. The passive one is a bare speaker: tone() (LEDC PWM under the hood) must generate the frequency, which is why it can sweep and warble. Note beep() never waits — it books a deadline and returns; the top of loop() reaps it. One more line worth finding: app.pirMotion = now > kPirWarmupMs && … — the HC-SR501 emits garbage for its first minute, so the code simply refuses to listen until then. The datasheet is part of the program.

Try it: retune the siren — swap 2400 / 1600 for your own pair (try 900/600 for a European two-tone). Then a puzzle: the ARMING chirp line (now - app.alarmSince) % 1000 < 30 is subtly fragile — can you see why? (Hint: what if no loop pass lands inside the 30 ms window?) The lastX pattern used everywhere else can't miss.

✓ Phase 3 checkpoint

Relay pin (board 2 row I, cols 46–49)Connect
VCCbottom + rail (5V)
GNDbottom − rail
IN1 (lamp)→ board 1 C9 (pin RX2 / GPIO16)
IN2 (fan)→ board 1 C10 (pin TX2 / GPIO17)

Loads on the screw terminals: COM + NO in series with the load's supply. Low voltage only — a 12V LED strip or USB fan. No mains, ever.

Rules already live: lamp = dark + motion within 5 min while disarmed; fan = temp above the pot threshold (1°C hysteresis, 30 s dwell). On the AUTOMATION page BTN1/BTN2 cycle each relay auto → on → off. From this phase on, power the bench from a 1A+ phone charger — relay coils plus WiFi can brown-out a laptop port. A relay that hums or stays weakly on is the classic 3.3V-logic-vs-5V mismatch — tell Claude, two-minute fix.

Open src/relays.cpp, loop() — the same idea in two shapes, back to back:

// dark detection — LATCH style: different thresholds to enter and leave if (!darkLatch && app.lightRaw < kDarkThreshold) darkLatch = true; if (darkLatch && app.lightRaw > kDarkThreshold + kDarkHysteresis) darkLatch = false; // fan — SETPOINT style: seeded from the CURRENT output, so between the // two thresholds nothing changes bool fanWant = app.relayOn[1]; if (app.tempC > app.fanThreshC) fanWant = true; if (app.tempC < app.fanThreshC - kFanHysteresisC) fanWant = false;

A sensor value hovering exactly at one threshold would flip the relay on and off every loop pass — audible, and hard on the hardware. Hysteresis opens a dead band: the value must travel past the far edge to switch back. As a second seatbelt, drive() enforces kRelayMinDwellMs (30 s) between physical switches — software rules protecting a mechanical part. The lamp rule right between the two excerpts is also worth reading: it's one boolean that reads exactly like the English sentence describing it.

Try it (the best demo in the project): in config.h set kDarkHysteresis = 0 and kRelayMinDwellMs = 1000, reflash, then hover the light level around the threshold from the console — alternate sim light 1195 / sim light 1205 (with sim motion to satisfy the lamp rule). Hear the chatter. Restore both values and it's gone. Now you know why every thermostat on Earth has a dead band.

✓ Phase 4 checkpoint

Phase 5 — WiFi, dashboard, notifications. No wiring — the bench shows the completed build. Configuration only, and the device must keep working with no WiFi at all: the network is an enhancement, never a dependency.

$ cp include/secrets.example.h include/secrets.h
$ open -e include/secrets.h # fill in WIFI_SSID / WIFI_PASS
$ pio run -t upload

The serial monitor prints the IP; the dashboard lives at http://room-guardian.local — live readings, arm/disarm with the code, relay control from your phone (same WiFi). For push notifications: uncomment NTFY_TOPIC in secrets.h, pick a long random topic name, install the ntfy app and subscribe to that topic. Test from the serial console: notify hello.

secrets.h is git-ignored — credentials never leave your machine. The SYSTEM page now shows a real clock (NTP) instead of uptime.

Open src/net.cpp. Most tutorials connect like this: while (WiFi.status() != WL_CONNECTED) delay(500); — freezing the whole device until the router answers. This firmware refuses:

void loop() { bool up = WiFi.status() == WL_CONNECTED; if (up && !app.wifiUp) { // rising edge: we JUST connected MDNS.begin(kHostname); // …do the one-time setup now configTzTime(kTz, "pool.ntp.org"); if (!serverUp) startServer(); } app.wifiUp = up; if (!up && millis() - lastAttempt > 15000) { // retry without blocking lastAttempt = millis(); WiFi.begin(WIFI_SSID, WIFI_PASS); } }

Same edge-detection idea as the buttons, applied to the radio: compare the current status with the remembered one, and run connect-time work exactly once on the rising edge. The alarm keeps arming, the sensors keep reading, and the network joins whenever it joins. Two more patterns worth finding in this file: #if __has_include("secrets.h") at the top compiles the whole module down to three empty stubs when there are no credentials (that's why phases 1–4 never mention WiFi), and notify() never touches the network — it queues the message and lets loop() send it later, rate-limited.

Try it: change kHostname in config.h and your dashboard moves to http://<newname>.local. Then kill your router's WiFi mid-session and watch the serial log: the device shrugs, keeps running, reconnects alone. That resilience is this loop.

✓ Phase 5 checkpoint

Phase 6 — MQTT + Home Assistant. No wiring — configuration only. The Guardian becomes a first-class smart-home node.

$ brew install mosquitto && brew services start mosquitto # or use the HA add-on

Uncomment the MQTT_HOST block in secrets.h (your broker's IP), re-flash. The device publishes Home Assistant discovery messages — every sensor, the alarm state, and both relays appear as entities automatically, no YAML. Availability tracking included: pull the ESP32's power and HA marks it unavailable.

Open src/mqttlink.cpp. The most elegant two lines in the whole project are the connect call:

// LWT: HA marks the device unavailable if we drop off if (mqtt.connect("room-guardian", MQTT_USER, MQTT_PASSWORD, "rg/availability", 0, true, "offline")) { mqtt.publish("rg/availability", "online", true); // retained mqtt.subscribe("rg/relay1/set"); /* …relay2, alarm */ } if (millis() - lastPublish >= 10000) { // state out, every 10 s lastPublish = millis(); mqtt.publish("rg/state", net::statusJson().c_str()); }

The extra connect arguments are a Last Will & Testament: you tell the broker, in advance, what to announce if you die. Pull the ESP32's power and the broker itself publishes offline — Home Assistant greys the device out with zero polling. The reconnect logic above it is the same non-blocking shape as WiFi's (third time you've seen it now). The one part not worth reading line-by-line is publishDiscovery(): it publishes one retained JSON config per entity so HA auto-creates them — JSON inside C strings inside Jinja templates. Trust the summary.

Try it: drop the publish interval from 10000 to 2000 and watch the HA entities update faster. Then run the LWT experiment for real: pull the USB cable and watch HA grey out. That's the broker keeping your promise.

✓ Phase 6 checkpoint — project complete

The three commands

Everything happens in the terminal, from the project folder (~/Documents/esp32).

$ pio run # compile — catches mistakes before touching hardware
$ pio run -t upload # flash the board over USB
$ pio device monitor # watch serial output · Ctrl+C exits

The serial console

The firmware contains all six phases and adapts to whatever hardware it finds (no OLED → buttons behave exactly as the phase 1 checkpoint expects). The console lets you drive and simulate everything before the hardware exists:

> help # list all commands
> status # full device state as JSON
> sim temp 31 # fake a heatwave — watch the fan rule fire
> arm # then: sim motion → entry countdown → siren
> disarm # silence it (buttons: 3·1·2·4 = the real code)
> relay 1 on # force the lamp relay; 'relay 1 auto' returns to rules

Every </> code experiment is safely revertable: undo in your editor, or git checkout -- src/<file> restores the original. Break things freely — that's the point.

When it doesn't work

It won't all work first try — that's normal and it's where the learning is.

No serial port / upload can't find the board

Run pio device list. Empty list → either the USB cable is charge-only (very common — try another) or macOS lacks the USB-serial driver. Most kit boards use a CH340 or CP2102 chip; install the matching driver, replug, check again.

An LED stays dark

Suspects in order: the flying jumper's female end not fully seated on the hanging pin, LED backwards (long leg must face the resistor), resistor and LED leg not sharing a column, or the jumper on the wrong hanging pin — recount from the USB end and check the printed name. Quick test: pull that LED's flying jumper off its hanging pin and touch the male end into B4 (the seated 3V3 pin's column) — the LED should light immediately. If it does, everything downstream is fine and the source pin was wrong.

RGB shows the wrong colors

The LED is flipped — longest leg must be in B35 — or two flying jumpers are swapped on the hanging row. If colors appear when they should be off and vice versa, your kit has a common-anode RGB: tell Claude, it's a two-line code fix.

A button fires twice, constantly, or never

Constantly pressed → rotated 90° (see Step 4 tip). Never fires → a leg isn't reaching its hole, or the signal wire is on the wrong column. Double-fires → tell Claude; the debounce window may need widening for your switches.

Serial monitor prints garbage

Baud mismatch. The project sets 115200 in platformio.ini, so a plain pio device monitor is correct — a burst of gibberish only at reset is the bootloader talking at 74880 and is harmless.

Board resets when I press BTN5 or BOOT at the wrong time

GPIO15 and GPIO0 are "strapping pins" the chip reads at power-on to decide how to boot. Held down during a reset they change boot mode; pressed while running they're ordinary buttons. Just don't hold them while plugging in.