Register a board
A blank ESP32 board → into the registry → controllable over WiFi with the USB cable unplugged.
This page follows the cube hardware line's registration process exactly as it was walked on 2026-09-16 on the LCD2 board
(MAC 40f520b06ebc, MicroPython v1.16). The focus is WiFi setup and WebREPL.
boot.py so the board does them at every boot, and finally
unplug the serial cable and accept the board over WiFi only. Serial is needed only while registering, never afterwards.- ✓ The eight steps themselves: walked end to end on LCD2.
- ✓ The first screen showed the test image: confirmed by eye.
- ? The screen after a swap: the self-test ran, but nobody has looked at the screen yet.
- ✗ The v2 template's
boot.py+compat.pyhave not been run on a board yet: the board lost power just as they were about to be uploaded. LCD2 runs the previous version (WiFi hard-coded, same logic). The first time you use v2, keep the serial cable in and read the boot log (step 7) before relying on WiFi.
main.py, not from memory.What "connected" means: three layers
| Layer | Criterion | What it proves |
|---|---|---|
| 1 | TCP connects | Almost nothing: a VPN TUN in fake-IP mode (Clash and the like) accepts every connect |
| 2 | WebREPL handshake succeeds and the board sends Password: | The board is there and its WebREPL service is running |
| 3 | The REPL really computes 6*7 = 42 | The main thread isn't blocked: the board is really controllable |
WebREPL lives and dies with the main thread: when main.py blocks it, WebREPL still handshakes but
never executes, and that handshake is a very convincing false signal. What really keeps a board from getting lost is not WebREPL but:
no blocking loop in main.py (long-running services live in their own module and are started remotely);
boot.py's fallback AP plus a try/except around everything; and the serial port as the physical last resort.
A fourth shape that none of the three layers catches, the "half-dead association": the board passes all three over serial, yet the computer can't ping it or connect to it, and only a board reset helps. Seen on the camera board after heavy streaming.
The eight steps
- 1Identify the board: by MAC, not by COM portSerial
- 2Check the firmware: it decides which APIs you can useSerial
- 3Test the peripheral alone: light it up before any networking, confirm by eyeSerial
- 4WiFi setup: scan, connect, get a real IPSerial
- 5Start WebREPL: once by hand, then try it from WiFi right awaySerial + WiFi
- 6Flash the files: generic layer + peripheral layer; keep the factory codeSerial
- 7Reset and read the boot log: it joins WiFi and starts WebREPL by itselfSerial
- 8WiFi-only acceptance: no serial; commands and a peripheral action, remotelyWiFi
Steps 3 and 8 are the easiest to skip and the most valuable. Step 3 keeps "the peripheral is broken" apart from "the network is broken"; step 8 is the only ruler that proves the registration worked.
1Identify the board: by MAC, not by COM portSerial
mpremote connect list
mpremote connect COM5 exec "import machine,ubinascii; print(ubinascii.hexlify(machine.unique_id()))"
b'40f520b06ebc'. That is the MAC. Compare it with the
registered boards below: a known board or a new one?- Don't write
.hex(): old firmware doesn't have it and fails with'bytes' object has no attribute 'hex'. Turned around, that error is the fastest way to recognise old firmware. - The COM port is not an identity: ports swap after re-plugging. One day four different boards showed up on the same COM5.
connect listprinting nothing means the device isn't there (USB not plugged in, or no power), not a driver problem.
2Check the firmwareSerial
mpremote connect COM5 exec "import sys,os,gc; print(sys.implementation); gc.collect(); print(gc.mem_free(), os.listdir())"
| Capability | v1.16 | v1.18 | v1.26.1 |
|---|---|---|---|
bytes.hex() | no | no | yes |
WLAN.config(pm=) power-save switch | no | no | yes |
webrepl.start(password=) | yes | yes | yes |
os.listdir() also shows what the factory installed. LCD2 shipped with ST7735.py and a test image,
and its wiring is written in the factory main.py: read it out, don't assume.
3Test the peripheral alone, before networkingSerial
Draw, beep or light something whose rightness you can see at a glance. For a screen, draw three color bars: red / green / blue, plus a white circle and a yellow line at the bottom.
LCD_SELFTEST_OK only proves the SPI commands finished, not that anything reached the screen.| Screen looks wrong | Try |
|---|---|
| Colors in the wrong order | rgb(False) |
| Color cast | initb() / initg() |
| Image shifted | adjust the window offsets |
| Garbage | drop SPI to 10 MHz |
- Power off before plugging or unplugging a screen or sensor. No hot-plugging: signal pins touching before VCC/GND can back-feed through the protection diodes and kill the screen or the ESP32's pins.
- GPIO12 is a strapping pin (MTDI). If the peripheral pulls it high at power-up, the flash voltage flips to 1.8 V and the board won't boot. The ST7735 wiring puts MISO on GPIO12. If a board won't boot after a swap, suspect GPIO12 before the screen.
- Good news: without the screen the board still boots and joins WiFi. ST7735 init is pure SPI writes, a missing screen raises no error.
4WiFi setup: scan, connect, get a real IPSerial
First scan with the board's own radio (more reliable than the computer's):
import network, time wl = network.WLAN(network.STA_IF); wl.active(True); time.sleep(1) for n in wl.scan(): print(n[0], 'rssi', n[3], 'ch', n[2])
Then connect, and trust ifconfig, not isconnected():
if wl.isconnected(): wl.disconnect(); time.sleep(1)
wl.connect('YOUR_SSID', 'YOUR_WIFI_PASSWORD')
for i in range(40):
if wl.isconnected(): break
time.sleep(0.5)
print(wl.ifconfig(), wl.config('essid'), wl.status('rssi'))ifconfig shows an IP in the same subnet as your computer
(on 09-16 both were on 192.168.27.x), and essid is the network you wanted.- Disconnect first. At boot the ESP32 re-joins the network saved in its NVS, so
isconnected()is already True and your first candidate never really got tried. - Print the SSID the board actually joined (
wl.config('essid')), not the one you asked for. An old boot.py printed the requested name, a fake SSID that misled troubleshooting. - Disconnect again before trying the next candidate, or
isconnected()lies. - The board's own scan beats the computer's: Windows, while connected, may list only the current SSID.
- 2.4 GHz only. Put a phone hotspot in compatibility mode.
- IPs and even subnets change: one phone-hotspot restart moved the subnet 192.168.193.x → 192.168.153.x → 192.168.27.x. Networks around you change too (one hotspot had simply disappeared from the scan).
- If no known WiFi works, boot.py opens a fallback AP with a different name on every board (cube-cam / cube-lcd / cube-lcd2).
Join it; the board is at
192.168.4.1. No fallback AP either means the board has no power. - Two hotspot names exist, for two different stages — don't mix them up: cube-<role> (cube-lcd2, cube-cam, …) is the
fallback AP of a board that went through these registration steps (its name comes from
cfg.json); MK-CUBE-xxxx (xxxx = the last 4 hex digits of its MAC) is a factory-fresh board with no WiFi settings yet, waiting for phone setup (the cube line's out-of-box flow). A board you registered that shows MK-CUBE-xxxx is running that factory setup flow with no WiFi settings — it is no longer in the state you registered; walk steps 4-7 again. - Wrap the whole boot.py in
try/except: if boot.py raises, you can't get into the board. Better no network than no REPL.
5Start WebREPL: once by hand, then try it from WiFiSerial + WiFi
import webrepl; webrepl.start(password='YOUR_WEBREPL_PASSWORD')
WebREPL daemon started on ws://<ip>:8266. Then, while it's fresh,
connect from the computer over WiFi (client below) and make it compute 6*7 = 42 before going on.| Three ways to set the password | Verdict |
|---|---|
webrepl.start(password='...') | Recommended. All three firmware versions support it; the password travels with cfg.json |
webrepl_cfg.py with PASS = '...' | Works, but it's one more file to manage |
import webrepl_setup | Never. It's an interactive wizard: it waits for keyboard input and the command times out |
Minimal client on the computer (Python, pip install websockets):
import asyncio, websockets
HOST, PW = "192.168.x.x", "YOUR_WEBREPL_PASSWORD"
async def recv_for(ws, s):
buf = b""
try:
while True:
c = await asyncio.wait_for(ws.recv(), timeout=s)
buf += c.encode() if isinstance(c, str) else c
except asyncio.TimeoutError:
pass
return buf
async def main():
# ping_interval=None is required
async with websockets.connect(f"ws://{HOST}:8266/", ping_interval=None) as ws:
await recv_for(ws, 6) # "Password: "
await ws.send(PW + "\r")
assert b">>>" in await recv_for(ws, 6)
await ws.send("print(6*7)\r\n")
print(await recv_for(ws, 6))
asyncio.run(main())- Release the serial port before connecting. While a serial session is open, WebREPL handshakes but doesn't execute commands, which looks exactly like a hung board.
ping_interval=Noneis required: the board never answers WebSocket ping/pong, and the websockets library pings every 20 s by default and drops the connection when no pong comes. Short tests don't show it; long operations (streaming) always hit it.- One WebREPL session at a time, and don't open one connection per command: connection churn has exhausted a board's TCP resources before. Use one persistent session for several commands.
6Flash the files: generic layer + peripheral layerSerial
| File | Layer | Change it per board? |
|---|---|---|
boot.py | Generic: join WiFi → fallback AP → start WebREPL | No |
compat.py | Generic: smooths over firmware differences (MAC, version, SSID, RSSI, power-save) | No |
cfg.json | Config: WiFi candidate list / AP name / password / id / role | Yes |
main.py | Peripheral: set up the peripheral + small functions you call remotely | The only file you edit |
cfg.json (the v2 template's keys; WiFi candidates are tried in order, put the preferred one first):
{"wifi": [["YOUR_SSID", "YOUR_WIFI_PASSWORD"], ["BACKUP_SSID", "BACKUP_PASSWORD"]],
"ap": ["cube-lcd2", "AP_PASSWORD"], "webrepl_pw": "YOUR_WEBREPL_PASSWORD",
"id": "lcd2", "role": "lcd"}Back up the factory code first (and keep a .bak on the board), then upload:
# 1) back up the factory code (main.py holds the wiring: keep it) mpremote connect COM5 fs cat boot.py > factory_code\boot.py mpremote connect COM5 fs cat main.py > factory_code\main.py mpremote connect COM5 fs cp :boot.py :boot.py.bak # 2) upload (wait 3-6 s between commands) mpremote connect COM5 fs cp cfg.json :cfg.json mpremote connect COM5 fs cp compat.py :compat.py mpremote connect COM5 fs cp boot.py :boot.py mpremote connect COM5 fs cp main.py :main.py # 3) check every file and its size mpremote connect COM5 fs ls
fs ls lists every file with the size you expect.- Calling mpremote back to back makes it collide with itself on the serial port ("in use" / "access denied"). Wait 3-6 s between commands. On LCD2 boot.py went up while main.py and lcdnet.py failed, leaving the board half done: new boot, old main.
- Rules for
main.py(template v2): keep initialised objects global and don'tdeinit()them; never put a blocking loop or anaccept()in it (long-running services go into their own module, started remotely, each with socket timeouts, a maximum run time and a remote stop switch); a background_threaddoesn't get around it (it starves while the main thread sits in the REPL: 12.06 FPS in the main thread, 0 in a background thread); end with oneREADYline listing the functions you can call. - The template is the cube line's generic-node template v2. Its boot.py and compat.py are not verified on a board yet (see the box at the top).
7Reset and read the boot logSerial
Reset with a short pyserial script that lets go of the port afterwards (the cube line's own reset-and-read-log script does
the same, with a DTR/RTS hard-reset fallback). Save it as reset_and_read.py:
import sys, time, serial
port = sys.argv[1]
secs = int(sys.argv[2]) if len(sys.argv) > 2 else 30
s = serial.Serial(port, 115200, timeout=0.3)
time.sleep(0.5)
for _ in range(4): # one Ctrl-C doesn't always get through
s.write(b"\x03"); time.sleep(0.35)
time.sleep(0.8)
s.reset_input_buffer()
s.write(b"import machine; machine.reset()\r\n")
t0 = time.time()
while time.time() - t0 < secs:
c = s.read(4096)
if c:
sys.stdout.write(c.decode("utf-8", "replace")); sys.stdout.flush()
s.close() # the port is free againpython reset_and_read.py COM5 30
WIFI_OK xm9 192.168.27.47 WebREPL daemon started on ws://192.168.27.47:8266 LCD2 READY selftest() clear() fill_color(r,g,b) ... VIDEO lcdnet.serve() receives video (:9000)
main.py
raised an error or is stuck. The v2 boot.py prints WIFI_OK <SSID it really joined> <ip>
(or WIFI_FALLBACK_AP <name> <ip> when no WiFi worked), then NODE <id> <role> BOOT_OK.- Don't reset with
mpremote exec "machine.reset()": it never exits and keeps holding the serial port (it once locked COM5 for hours). - After changing a file on the board, always reset.
importhands back the cached old module; uploading a file doesn't reload it, and Ctrl-C doesn't clearsys.modules. Upload → reset → use: three steps, never two.
8WiFi-only acceptance, USB unpluggedWiFi
Don't touch serial. Run remotely: identity (the MAC matches) → network (IP / SSID / RSSI) → a peripheral action. The action must draw something different from step 3 (09-16: purple background, green circle, white X), or you can't tell a new image from a leftover one.
UID b'40f520b06ebc' MEM 85168 IP 192.168.27.47 SSID xm9 RSSI -60 LCDNET True TFT_SHARED True REMOTE_DRAW_OK_OVER_WIFI
Verify over WiFi (below) runs the three layers plus the identity and network check from the backend. The peripheral action still needs your eyes.
- Prove the USB is really unplugged:
mpremote connect listprints nothing. Prove the board really restarted: its uptime is small. A connection that works 3 s aftermachine.reset()doesn't prove the reset happened. - Unplugged boards run slower (LCD: RSSI -49 → -57, ping 24 → 124 ms, video 6.7 → 4.5 FPS). Note how the board was powered next to any number you measure, or two measurements can't be compared.
Record the board
Every registered board records at least these fields:
| Field | Why |
|---|---|
| MAC | The only reliable identity |
| Firmware version | Decides which APIs you can use |
| Peripherals + wiring | Read them from the factory main.py, not from memory |
| Fallback AP name | Different on every board, or two hotspots look the same |
| IP / SSID / RSSI at the time | They change, but they tell you where to try first |
| Material folder | Factory code, the files you flashed, a README |
Boards in the cube line's own registry (09-16), for comparing MACs in step 1. They are not registered in 3C until you record them here.
| Board | MAC | Firmware | Peripheral | Fallback AP | Status |
|---|---|---|---|---|---|
| CAM | c0cdd6cfa5fc | v1.18 | OV camera | cube-cam | registered |
| LCD | 8c4b14432108 | v1.26.1 | ST7735 128×160 | cube-lcd | registered; accepted with USB unplugged |
| LCD2 | 40f520b06ebc | v1.16 | ST7735 128×160 (same wiring) | cube-lcd2 | registered 09-16 |
| CAM2 | ece334… | — | — | — | not done by this process |
The camera boards also have a separate multi-board firmware upgrade line (v1.27); for them that line's page wins.
Registered boards
The check runs on the backend, not in your browser:
TCP connect → WebREPL handshake + Password: → password → >>> →
print(6*7) → 42, then it reads the board's MAC (an IP may now belong to another board) and its IP / SSID / RSSI.
One WebREPL connection per click; the board takes one session at a time.
When you can't find the board
- Ping the gateway first. That proves your own computer's network works (this computer may run a VPN TUN).
- Same subnet? Compare
ipconfigon the computer with the board'sifconfig. Three devices on three subnets was the number one misdiagnosis. - Look at the ARP table: not in
arp -a= not on the network. - Count only
Reply from <board IP>:. Windows ping countsDestination host unreachable(sent by your own computer) as received: "10 sent, 5 received" once came from a board that had no power. - Nothing works → join the board's fallback AP → still nothing → only then the serial cable.
ping -n 6 192.168.27.1 ping -n 6 192.168.27.47 | findstr /c:"Reply from 192.168.27.47:" arp -a
IPs keep changing, which is the direct reason for "self-report": the board broadcasts itself over UDP on a timer,
and the computer listens (netbeacon.py on the board, cube_find.py on the computer; 3C keeps a copy in
tools/cube_find.py). It is written and tested on a computer, not installed on any board yet.
Error messages that mislead
| You see | The truth | How to tell |
|---|---|---|
failed to access COM5 (it may be in use by another program) | Often the device is simply gone | mpremote connect list first: empty = not there; listed = really busy, retry after 3-6 s |
ClearCommError ... PermissionError(13) | The previous mpremote hasn't let go of the port | Retry after a few seconds |
| ping "10 sent, 5 received" | Maybe not a single real reply | Count only Reply from <target IP>: |
| WebREPL handshake OK | Doesn't mean it can execute | Make it compute 6*7 |
A script prints SELFTEST_OK | Doesn't mean anything reached the screen | Look at it |