A self-mapping RF instrument. An M5Stack Tab5 with a tethered AI camera scans a room into a floor plan — then measures the WiFi and paints the signal onto the map it just drew, one heat layer per antenna, including a custom patch antenna PCB. No imported floor plans anywhere in the loop: the instrument draws its own.
Built for the M5Stack Global Innovation Contest 2026, as an exercise in pushing the Tab5 (ESP32-P4) and friends to their limits — and, at roughly $130 of parts, an accidental RF classroom: aim the patch antenna and watch directionality appear on the map; swap in an antenna you made and the instrument tells you if it's better. (The custom antenna boards were $40 for a panel of 45 — the antenna is literally the cheapest part of the instrument that measures it.)
┌───────────────────────────── M5View shell ─────────────────────────────┐
│ 3D Viewer Room Scan Antenna │
│ .mesh renderer UnitV camera → YOLO → 2.4GHz RSSI scope, │
│ (orbit/zoom) carved floor plans, channel scan, polar │
│ WALK+ scans, building map, plot, INT/EXT switch │
│ RF heatmap surveys │
└──────────────────────────────────────────────────────────────────────────┘
Tab5: ESP32-P4 @360MHz, 1280x720 touch, BMI270 IMU, ESP32-C6 WiFi
UnitV: Kendryte K210 (KPU NPU), OV7740, MaixPy — tethered via Grove UART
- Sweep scan — stand in a corner, sweep 360°. The BMI270 gyro measures the turn (stillness-locked bias calibration, gravity-projected so grip doesn't matter); a sweep dial fills as you rotate, dropping a colored pip at the bearing of every object the camera confirms, and the scan finishes itself at a measured full turn. The tethered UnitV streams JPEG + YOLOv2 detections over 460800-baud UART; objects are ranged monocularly from known real-world sizes (width preferred for wide furniture), passed through box sanity gates, and placed by the median of their sightings.
- Visibility-carved floors — no wall sensor, so the floor is built from evidence: where you stood, everything you saw (each sight-line proves open floor between you and the object), and everywhere you walked. Rooms come out L-shaped when they are L-shaped — not bounding boxes.
- WALK+ — extend a scanned room on foot. IMU step detection dead-reckons your path (drift absorbed by registering against the room's own objects as landmarks); the walked path becomes floor-truth for the carve, and —
- Walk-fused RF survey — while you walk, the target AP is sampled at your pose and bearing, so one walk produces the room and its heatmap. Manual tap-to-sample still works. Heat layers are per-antenna (internal vs the custom MMCX patch via the Tab5's RF switch), with a dBm legend and automatic DEAD / BEST spot callouts. Because every sample records its aim, the directional patch is measured honestly — and its strongest bearing draws an "AP this way?" ray. (Ekahau needs an imported floor plan; Platypus draws its own.)
- Additive scanning — rescanning registers new observations against the room's persistent object database (yaw + translation solved from the objects themselves) and merges. Rooms improve with every pass; a per-room object menu toggles anything the model found on/off, and the floor re-carves to match.
- Building map — every room's carved footprint on one top-down canvas; drag rooms to arrange your building, tap to open. Arrangements persist.
- Live view — realtime detection viewfinder with class-colored boxes.
- 3D Viewer — the original mesh renderer (int16 vertices, precomputed
normals, bucket-sorted painter's algorithm) for
.meshfiles from SD;tools/stl_to_mesh.pyconverts STLs. - Antenna — 2.4GHz test bench: a live RSSI oscilloscope and a fast-sampling walk test, both driving the internal/external antenna switch, with per-antenna running averages and CSV logging to SD.
Tab5 3D Render/ the Tab5 firmware (PlatformIO, Arduino, ESP32-P4)
src/ M5View shell + applets (viewer, scanner/, antenna/)
src/scanner/cv/ portable SfM pipeline for Phase 2 (ORB, RANSAC,
essential matrix, triangulation, surface recon)
docs/ renderer internals, heatmap design, detection
tuning protocol, antenna/WiFi postmortem
tools/ stl_to_mesh, screenshots, OTA push, serial log
meshscan/
unitv/ UnitV (K210/MaixPy) camera firmware — deploys as a
single bundled boot.py
tools/ unitv_bundle, unitv_upload, mesh_to_obj, kmodel_info
shared/ standalone .mesh format spec
docs/ roadmap docs: ToF wall ranging, RF directionality,
custom detection model walkthrough
assets/ build photos + device screenshots
Start here: docs/BUILD_GUIDE.md — desktop prerequisites → assembly → camera flash → Tab5 flash → first scan, with a troubleshooting table. docs/CODE_TOUR.md is a guided reading order for the source. The short version:
Tab5 (PlatformIO):
cd "Tab5 3D Render"
pio run -e tab5 -t upload # over USB
pwsh tools/ota_push.ps1 # over WiFi (device on home screen)- Create
Tab5 3D Render/include/wifi_creds.h(gitignored) withOTA_WIFI_SSID / OTA_WIFI_PASS / OTA_HOSTNAMEbefore building. - OTA arms only on the M5View home screen (green
OTA readychip), so it never fights the applets that own the radio. 60s watchdog + serial heartbeat built in.
UnitV camera (MaixPy v0.5.0 on the K210):
cd meshscan/tools
python unitv_bundle.py # bundles modules into one boot.py
python unitv_upload.py ../unitv/build/boot.py /flash/boot.py COM10- The bundle exec-loads every module from RAM at boot (this MaixPy build's boot-time filesystem imports are unreliable) and sidesteps SD shadowing.
- Detection model: tiny-YOLOv2 VOC-20 (
meshscan/unitv/model/20class.kmodel, from kendryte-standalone-demo) goes on the camera SD as/sd/20class.kmodel.
Debug niceties
python "Tab5 3D Render/tools/tab5_screenshot.py" COM6 out.bmp— sendSSover USB, get a pixel-perfect 1280x720 framebuffer capture.tools/serial_log.py— timed telemetry capture (it localized an intermittent camera freeze to corrupt bytes arriving on the link — frames stalled while CRC errors climbed, receiver healthy throughout, resync self-recovering within seconds).- Serial heartbeat
[hb] up/home/ota/heapevery 5s pinpoints any lockup.
- Tab5 WiFi: the P4 has no radio — WiFi rides an ESP32-C6 over hosted
SDIO. Arduino defaults target the P4 EV-board; the Tab5 needs
WiFi.setPins(12, 13, 11, 10, 9, 8, 15)before any WiFi call. - ArduinoOTA/mDNS must init at most once per boot on hosted WiFi — an end()/begin() cycle hard-froze the loop task.
- UART RX buffer:
setRxBufferSize()is a no-op afterbegin(). Real-room JPEGs (~8KB) overflowed the default buffer → CRC storm. 32KB, set before begin. - PI4IOE5V6408 expander (RF switch, 0x43): register 0x01 is Chip ID/Control with bit0 = software reset — writing it blanks the display (other rails share the chip). Output register is 0x05; Hi-Z (0x07) must be cleared to drive a pin.
- K210/MaixPy:
/sdprecedes/flashin sys.path (stale SD files shadow firmware); boot-time file opens fail intermittently on some files; ampy crawls at ~1KB/s — hence the bundler + custom uploader. - A directional antenna breaks position-only surveys: patch RSSI is a function of position AND aim (10–15dB front-to-back), so every sample records its bearing — omni layers ignore it, the patch layer requires it.
Measured wall polygons via a ToF ranger on the M5-Bus I2C (the sweep dial
becomes a live floor-plan radar — see docs/TOF_RANGING.md), a custom
detection model with door/window classes (docs/CUSTOM_MODEL_WALKTHROUGH.md),
Phase-2 SfM walls, an M5-Bus expansion breakout PCB ("Design B": one small
board fans the internal I2C out to Grove jacks — ToF at 0x29, the joystick
at 0x63 whose driver already waits in-tree, and friends), and a printed
enclosure with a peripheral bay + swappable sensor pod to house it all.
Built collaboratively with Claude (Anthropic) driving firmware, debugging, and tooling over serial — including taking its own screenshots of the device.