I built KanKaung, an ESP32-based robot car that kids control with Scratch-style blocks — rotate a servo, blink an LED, read a sensor. It supports two ways to talk to it: WiFi and Bluetooth. This post is the story of turning that into one clean extension instead of two, and every bug I ran into along the way. I'm writing it mostly so future-me doesn't repeat these mistakes.
The starting point
The firmware originally drove two continuous-rotation servos through a simple command set:
left_servo cw|ccw|stopright_servo cw|ccw|stop-
left_servo_speed 0-100,right_servo_speed 0-100 led_on|led_off green|red|all-
sensor_distance,sensor_ir_left,sensor_ir_right
Both the WiFi path (plain HTTP GET /cmd?action=...&value=...) and the Bluetooth path (a BLE characteristic you write a plain string to, like "left_servo cw") spoke the exact same vocabulary. That symmetry mattered a lot later.
Why I wanted one extension instead of two
I had kankaung-extension-wifi.js and kankaung-extension-bluetooth.js as separate files. Same blocks, copy-pasted twice, differing only in how they sent commands. The real problem: if a kid built a whole program using the WiFi extension and then wanted to switch to Bluetooth, they had to rebuild everything from scratch — the blocks were tied to a specific extension ID.
So the plan was: one extension, one block set, and pick the transport once.
Mistake #1: I first tried a dropdown on a "connect" block
My first design had a connect via [WiFi/Bluetooth] (IP: [IP]) block that kids would drag in. It worked, technically — but it didn't match how the app was actually meant to be used. The intent was: you connect once when the extension loads, and never think about it again. Having a "connect" block meant re-running it every time, which didn't make sense for something that should just be a one-time setup.
Lesson: figure out the actual intended UX before building the mechanism. I built the dropdown-on-a-block version first and had to rip it out later.
Mistake #2: two systems fighting over the same state
While ripping out the connect block, I found something worse: there was also a host-page popup modal that asked "WiFi or Bluetooth?" when the extension loaded — built earlier, in parallel, and left in place. Both the block's dropdown and the modal were trying to set which transport the extension used (this.transport), and whichever fired last silently won.
Symptom: connect via Bluetooth successfully, then run any block, and get:
Error: Not connected - use the connect block first
at kankaung-wifi.js:103
Bluetooth had connected. But something, somewhere, quietly flipped the transport back to "wifi".
Lesson: when two independent pieces of code can both set the same flag, one of them is a bug waiting to happen. Pick one source of truth. In the end, the modal became the only thing allowed to set the transport, and I deleted the connect block entirely.
Mistake #3: connecting doesn't mean commands work
Once transport-selection was untangled, I still had command blocks that just... did nothing. No error. No movement. Nothing.
Turned out there wasn't a bug in the extension or the firmware at all — there was a missing file. The extension only does postMessage() to the parent page; something else has to catch that message and actually call fetch() or the Web Bluetooth API. I hadn't looked at that bridge code yet.
When I finally got it, I found:
const wifiSendHandler = async (event) => {
await kankaungWiFi.send(event.detail);
console.log("...completed:", event.detail);
};
This calls .send() and logs it — but never tells the extension the command finished. The extension's sendCommand() returns a Promise that only resolves when a KANKAUNG_WIFI_DONE message comes back. Since that message was never sent, every command's Promise just hung forever. From inside Scratch, a block that never finishes looks exactly like "nothing happened" — even though the robot might have physically moved.
Lesson: "the command was sent" and "the caller knows it finished" are two different things, and skipping the second one produces a bug that looks nothing like its actual cause.
Mistake #4: a real race condition in the Bluetooth code
Testing Bluetooth specifically, I hit:
Bluetooth connect failed: GATT Server is disconnected. Cannot retrieve services.
(Re)connect first with `device.gatt.connect`.
This is a known ESP32-BLE-vs-Chrome timing issue: device.gatt.connect() can report success a moment before the link has actually settled, so the very next call (getPrimaryService()) can land on a connection that's already gone.
Fix: add a short delay after connecting, and retry the whole connect-and-discover sequence a couple of times with backoff before giving up:
async _connectGattWithRetry(maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const server = await this.device.gatt.connect();
await new Promise(r => setTimeout(r, 400)); // let it settle
if (!this.device.gatt.connected) throw new Error('dropped');
const service = await server.getPrimaryService(SERVICE_UUID);
// ...get characteristics...
return { server, service, /* ... */ };
} catch (err) {
if (attempt < maxAttempts) await new Promise(r => setTimeout(r, 500 * attempt));
}
}
}
Mistake #5: a stuck queue when a BLE notification gets dropped
Related bug, same file: the Bluetooth command queue only released its "busy" flag when a notification came back from the ESP32 confirming the command finished. If even one notification got dropped (easy right after connecting, before the subscription has fully settled), busy stayed true forever — and every command after that one just silently queued up and never got sent, not just the first one.
Fix: a bounded timeout. If no reply shows up in a few seconds, resolve anyway and let the queue keep moving instead of wedging permanently:
this._pendingTimeout = setTimeout(() => {
if (this.pendingResolve) {
const resolve = this.pendingResolve;
this.pendingResolve = null;
resolve({ status: 'timeout', command: item.command });
this.finish(); // release busy, keep the queue going
}
}, 4000);
Mistake #6: the classic — I forgot to actually deploy the fix
After several rounds of edits, I hit the exact same "Not connected" error again, even after supposedly fixing it. The extension's own diagnostic log ([Extension] Transport mode set to: bluetooth) never appeared — meaning the message wasn't even being handled.
The bug wasn't in the code at all. It was that the file actually running on the server was an older version from a step in between two fixes — I'd removed the mode-setting listener in one pass, then added it back in a later pass, but the redeployed file never made it to the actual path the browser was loading from.
Lesson: when a bug you already fixed comes back exactly the same way, check whether your fix actually got deployed before you start debugging the logic again. Searching the live file for the exact string you expect to have added (grep, or just Ctrl+F in the file) takes ten seconds and saves an hour.
The final architecture
-
One extension file, one block set:
rotate left/right servo [cw/ccw/stop],stop left/right servo,set left/right servo speed, LED blocks, sensor blocks. -
No connect block. The extension posts
KANKAUNG_EXTENSION_LOADEDthe moment it loads; the host page catches that and shows a one-time mode-selection popup. -
The popup is the single source of truth for transport — it posts
KANKAUNG_SET_MODEto the extension and fires the real connect (KANKAUNG_WIFI_CONNECT/KANKAUNG_CONNECT) in the same click handler. - Every block internally checks
this.transportand routes to either the WiFi channel (KANKAUNG_WIFI_*messages → HTTPfetch()) or the Bluetooth channel (KANKAUNG_*messages → Web BluetoothwriteValue()), completely invisibly to the person dragging blocks around. - Both channels always reply with a
_DONEor_ERRORmessage so the extension's promises actually resolve.
Quick checklist for next time
- [ ] Does every "send a command" path have a matching "here's your reply" path? A promise with no way to resolve is a silent hang, not an error.
- [ ] Is there more than one place that can set the same piece of state? If yes, that's a race condition waiting to happen — pick one owner.
- [ ] For BLE specifically: never assume
connect()resolving means the link is stable. Add a settle delay and a retry. - [ ] For any queue that waits on an external reply: always have a timeout fallback. "Waiting forever" should never be a valid state.
- [ ] Before debugging logic again, check that your last fix is actually the file being served.
grepthe live file for a string you know you added.