When the Christmas tree pings an API.
Westfield needed Santa during a Covid Christmas. We built a WebRTC video-call platform — and let kids pick the tree lights live with an Arduino on the other end. Here is the technical walk-through, the production challenges, and the lessons we still apply to every hardware brief.
Westfield's grotto wasn't going to happen in 2020. The mall was closed, the queue was an impossibility, the Santa-on-a-throne tradition was indefinitely on hold. We had a few weeks. The brief was straightforward: still let kids meet Santa, still make it feel magical, still ship it before the lights officially turned on across the centres. The constraint that made it interesting was the Arduino-driven Christmas tree on the production end.
Two halves of one product
On the public side we built a booking surface — parents picked a date and time, got a calendar slot, and showed up to a WebRTC video call at the right moment. Standard, polished, on-brand Westfield. The booking system was Nuxt + Mongo + Stripe for the donation flow; the video call was WebRTC through Twilio Video for reliability.
On the magical side we built an Arduino-driven Christmas tree. When the child reached the part of the call where Santa asks 'what colour shall we make the lights?' — the producer triggered an API call, the Arduino flipped the LEDs, and the tree behind Santa changed in real time. The kid sees it through the camera. The kid loses their mind.
The end-to-end pipeline
| Stage | Component | What it did |
|---|---|---|
| Parent books | Nuxt booking flow + Stripe | Slot reservation, payment, calendar invite |
| Reminder sent | Triggered email at T-1h | iCal invite, video link, troubleshooting guide |
| Family joins call | Twilio Video room | Auto-join at the booked time, Santa already in |
| Producer chooses cue | Browser control surface | Buttons for 'red', 'green', 'rainbow', 'sparkle' |
| Cue hits API | Nitro route on Nuxt | Translates UI action to hardware command |
| API hits Arduino | HTTPS → MQTT bridge | Each tree subscribes to its own topic |
| Arduino flips LEDs | Custom firmware | Adafruit NeoPixel library, simple state machine |
| Camera sees lights | Santa's webcam | The child sees the change in real time on their screen |
The Arduino side, in code
The firmware was small. Subscribe to an MQTT topic per tree, listen for colour commands, render to the NeoPixel strip. The whole sketch fits on the back of a postcard.
#include <WiFi.h>
#include <PubSubClient.h>
#include <Adafruit_NeoPixel.h>
#include <ArduinoJson.h>
#define LED_PIN 6
#define LED_COUNT 150
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
WiFiClient net;
PubSubClient mqtt(net);
void onCue(char* topic, byte* payload, unsigned int length) {
StaticJsonDocument<128> doc;
deserializeJson(doc, payload, length);
const char* effect = doc["effect"];
if (strcmp(effect, "red") == 0) fillSolid(255, 0, 0);
else if (strcmp(effect, "green") == 0) fillSolid(0, 255, 0);
else if (strcmp(effect, "rainbow") == 0) rainbow(20);
else if (strcmp(effect, "sparkle") == 0) sparkle(50);
}
void fillSolid(uint8_t r, uint8_t g, uint8_t b) {
for (int i = 0; i < LED_COUNT; i++) {
strip.setPixelColor(i, strip.Color(r, g, b));
}
strip.show();
}
void setup() {
strip.begin();
WiFi.begin(SSID, PASSWORD);
mqtt.setServer(MQTT_HOST, 1883);
mqtt.setCallback(onCue);
mqtt.subscribe("santa/tree/01/cue");
}
void loop() {
if (!mqtt.connected()) reconnect();
mqtt.loop();
}The producer-side API
The producer side is a Nitro route that takes a cue from the operator UI and forwards it to the right MQTT topic. The whole thing fits in 30 lines of TypeScript.
import mqtt from 'mqtt'
const client = mqtt.connect(process.env.MQTT_URL!, {
username: process.env.MQTT_USER,
password: process.env.MQTT_PASS
})
export default defineEventHandler(async (event) => {
const { treeId, effect, producerId } = await readBody(event)
// Validate cue + producer auth
if (!isAuthedProducer(producerId)) {
throw createError({ statusCode: 403 })
}
if (!ALLOWED_EFFECTS.includes(effect)) {
throw createError({ statusCode: 400, message: 'Unknown effect' })
}
await new Promise<void>((resolve, reject) => {
client.publish(`santa/tree/${treeId}/cue`, JSON.stringify({ effect }),
{ qos: 1 }, (err) => err ? reject(err) : resolve()
)
})
return { ok: true, effect, treeId, at: new Date().toISOString() }
})The kid sees the change in real time on their screen. The kid loses their mind. The magic is real — because the lights are real. That difference is what 'hardware integration' actually means.
Why it worked
- 01The hardware loop was honest — the lights you saw were physical lights, not a filter.
- 02The API surface was simple enough that the producer could trigger it without thinking.
- 03The whole thing was prototyped on a desk in two days before any of it was deployed.
- 04We picked MQTT for the device transport because it's purpose-built for this — small messages, persistent connections, QoS guarantees.
- 05We picked Arduino over a Raspberry Pi because the simpler the device, the fewer the failure modes. A Pi running Node is fine until the SD card corrupts during the Christmas rush.
- 06We sent every cue through QoS 1. 'At least once' delivery is fine when the worst case is the lights flash twice.
What went wrong
- 01The first prototype tree drifted out of sync on the LED chain. Solution: ground every connection properly, separate the data line from the power.
- 02Wi-Fi at the production venue was unreliable. We added a 4G fallback to every tree.
- 03Producer UI was initially too complex — 16 effects, too many buttons. We cut to 6.
- 04Camera autoexposure occasionally washed out subtle effects. We re-graded the effect palette to be more saturated.
It's still our favourite story for explaining what we mean when we say 'hardware integration': pixels that move atoms. Five years on, the same architecture pattern — booking on the front, producer in the middle, hardware on the back — runs almost every installation we ship.