For a new Mystery Geocache (see Geocaching for Muggles below if you have never heard of it) I was looking for a final stage that is more than a box hidden behind a tree. The idea: a doll house in which the cachers have to solve some riddles — not on paper, but by interacting with the house itself. Their own smartphone should serve as the remote control.
The basis is a simple and cheap wooden doll house from a discounter with three floors. I equipped it with Playmobil scenery — a Vespa workshop on the ground floor, a driving school on the first floor, and a living room under the roof — and pimped it with electronics: RGB lighting, an elevator driven by a servo winch, flashing indicator lights on the workshop car, and a policeman operating a speed trap. An ESP32 controls everything and opens its own WiFi access point. As soon as a cacher connects with a smartphone, a captive portal pops up which shows the riddles and the controls for the house.
I started the project at the beginning of November 2024, and the geocache was published at the end of April 2026. Since then, the house has been in operation, and the feedback from the cachers has been great. Some quotes from the online logs (translated from German):
"When we arrived at the final, we were quite amazed. Really cool work."
"When we arrived at the final, we were quite amazed. Really cool work."
"On site it was really exciting — I would never have thought of something like this."
"Thanks for the great craftsmanship and the technology."Geocaching for Muggles
Geocaching is a worldwide outdoor treasure hunt: people hide containers ("caches") with a logbook and publish their coordinates on an online platform. Others ("cachers") navigate to them with a GPS receiver or smartphone, sign the logbook, and write an online log about their experience. In a Mystery cache, the coordinates are not published directly, but have to be found by solving puzzles first. Many caches consist of several stages, and the last one, containing the logbook, is called the final. And people who don't know about geocaching? They are called muggles.
Key Features- WiFi access point with captive portal — the web page opens automatically on the smartphone
- Four riddles which can only be solved by interacting with the house
- RGB lighting of the floors using a single WS2812B LED strip, with a color picker for the workshop
- Speed trap with a flashing LED, triggered from the web page
- Several indicator light patterns for the car in the workshop, selected via a keypad
- Elevator driven by a continuous rotation servo with a winch, limited by two micro switches
- Live status of the elevator on the web page via server-sent events
- Automatic reset when the cacher disconnects: lights off, elevator returns to the ground floor
- Software and hardware watchdogs for unattended operation
- Password-protected admin web interface for settings and over-the-air (OTA) firmware updates with MD5 check
As in my previous projects, I used Visual Studio Code with the PlatformIO IDE. The platformio.ini file defines a dev and a release build environment, which differ in the log level and the optimization. I use the nodemcu-32s board definition, which also works with the NodeMCU Dev Kit C. Since PlatformIO has no lock file, the platform and library versions are pinned explicitly to get reproducible builds:
[common]
platform = espressif32 @ 6.13.0
board = nodemcu-32s
framework = arduino
upload_speed = 921600
monitor_speed = 115200
lib_deps =
ESP32Async/ESPAsyncWebServer @ 3.10.1
ESP32Async/AsyncTCP @ 3.4.10
bblanchon/ArduinoJson @ 7.4.3
fastled/FastLED @ 3.10.3
madhephaestus/ESP32Servo @ 3.1.3
monitor_filters = log2file, esp32_exception_decoder, default
[env:dev]
extends = common
build_type = debug
build_flags =
-DAPP_LOG_LEVEL=4 ; DEBUG
-DCORE_DEBUG_LEVEL=5
[env:release]
extends = common
build_type = release
build_flags =
-DAPP_LOG_LEVEL=0 ; NONE (silent)
-DCORE_DEBUG_LEVEL=0The libraries are used for the following purposes:
- ESPAsyncWebServer provides the web server. Its handlers serve the start page, receive the cacher's actions (color, elevator, keypad, speed trap), redirect unknown host names for the captive portal, and accept the firmware upload for OTA updates. Its
AsyncEventSourceclass pushes the elevator status to the browser via server-sent events. Since the server works asynchronously, requests are handled in the background without blocking control tasks. - AsyncTCP is the asynchronous TCP layer underneath ESPAsyncWebServer. It would be pulled in automatically as a dependency, but it is listed explicitly to pin its version.
- ArduinoJson serializes the elevator status (the text shown on the elevator display of the web page, the servo speed, and the states of the limit switches and of the relay which powers the servo) into a JSON string, which is sent as a server-sent event and parsed by the JavaScript code of the web page.
- FastLED drives the WS2812B LED strip. Besides setting the colors of the individual LEDs, it provides color correction, global brightness, and a current limiter which keeps the current of the strip moderate (see Power Supply below).
- ESP32Servo generates the 50 Hz PWM (pulse-width modulation) signal for the servo using the LED control (LEDC) PWM peripheral of the ESP32, which despite its name is also suited for servos. The firmware sets the pulse width to control speed and direction of the continuous rotation servo.
Further functionality comes from the Arduino core for the ESP32 itself: WiFi for the access point (including a scan for the least crowded WiFi channel), DNSServer (Domain Name System) for the captive portal, Preferences for storing the settings of the admin page in the non-volatile storage (NVS), and Update together with the OTA functions of the ESP-IDF (Espressif's development framework underneath the Arduino core) for the firmware updates.
In the release build, all log output is disabled, since nobody will ever connect a serial monitor to the house in the field.
Since the riddles belong to an active geocache, the source code is not published. Instead, the following sections show the most interesting parts of the code.
OverviewThe figure below shows the wiring. I admit that the cables cross each other more often than strictly necessary. Let's say they reflect the creative chaos of the building phase — and the real wiring inside the house looks very much the same.
The ESP32 NodeMCU Dev Kit C ("DevKit" for short) is plugged into an expansion board which provides a USB-C power input and power and ground pins next to each GPIO (general-purpose input/output) pin. This makes it possible to connect all components with simple jumper cables, without any soldering on the ESP32 side.
Power SupplyThe house is powered by a 2 A USB power supply via the USB-C port of the expansion board (labeled "ESP32S 38P/V4/Goouuu"). The board comes without a schematic, but done.land and ProtoSupplies provide helpful descriptions. On my board, the 5 V passes an SS24 Schottky diode (2 A), which leaves about 4.6 V on the 5 V rail. The board's USB ports only supply power, so the firmware is flashed via the Micro-USB port of the DevKit or over WiFi (OTA, see below).
This 5 V rail powers the whole house:
- The ESP32 DevKit generates 3.3 V with its own regulator. The red power pins next to the GPIOs are jumpered to this 3.3 V. The relay module and the limit switches are connected to them, so the switch signals at GPIO 34 and 35 never exceed 3.3 V.
- The LED strip is connected directly to a 5 V pin.
- The servo gets its 5 V via the relay (COM to NO contact), i.e. only while the elevator moves.
The continuous current stays below about 1 A, which leaves a comfortable margin for short peaks: about 80 mA for the ESP32, about 130 mA for the relay coil, at most 300 mA for the LED strip (limited by FastLED, see below), and about 130-300 mA for the moving servo (datasheet values at 4.8 V). Only when the cabin is blocked at an end position does the servo briefly draw its stall current of up to 0.9 A, until the firmware cuts its power (see Elevator below).
MountingThe electronics are mounted with self-adhesive PCB standoffs on a wall behind the elevator shaft, and the cables are routed through a duct on the side of the house.
Firmware StructureSeveral parts of the firmware run in parallel:
- The main loop runs about every 50 ms (it pauses for 50 ms after each pass). It updates the light animations, controls the elevator, and pushes status updates to the web page. To save CPU time, most of this only happens while a cacher is connected or the elevator is moving.
- An asynchronous web server handles the requests from the web page in the background.
- Separate FreeRTOS tasks (FreeRTOS is the real-time operating system underneath the Arduino core of the ESP32) run the DNS server of the captive portal (every 50 ms), poll the limit switches of the elevator (every 5 ms, at the highest priority), and check every 5 s whether the main loop is still alive (watchdog).
A single 2 m WS2812B strip was cut into segments which are connected with JST connector cables. In total, 36 LEDs are used, and the firmware addresses them in groups:
- LEDs 0-15: Driving school (first floor)
- LEDs 16-21: Back room behind the elevator
- LEDs 22-32: Workshop (ground floor)
- LEDs 33, 35: Headlights of the workshop car
- LED 34: System LED (Yellow = starting, green = ready, blue = connected)
The indicator light patterns are defined as simple step sequences. Each step defines the colors of the left and the right headlight and a duration. For example, this is the "police" pattern:
struct Step
{
CRGB colors[2];
uint32_t duration;
};
Step lightSequence4[] = {
{{CRGB::Black, CRGB::Black}, 50},
{{CRGB::DarkRed, CRGB::DarkBlue}, 50},
{{CRGB::Red, CRGB::Blue}, 250},
{{CRGB::DarkRed, CRGB::DarkBlue}, 50},
{{CRGB::DarkBlue, CRGB::DarkRed}, 50},
{{CRGB::Blue, CRGB::Red}, 250},
{{CRGB::DarkBlue, CRGB::DarkRed}, 50},
};A generic player steps through the selected sequence. It is called every 50 ms and counts down the ticks of the current step:
void updateFlashlightAnimation()
{
Step *sequence = sequences[carFlashlightMode_];
uint16_t sequenceLength = sequenceLengths[carFlashlightMode_];
if (carFlashlightTicksLeft_ == 0)
{
led_strip_[kLedsCarIndicatorLeftIndex] = sequence[carFlashlightNextIndex_].colors[0];
led_strip_[kLedsCarIndicatorRightIndex] = sequence[carFlashlightNextIndex_].colors[1];
carFlashlightTicksLeft_ = sequence[carFlashlightNextIndex_].duration / kLedsUpdateCycleMS;
carFlashlightNextIndex_ = (carFlashlightNextIndex_ + 1) % sequenceLength;
}
else
{
carFlashlightTicksLeft_ -= 1;
}
}This makes it easy to add new patterns without touching the timing logic. To keep the power consumption moderate, FastLED limits the total current of the strip to 300 mA:
FastLED.addLeds<NEOPIXEL, kPinLedStrip>(led_strip_, kLedsNum);
FastLED.setCorrection(TypicalSMD5050);
FastLED.setBrightness(196); // approx. 77% brightness
FastLED.setMaxPowerInVoltsAndMilliamps(5, kLedsMaxPowerMilliamps);The ElevatorThe elevator cabin hangs on a steel wire which is wound on a winch spool. The spool is driven by a Feetech FS5103R continuous rotation servo, i.e. the pulse width does not define a position but the speed and direction: 1500 µs means stop, 1200 µs is full speed in one direction, and 1800 µs full speed in the other direction.
The elevator control had to meet several requirements:
- Soft start and stop: The speed is ramped up and down in small steps every 50 ms (about 1 s from standstill to full speed). The cabin does not jerk, and the motor current rises gradually instead of jumping to the stall current at start-up.
- Limit switches: Two micro switches with roller levers mark the lower and the upper end position. The cabin runs against them and is blocked there, so the servo has to be stopped quickly.
- Timeout: Each movement is limited to a maximum duration. This protects the mechanics in case a limit switch fails.
- Relay: The servo is powered via a relay which is only switched on while the elevator moves. When the elevator stands still, the servo is completely without power — no creeping, no humming, no idle current.
The core of the control logic is called from the main loop every 50 ms:
void updateElevatorControl()
{
switch_lower_state_ = p_switch_io_->getSwitchLowerActivation();
switch_upper_state_ = p_switch_io_->getSwitchUpperActivation();
if (relais_state_ == HIGH)
{
// stop at lower end position when moving down
if (switch_lower_state_ > 0 && servo_speed_ > 0)
{
servo_set_speed_ = 0;
servo_speed_ = 0.0f;
}
// stop at upper end position when moving up
if (switch_upper_state_ > 0 && servo_speed_ < 0)
{
servo_set_speed_ = 0;
servo_speed_ = 0.0f;
}
// stop elevator after maximum duration (safe across millis() overflow)
if ((int32_t)(current_time_ - elevator_stop_timestamp_) >= 0)
{
servo_set_speed_ = 0;
}
// ramp the actual speed towards the set speed
if (servo_set_speed_ > servo_speed_)
{
servo_speed_ += 0.5f;
writeServo();
}
else if (servo_set_speed_ < servo_speed_)
{
servo_speed_ -= 0.5f;
writeServo();
}
// switch off relais when elevator stops
if (servo_set_speed_ == 0 and servo_speed_ >= -2.0f and servo_speed_ <= 2.0f)
{
setRelais(false);
}
}
}Two details are worth mentioning. First, the timeout comparison casts the difference of two timestamps to a signed integer. A naive current_time_ >= elevator_stop_timestamp_ would fail when millis() overflows after about 49 days — and the house is supposed to run for months.
Second, the micro switches sit at the end positions, where the cabin is blocked. From that moment on, the servo draws its stall current (up to 0.9 A at 4.8 V) until the firmware stops it.
To keep this phase short, a FreeRTOS task with the highest priority polls the switches every 5 ms and debounces them (three consecutive samples). I reused this detection and debouncing implementation from my Wireless Gamepad with ESP32 and BLE project. Debouncing takes about 15 ms, and the main loop evaluates the switches about every 50 ms. So the servo is typically stopped and its power is cut by the relay less than 100 ms after the cabin reaches the end position.
The task also latches each activation in a flag, so that a short press is not missed if the cabin would bounce back from the switch. The main loop then reads "is pressed or has been pressed since the last call":
inline uint8_t getSwitchUpperActivation()
{
return isSwitchUpperPressed() || wasSwitchUpperPressed();
}If a limit switch fails, the timeout makes sure that the servo does not stall for longer than the maximum duration of a movement.
The Speed TrapNext to the elevator, a Playmobil policeman operates a speed trap. A red LED is built into the camera on the tripod. When the cacher presses a button on the web page, the camera flashes several times. Like all other animations, the flashing is implemented as a small state machine which is updated in the 50 ms cycle of the main loop, so the web server never has to wait.
WiFi Access Point and Captive PortalChoosing the WiFi channel
The ESP32 runs as a WiFi access point which accepts only one connection at a time. Since the house is located in an environment with other WiFi networks, it first performs a passive scan and selects the channel with the least interference. Each network found contributes to the interference of its channel, weighted by its signal strength (RSSI, received signal strength indicator):
int16_t num_networks = WiFi.scanNetworks(false, false, true, 150); // Passive Scan
for (int i = 0; i < num_networks; i++) {
int channel = WiFi.channel(i);
int rssi = WiFi.RSSI(i);
if (channel >= 1 && channel <= 13) {
// RSSI is negative, so closer to 0 means stronger
channel_interference[channel] += 100 + rssi;
}
}Redirecting the smartphone
The captive portal consists of two parts:
- A DNS server answers every DNS query with the IP address of the ESP32.
- The web server redirects every request for an unknown host name to the start page of the ESP32.
When a smartphone connects to a WiFi network, it checks for an internet connection by requesting a well-known URL (e.g. connectivitycheck.gstatic.com on Android or captive.apple.com on iOS). Instead of reaching the internet, this request ends up at the ESP32, which answers with a redirect. The smartphone concludes that it is behind a captive portal and automatically opens the start page — exactly as in a hotel WiFi.
For the first part, the DNS server of the Arduino core is started with the wildcard "*", so it resolves any host name to the IP address of the access point. The answers are valid for 300 s (TTL, time to live). Its processNextRequest() method is called by the DNS task mentioned above:
dns_server_.setErrorReplyCode(DNSReplyCode::NoError);
dns_server_.setTTL(300);
dns_server_.start(53, "*", ap_ip);For the second part, the web server's handler for unknown URLs checks the Host header of the request. If it contains a host name instead of an IP address, the request was meant for another server (such as the connectivity check), and the browser is redirected to the start page:
void handle_notFound(AsyncWebServerRequest *request)
{
...
if (!isIp(host))
{
request->redirect(String("http://") + toStringIp(request->client()->localIP()));
}
...
}Communication between web page and ESP32
The web page and the ESP32 talk to each other in both directions, using a different technique for each: the page sends the cacher's actions to the ESP32 with simple HTTP requests, and the ESP32 pushes status updates to the page with server-sent events.
Web page → ESP32: actions via fetch()
The web page sends the cacher's actions (color, elevator commands, keypad, speed trap) to the ESP32 with the browser's fetch() API. It sends an asynchronous HTTP request in the background, so the page is not reloaded and stays responsive. Each action is a plain HTTP GET request to its own endpoint, with the value as a query parameter:
fetch('/elevator?direction=' + direction) // "up", "down" or "stop"
fetch('/setColor?color=' + color.slice(1)) // e.g. "ff8000" from the color picker
fetch('/setFlashlight?number=' + number) // keypad
fetch('/speedcam') // speed trapOn the ESP32, ESPAsyncWebServer calls the handler registered for the endpoint (e.g. web_server_.on("/elevator", HTTP_GET, handle_elevator)). The handler reads the parameter, sets the new target state, e.g. the direction of the elevator, and immediately answers with a short 200 OK. The page does not evaluate this answer. The actual work, such as the movement of the elevator, is done later by the main loop.
ESP32 → web page: status via server-sent events
In the opposite direction, the ESP32 pushes the elevator status to the browser via server-sent events (SSE). This is much simpler than WebSockets and sufficient for one-way updates. To limit the traffic, an event is sent immediately (at most every 250 ms) when something changes, and otherwise every 2.5 s as a heartbeat:
void updateEventSource()
{
uint32_t elapsed_time = current_time_ - last_event_update_timestamp_;
if (event_update_needed_)
{
if (elapsed_time >= 250)
{
sendEventData();
event_update_needed_ = false;
last_event_update_timestamp_ = current_time_;
}
}
else if (elapsed_time >= 2500)
{
sendEventData();
last_event_update_timestamp_ = current_time_;
}
}sendEventData() serializes the current state of the elevator to a small JSON object with ArduinoJson and sends it to all connected browsers. On the web page, the browser's EventSource API opens a long-lived HTTP connection to the event endpoint and calls onmessage for each event. The page only displays the status text of the elevator; the other fields (speed, relay, and switch states) are useful for debugging in the browser console. If the connection is lost, e.g. because the smartphone briefly drops out of WiFi, the page reconnects after 3 seconds:
function startEventSource() {
eventSource = new EventSource(eventUrl);
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
updateElevatorDisplay(data.elevatorText);
};
eventSource.onerror = function(event) {
eventSource.close();
setTimeout(startEventSource, 3000);
};
}Waking up and resetting the houseThe WiFi events of the access point are used to wake up the house when a cacher connects, and to reset it when the cacher leaves:
case ARDUINO_EVENT_WIFI_AP_STAIPASSIGNED:
user_connected_ = true;
setSystemLedColor(kColorConnected);
setGarageColor(CRGB::DarkBlue);
switchOnClosetLight();
switchOnSchoolLight();
FastLED.show();
break;
case ARDUINO_EVENT_WIFI_AP_STADISCONNECTED:
wifiStationDisconnected(); // all lights off, elevator back to ground floor
break;So the next visitor always finds the house in its initial state.
Hardening for Long-Term Unattended OperationThe house must run for months without anybody looking after it. Therefore I spent quite some time on robustness.
Two watchdogs
A hardware task watchdog monitors the main loop, the DNS task and the software watchdog task. If one of them does not report back within 60 s, the ESP32 is reset. In addition, a software watchdog task checks every 5 seconds whether the main loop is still alive. If the loop has stalled for 30 seconds, the task performs a clean restart, i.e. it stops the web server, the DNS server and WiFi properly before rebooting, so that a connected smartphone sees an orderly disconnect:
void watchdogTask(void *pvParameters)
{
esp_task_wdt_add(NULL);
while (true)
{
esp_task_wdt_reset();
vTaskDelay(5000 / portTICK_PERIOD_MS); // Check every 5 seconds
if ((int32_t)(millis() - last_loop_time_) > (int32_t)kWatchdogTimeoutMS)
{
LOG_ERROR("Watchdog: loop() stalled for too long, restarting...");
cleanRestart();
}
}
}Reducing memory fragmentation
Building HTML pages by concatenating many String objects at runtime fragments the heap (the dynamically allocated memory) of the ESP32 over time. To reduce this, the start page is stored in flash memory (PROGMEM) as a template, and only a few placeholders are replaced when a page is requested:
const char html_template[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
...
<div id='direction-display'>{{ELEVATOR_TEXT}}</div>
...
)rawliteral";
void handle_root(AsyncWebServerRequest *request)
{
String html = FPSTR(html_template);
html.replace("{{APP_TITLE}}", kAppTitle);
html.replace("{{ELEVATOR_TEXT}}", getElevatorText());
html.replace("{{FIRMWARE_VERSION}}", getFirmwareVersion());
html.replace("{{EVENT_URL}}", url + "/events");
request->send(200, "text/html", html);
}The admin and OTA pages are still assembled dynamically, but they are rarely used, and the board restarts anyway after the settings are changed or a new firmware is installed.
OTA updates via the admin web interface
New firmware can be uploaded over WiFi via a web form, without opening the house. Before the upload, a small PowerShell script computes the MD5 checksum of the built firmware and reads its version number from the binary via a marker string (FW_VER:x.y.z). In the form, I select the firmware file and enter the password and the MD5 checksum.
The ESP32 writes the received firmware into the second, currently unused app partition of the flash memory and computes the MD5 checksum of the received data at the same time. The new firmware is only activated if the upload is complete, the computed checksum matches the one entered in the form, and I confirm the update on the result page. Then the ESP32 switches to the new partition and restarts.
If the upload is interrupted, the checksum does not match, or the update is not confirmed, the current firmware simply stays in place.
Settings and energy saving
The SSID (network name), the WiFi password, the TX power (transmit power) and a blinking idle indicator can be changed on a password-protected admin page. The settings are stored in the non-volatile storage (NVS) of the ESP32 and are only written when they actually change, to avoid unnecessary flash wear. Apart from the optional blinking idle indicator, the light animations only run while a cacher is connected.
Lessons LearnedMake the WiFi settings adjustable in the field. At home, a low TX power was perfectly sufficient for the access point. At the final location, however, the WiFi environment is crowded with lots of other networks, and cachers would not reliably discover the access point of the house. Since the TX power can be changed on the admin page, I simply increased it on site, without opening the house or flashing new firmware. In hindsight, the admin page was well worth the effort.
"Stop" does not always mean standstill. Initially, I had planned to control the servo without a relay. However, the FS5103R is an analog servo, and its electronics do not evaluate the PWM signal with absolute precision. The neutral point has a tolerance of a few microseconds, so even with a perfect 1500 µs "stop" signal, the servo kept creeping slowly and humming quietly — not exactly what you want in a quiet doll house. The relay solved this elegantly: when the elevator is not needed, the servo is switched off completely and stands perfectly still.
Know the limits of your power supply. One of the more challenging parts was to keep the power consumption within the limits of the hardware. If the supply voltage drops too far, the brown-out detector of the ESP32 resets the board — and this really happened during development. Not a good experience for a cacher in the middle of solving a riddle. Surprisingly, the hardest part was not the calculation, but finding detailed information on the power supply circuit of the expansion board. These boards come without a schematic, and the few descriptions I found (see Power Supply above) cover the general concept, but not the components of my particular board. In the end, I had to identify the diode in the USB-C path by its marking to know how much current the board can actually handle.
Next time, I would get help from AI agents. All in all, the project took me roughly 200 hours, spread over about one and a half years. Since then, AI agents have fundamentally changed how projects like this can be done. If I built something similar again, I would use them not only for coding, but also for research: finding and exploring software libraries, searching for suitable electrical, electronic and mechanical parts, and analyzing datasheets. I expect this would save a good share of those hours. In fact, I wrote this article with the help of Claude Code. As a welcome side effect, checking the article against the source code uncovered a few bugs in the firmware, so an update for the house is now pending.








Comments