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.

In one sentence Registering means: first find out which board it is and what firmware it runs, then light up its peripheral on its own, then set up WiFi and WebREPL and write both into 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.
Checking where this page runs…
What is proven and what is not (cube line, 2026-09-16)
Register the board, program the cubes One ESP32 hosts several cubes (the sensor board carries distance + buzzer + screen), so identity belongs to the board: its MAC. The platform has 12 programmable cube kinds: 3 run from Blockly on a real board, 2 run on their own boards, 7 are stubs (cube-line overview, 09-15). "Plug and play" is not true yet: the original sensor programs give the same pins to different parts (GPIO 25/32/12/13/34), so record the wiring you read from the board's factory main.py, not from memory.

What "connected" means: three layers

LayerCriterionWhat it proves
1TCP connectsAlmost nothing: a VPN TUN in fake-IP mode (Clash and the like) accepts every connect
2WebREPL handshake succeeds and the board sends Password:The board is there and its WebREPL service is running
3The REPL really computes 6*7 = 42The 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

  1. 1Identify the board: by MAC, not by COM portSerial
  2. 2Check the firmware: it decides which APIs you can useSerial
  3. 3Test the peripheral alone: light it up before any networking, confirm by eyeSerial
  4. 4WiFi setup: scan, connect, get a real IPSerial
  5. 5Start WebREPL: once by hand, then try it from WiFi right awaySerial + WiFi
  6. 6Flash the files: generic layer + peripheral layer; keep the factory codeSerial
  7. 7Reset and read the boot log: it joins WiFi and starts WebREPL by itselfSerial
  8. 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()))"
Success looks like: 12 hex digits, e.g. b'40f520b06ebc'. That is the MAC. Compare it with the registered boards below: a known board or a new one?
Traps
  • 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 list printing 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())"
Capabilityv1.16v1.18v1.26.1
bytes.hex()nonoyes
WLAN.config(pm=) power-save switchnonoyes
webrepl.start(password=)yesyesyes
Conclusion (measured): v1.16 can do the whole registration; the old template's "needs v1.19+" is false. The only real gap is the power-save switch. Other versions were not measured: check them yourself.

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.

Success looks like: you see it. The only judge is a human eye: a script printing LCD_SELFTEST_OK only proves the SPI commands finished, not that anything reached the screen.
Screen looks wrongTry
Colors in the wrong orderrgb(False)
Color castinitb() / initg()
Image shiftedadjust the window offsets
Garbagedrop SPI to 10 MHz
Traps
  • 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'))
Success looks like: 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.
Traps (this is where most time went)
  • 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')
Success looks like: it prints 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 passwordVerdict
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_setupNever. 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())
Traps
  • 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=None is 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
FileLayerChange it per board?
boot.pyGeneric: join WiFi → fallback AP → start WebREPLNo
compat.pyGeneric: smooths over firmware differences (MAC, version, SSID, RSSI, power-save)No
cfg.jsonConfig: WiFi candidate list / AP name / password / id / roleYes
main.pyPeripheral: set up the peripheral + small functions you call remotelyThe 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
Success looks like: fs ls lists every file with the size you expect.
Traps
  • 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't deinit() them; never put a blocking loop or an accept() 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 _thread doesn'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 one READY line 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 again
python reset_and_read.py COM5 30
Success looks like (LCD2's real log, 09-16; the last line translated):
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)
Whichever line is missing tells you which layer didn't come up: no line 1 = WiFi; no line 2 = WebREPL; no line 3 = 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.
Traps
  • 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. import hands back the cached old module; uploading a file doesn't reload it, and Ctrl-C doesn't clear sys.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.

Success looks like (LCD2, 09-16):
UID b'40f520b06ebc'  MEM 85168
IP 192.168.27.47  SSID xm9  RSSI -60
LCDNET True  TFT_SHARED True
REMOTE_DRAW_OK_OVER_WIFI
The last gate is still a human eye on the screen.

Verify over WiFi (below) runs the three layers plus the identity and network check from the backend. The peripheral action still needs your eyes.

Traps
  • Prove the USB is really unplugged: mpremote connect list prints nothing. Prove the board really restarted: its uptime is small. A connection that works 3 s after machine.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:

FieldWhy
MACThe only reliable identity
Firmware versionDecides which APIs you can use
Peripherals + wiringRead them from the factory main.py, not from memory
Fallback AP nameDifferent on every board, or two hotspots look the same
IP / SSID / RSSI at the timeThey change, but they tell you where to try first
Material folderFactory 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.

BoardMACFirmwarePeripheralFallback APStatus
CAMc0cdd6cfa5fcv1.18OV cameracube-camregistered
LCD8c4b14432108v1.26.1ST7735 128×160cube-lcdregistered; accepted with USB unplugged
LCD240f520b06ebcv1.16ST7735 128×160 (same wiring)cube-lcd2registered 09-16
CAM2ece334…———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.

No password field here, on purpose: passwords are never stored. You type the WebREPL password only when you verify. Registering the same MAC again updates that board.

Paste what step 1 printed. Separators and the b'…' wrapper are fine.
Kinds outside the 12 cube kinds are fine (e.g. lcd_st7735): recorded, but Blockly can't program them.

Registered boards

Used for one check only: never stored, logged or shown back.
Board pings of up to 1.8 s have been seen.

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

  1. Ping the gateway first. That proves your own computer's network works (this computer may run a VPN TUN).
  2. Same subnet? Compare ipconfig on the computer with the board's ifconfig. Three devices on three subnets was the number one misdiagnosis.
  3. Look at the ARP table: not in arp -a = not on the network.
  4. Count only Reply from <board IP>:. Windows ping counts Destination host unreachable (sent by your own computer) as received: "10 sent, 5 received" once came from a board that had no power.
  5. 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 seeThe truthHow 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 portRetry after a few seconds
ping "10 sent, 5 received"Maybe not a single real replyCount only Reply from <target IP>:
WebREPL handshake OKDoesn't mean it can executeMake it compute 6*7
A script prints SELFTEST_OKDoesn't mean anything reached the screenLook at it