A minimal but real dual-brain PWA that runs entirely on an Arduino Uno Q (4 GB RAM / 32 GB eMMC). It serves a full-screen 3D MapLibre map from OpenStreetMap tiles, drops a live GPS dot from an I²C GNSS module wired to the STM32U585 MCU, and does the whole thing over HTTPS so Safari will install it as a proper standalone PWA. There is no App Lab dependency past first boot, no cloud, no auth. Every configuration lives on the SD card or the local Debian userspace, and every service is a systemd unit or docker container that survives reboot.
The demo intentionally exercises both cores of the Uno Q:
- The Cortex-A53 MPU runs Debian, Docker, nginx, node, and tileserver-gl. It owns the storage, the network, and the PWA.
- The STM32U585 MCU runs an Arduino/Zephyr sketch that talks
I²C to the GNSS module and streams fixes over the internal
arduino-routerbridge to the MPU.
If either side crashes, the other keeps working (the frontend shows "GPS stale" if the MCU falls off; the MCU keeps emitting fixes even if docker restarts).
- What you'll need
- Architecture
- First-time bring-up — Uno Q side
- First-time bring-up — dev laptop side
- Wi-Fi provisioning via the SD card
- Building map tiles from OpenStreetMap
- Formatting the SD card and loading the tiles
- Wiring the peripherals
- Generating the TLS certs
- Trust the CA on your devices
- Deploying the docker stack
- Flashing the STM32U585 sketch
- Verifying the demo
- POI search (optional)
- Resource footprint on the live board
- Known Uno Q issues this project works around
- Project layout
- What's next (Phase 3)
| Item | Notes |
|---|---|
| Arduino Uno Q | 4 GB / 32 GB variant. The 2 GB variant reboots under load with a desktop environment; this project targets the 4 GB. |
| DFRobot Gravity GNSS (DFR1103) | Quectel L76K + SD3031 RTC, I²C mode. 3.3–5 V tolerant. Ships with a 4-pin Gravity PH2.0 cable. |
| Active GNSS antenna | The one that ships with the DFR1103 works. If you're using a bare u.FL board with a separate antenna, make sure it's an active (LNA-powered) antenna — passive ones can take 15 min to cold-lock indoors. |
| microSD card, ≥ 64 GB | Fast card (UHS-I is fine). We put 25 GB of North America tiles on it. The card lives on a USB-attached reader (see next row) — the Uno Q has no direct microSD slot for the MPU. |
| USB microSD reader | Any generic one. Uno Q sees it as /dev/sda1. |
| Powered USB-C hub | The Uno Q has one USB-C port that also serves as its power input. The hub gives it a way to be a USB host (for the SD reader) while receiving power. A powered hub is strongly recommended — bus-powered hubs can under-volt the Uno Q under load. |
| Ethernet dongle (optional) | The Uno Q comes up on Wi-Fi via the SD-card-based provisioning; a wired option is nice for the initial ADB bring-up but not required. |
| USB-C cable + laptop USB port | For the initial ADB bring-up, before Wi-Fi is configured. |
Linux is what this was developed on. macOS should mostly work with minor adjustments. Windows likely needs WSL2.
| Tool | Notes |
|---|---|
| Docker + Docker Buildx | Buildx needs to support linux/arm64. Test with docker buildx ls. |
adb |
sudo apt install -y adb on Debian/Ubuntu. Used for the initial bring-up. |
arduino-cli |
Only on the board, not on the laptop. It's already installed on the Uno Q's Debian image. |
openssl |
For generating the self-signed CA + server certs. Ships with every Linux distro. |
| Java 17+ | For running Planetiler (map tile generation). sudo apt install -y default-jre. |
| Python 3 | For the MBTiles sanity checks and the WebSocket verification snippets. |
curl, ssh, scp, rsync |
Standard networking tools. |
Browser (Safari / Firefox / Chrome)
│ HTTPS 443
▼
┌────────────────────┐ ┌────────────────────┐
│ frontend (nginx) │◄──►│ backend (node+ws) │
│ TLS 443, serves │ │ TCP-connects to │
│ PWA + reverse- │ │ 127.0.0.1:7500 to │
│ proxies /api, │ │ read MCU fixes, │
│ /ws, /styles, etc. │ │ broadcasts /ws │
└─────────┬──────────┘ └─────────┬──────────┘
│ │
/styles, /data, /fonts host-network
│ │
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ tileserver-gl │ │ arduino-router │
│ reads │ │ (systemd service) │
│ /data/tiles/map. │ │ bridges MCU serial │
│ mbtiles from SD │ │ ↔ 127.0.0.1:7500 │
│ card via bind- │ └─────────┬──────────┘
│ mount │ │
└────────────────────┘ │ /dev/ttyHS1
│
▼
┌──────────────────┐
│ STM32U585 MCU │
│ (Zephyr + our │
│ sketch) │
│ I²C ─► DFR1103 │
│ GNSS receiver │
└──────────────────┘
All three containers run on the Uno Q's Debian; the MCU sketch is
flashed into STM32U585 flash and runs autonomously.
The important architectural decisions:
network_mode: hoston the backend so it can reach the arduino-router at127.0.0.1:7500(the router only listens on loopback).extra_hosts: host.docker.internal:host-gatewayon the frontend so nginx can proxy/apiand/wsto the host-network backend.- Certs are bind-mounted read-only into the nginx container so regenerating them on the laptop just needs a container restart.
- Tiles live on a USB-mounted SD card (
/mnt/unoq-sd) via fstab by label, so device-node reshuffles across boots don't break it.
This is the sequence that gets a brand-new Uno Q from box to a working demo. All steps except step 1 run over Wi-Fi via SSH.
You need exactly one of these approaches, once:
Option A — App Lab (fastest, GUI). Install Arduino App Lab, plug
the Uno Q into your laptop via USB-C, follow the first-boot wizard.
It creates the arduino user, sets a password, connects Wi-Fi,
enables SSH. Then close App Lab and never open it again.
Option B — ADB (all-CLI, no App Lab). Plug the Uno Q's USB-C into your laptop. Then:
sudo apt install -y adb
adb devices # confirm the board is authorized
adb shell 'nmcli connection add type wifi \
con-name YOUR_SSID ifname wlan0 ssid YOUR_SSID \
wifi-sec.key-mgmt wpa-psk wifi-sec.psk YOUR_PSK && \
nmcli connection up YOUR_SSID'
adb shell 'ip -4 addr show wlan0 | grep inet' # note the IPThen set a password for the arduino user (adb shell can't skip it):
adb shell 'passwd arduino'Once you can SSH in (either path above got you here):
export UNOQ_IP=<the-board-ip>
# SSH key so you never type the password again
ssh-copy-id arduino@$UNOQ_IP
# NOPASSWD sudo so scripts don't hang on prompts
ssh arduino@$UNOQ_IP "echo 'arduino ALL=(ALL) NOPASSWD:ALL' | \
sudo tee /etc/sudoers.d/010-arduino-nopasswd && \
sudo chmod 440 /etc/sudoers.d/010-arduino-nopasswd && \
sudo visudo -c"Should end with parsed OK.
This handles two Uno Q reliability items: the USB-C role switch
staying in host mode across reboots, and the STM32U585 sketch
re-establishing its serial channel to arduino-router after each
MPU boot.
scp -r firmware/boot-config arduino@$UNOQ_IP:~/
ssh arduino@$UNOQ_IP 'bash ~/boot-config/install.sh'This is what lets future Wi-Fi changes happen by editing a text file on the SD card, rather than needing a laptop. See Wi-Fi provisioning via the SD card.
scp -r firmware/wifi-provisioning arduino@$UNOQ_IP:~/
ssh arduino@$UNOQ_IP 'bash ~/wifi-provisioning/install.sh'Docker on the Uno Q's default eMMC layout will fill the 10 GB root
partition almost immediately. This script moves /var/lib/docker
to /home/arduino/docker where the eMMC has 17 GB free.
scp scripts/setup-unoq-docker.sh arduino@$UNOQ_IP:~/
ssh arduino@$UNOQ_IP 'bash ~/setup-unoq-docker.sh'That's the last time you'll need to run anything on the board by
hand. Everything else is scripts/deploy-to-unoq.sh from the laptop.
Nothing to install beyond what's in What you'll need.
Clone the repo, create a .env, and you're ready.
cd ExampleProjects/UnoQ
cp .env.example .env
$EDITOR .env # set TLS_CERT_HOSTNAME to <your-board-hostname>.localThe TLS_CERT_HOSTNAME should match the hostname command output
on the Uno Q with .local appended. To find it:
ssh arduino@$UNOQ_IP hostname
# → trailcurrentunoq (default; yours will differ)
# → so TLS_CERT_HOSTNAME=trailcurrentunoq.localYou can also add extra hostnames (aliases) via EXTRA_DNS in the
same file — see the .env.example for the format.
Once firmware/wifi-provisioning/install.sh is on the board, the
service watches /mnt/unoq-sd/wifi.conf on the SD card. When the
file appears, it reads the credentials, calls nmcli to connect,
and shreds the plaintext file so the PSK doesn't sit on the SD.
Save this file at the root of the SD card as wifi.conf:
# Required
SSID=YourNetworkName
PSK=YourWiFiPassword
# Optional
COUNTRY=US # ISO 3166 two-letter country code, sets iw reg for 5 GHz
HIDDEN= # 'true' for hidden SSIDs; leave blank for normal
Notes:
- No quotes around values (
SSID=Home Network, notSSID="Home Network") - No spaces around the
=(SSID=X, notSSID = X) - Lines starting with
#are ignored - The service tolerates Windows CRLF line endings
Boot with the SD card inserted → service reads the file → nmcli
adds the connection profile → shred -u deletes the file. If the
credentials are wrong, the file stays in place so you can fix it.
To change Wi-Fi networks later: drop a fresh wifi.conf on the SD
card and reboot. The service is idempotent and won't create
duplicate profiles.
The tileserver serves vector map tiles in MBTiles format (SQLite database of PBF-encoded tiles at each zoom level). We build them from OpenStreetMap using Planetiler — a fast, single-binary tool that produces MapLibre-compatible tiles.
Go to download.geofabrik.de and find your region. Geofabrik offers extracts at country, state, and sub-region granularity. Examples:
| Region | PBF URL | Approx size |
|---|---|---|
| Whole planet | planet-latest.osm.pbf (from planet.osm.org) |
~90 GB |
| North America | north-america-latest.osm.pbf |
~15 GB |
| Contiguous US | north-america/us-latest.osm.pbf |
~11 GB |
| A single US state | north-america/us/illinois-latest.osm.pbf |
~500 MB |
| Chicago metro (via bbox) | Illinois PBF clipped to bbox | ~200 MB |
Smaller region = faster build, smaller MBTiles, faster tile serves. A single US state fits comfortably on a 16 GB SD card; the whole North America extract is 25 GB and needs a 64 GB card with room to spare.
Two included build scripts. Both use Planetiler under the hood.
# Chicago metropolitan area only (~200 MB, ~30 min build)
./scripts/build-chicago-mbtiles.sh
# Continental US at z0–12 + Chicago overlay at z13–15 (~15–25 GB,
# several hours build)
./scripts/build-us-tiles.shEach script is a working example you can copy and adjust for other regions. The relevant knobs at the top of each script:
BBOX_MIN_LON/BBOX_MIN_LAT/BBOX_MAX_LON/BBOX_MAX_LAT— the bounding box to clip to. Get one from boundingbox.klokantech.com.MIN_ZOOM/MAX_ZOOM— how deep the tile pyramid goes. z14 gets you individual buildings for 3D extrusion; z12 is roads and city labels only.ILLINOIS_PBF_URL/NA_PBF_URL— where to download the source data from. Change to your region's Geofabrik URL.
Simplest recipe: copy build-chicago-mbtiles.sh, rename it,
change the bbox + PBF URL. For example, to build New York City:
cp scripts/build-chicago-mbtiles.sh scripts/build-nyc-mbtiles.sh
$EDITOR scripts/build-nyc-mbtiles.sh
# BBOX_MIN_LON=-74.35, BBOX_MIN_LAT=40.45, BBOX_MAX_LON=-73.65, BBOX_MAX_LAT=40.95
# NY_PBF_URL=https://download.geofabrik.de/north-america/us/new-york-latest.osm.pbfThe script downloads the PBF from Geofabrik, clips it to your bbox
with osmium (containerized, no host install needed), runs
Planetiler to produce the MBTiles, and lands the file at
data/tileserver/map.mbtiles on your dev laptop. That local file
is the source of truth — you copy it onto the SD card in the next
step.
Skip the build. Point SRC= at your existing file:
SRC=/path/to/your/existing.mbtiles \
./scripts/copy-mbtiles-to-sd.shThe SD card holds two things: the MBTiles file, and the wifi.conf.
It gets formatted once, then survives across reboots and card
swaps because we mount it by label in /etc/fstab.
Destructive. Wipes whatever's on the card.
# Find your card first — check `lsblk`. The card is usually /dev/mmcblk0
# (if it's in your laptop's built-in slot) or /dev/sdX (via USB reader).
lsblk
# Format as GPT + ext4 + label UNOQ_TILES + mount at /mnt/unoq-sd
SDCARD_DEV=/dev/mmcblk0 ./scripts/format-sd-card.shThe script partitions, formats, labels, and mounts in one shot.
The UNOQ_TILES label is what the Uno Q's fstab entry matches
against, so don't change it.
./scripts/copy-mbtiles-to-sd.shReads data/tileserver/map.mbtiles by default (produced by the
build scripts above), writes to /mnt/unoq-sd/tiles/map.mbtiles,
and verifies the size + does a quick sqlite schema check.
For a 25 GB North America MBTiles, this takes 5–10 minutes at SD-card write speed.
# Adjust to your SD mount point — udisks auto-mount is /media/<user>/UNOQ_TILES
$EDITOR /media/$USER/UNOQ_TILES/wifi.confContent shown in File format — wifi.conf.
sync
sudo umount /mnt/unoq-sd # or `/media/$USER/UNOQ_TILES`Plug the SD card into the USB reader, and the USB reader into the
Uno Q's powered hub. On next boot, the fstab entry auto-mounts the
card at /mnt/unoq-sd.
The full wiring diagram lives at
docs/wiring-diagram.png. Key points:
- Everything wires to the STM32U585 MCU pins on the header, not to the QRB2210 MPU. This deliberately routes around the qupfw bug that disables hardware SPI on the header from the MPU side.
- DFR1103 GNSS: 4-pin Gravity PH2.0 cable to the header.
- Pin 4 (
+) → Uno Q 3V3 (module accepts 3.3–5 V) - Pin 3 (
−) → Uno Q GND - Pin 2 (
C/R) → Uno Q SCL - Pin 1 (
D/T) → Uno Q SDA
- Pin 4 (
- Antenna: screwed onto the DFR1103's u.FL/SMA connector. Point it upward with a real view of the sky (near a window at minimum; outdoors is far better for the initial cold lock).
Check the wiring twice. Even a slightly-loose Gravity pin causes
gnss.begin() failed errors that look like the module is dead.
Safari refuses to install a PWA "add to home screen" as a
standalone-mode app unless the origin is https://. We use a
self-signed CA + server cert setup that mimics enterprise trust
patterns.
# Regenerate whenever you add a hostname, add a device, or the
# server cert expires (825 days out).
EXTRA_IPS=$UNOQ_IP ./scripts/generate-certs.shThe EXTRA_IPS env var is comma-separated additional IPs to
include in the cert Subject Alternative Names, so https://<ip>/
also works without a browser warning. Additional DNS names come
from EXTRA_DNS= in your .env.
Result files land in data/keys/:
ca.crt/ca.key/ca.pem— long-lived (10 years) CA. Installca.crton each device once, and it trusts every server cert we ever sign under this CA.server.crt/server.key— short-lived (825 days, Apple's max) server cert. Bind-mounted into nginx. Regenerating this file doesn't require re-installing the CA.
Do this once per device. After it's done, https:// URLs from the
Uno Q load without warnings and the PWA "add to home screen" flow
installs standalone-mode.
The file to install is data/keys/ca.crt —
generated by the previous step.
- iOS/iPadOS: AirDrop or email
ca.crtto the device. Tap it → Settings → Profile Downloaded → Install. Then Settings → General → About → Certificate Trust Settings → toggle the CA on. - macOS: double-click
ca.crt→ Keychain Access opens → set When using this certificate: Always Trust. - Linux:
sudo cp ca.crt /usr/local/share/ca-certificates/unoq-ca.crt && sudo update-ca-certificates - Windows: double-click → Install Certificate → Local Machine → Trusted Root Certification Authorities.
Once everything above is done, deploys are one command from the laptop:
UNOQ_HOST=arduino@$UNOQ_IP ./scripts/deploy-to-unoq.shThe script:
- Cross-builds
frontend,backend,tileserverforlinux/arm64 docker saves all three into one gzipped tarballscps the tarball +docker-compose.yml+ the cert files- SSHs in,
docker loads the images,docker compose up -ds them - Prints the URL(s) to visit
Rerun this any time you change container source (nginx.conf, sketch, etc). Cached buildx layers make subsequent deploys fast (~30 s).
The sketch is at
firmware/stm32u585/UnoQGnssBridge/UnoQGnssBridge.ino.
It runs on the STM32U585 under Zephyr (Arduino's arduino:zephyr
core), reads the DFR1103 over I²C, and emits framed JSON on Serial
(which arduino-router forwards to 127.0.0.1:7500).
Flashing happens from the Uno Q itself — Arduino ships the full toolchain (arduino-cli, arduino-flash, arduino-reset) on the board's Debian image. No laptop-side toolchain needed.
# Ship the sketch to the board
scp -r firmware/stm32u585 arduino@$UNOQ_IP:~/firmware/
# Install libraries (one-time)
ssh arduino@$UNOQ_IP 'arduino-cli lib install "Arduino_RouterBridge" \
"DFRobot_GNSS" \
"ArduinoJson"'
# Compile + upload
ssh arduino@$UNOQ_IP 'cd ~/firmware/stm32u585 && \
arduino-cli compile -b arduino:zephyr:unoq UnoQGnssBridge && \
arduino-cli upload -b arduino:zephyr:unoq UnoQGnssBridge'Reflash any time you change the sketch. The boot-config service
resets the MCU on every board boot, so a flashed sketch survives
reboots without needing manual arduino-reset.
The sketch emits three message types (all newline-delimited JSON):
{"type":"boot","build":"Jul 10 2026 23:05:08","module":"DFR1103"}
{"type":"fix","latE7":422666930,"lonE7":-889062870,"alt":233.5,"speedMps":0,"headingDeg":121.0,"satellites":11,"latDir":87,"lonDir":78,"ts":1757375620}
{"type":"status","gnssLocked":true,"satellites":11,"gnssMode":7,"uptimeMs":42130}latE7 and lonE7 are signed int32 scaled by 10⁷ (MAVLink
convention). latDir / lonDir are the raw direction bytes from
the L76K's I²C direction registers, exposed for debugging — the
sketch's sign logic accounts for the L76K's swapped register mapping
observed on the DFR1103 (see the sketch's comment for the details).
Once deployed and flashed:
# Health check
curl -sk https://$UNOQ_IP/api/health
# → {"status":"ok","source":"mcu","router":"127.0.0.1:7500"}
# The whole PWA
open https://$UNOQ_IP/ # macOS
xdg-open https://$UNOQ_IP/ # LinuxBrowser should show a full-screen dark map. Zoom to z14+ (scroll
wheel a few clicks over a city) and 3D-extruded buildings should
appear. The HUD in the top-left flips to GPS live within a couple
of seconds. A blue dot appears at your actual location once the GNSS
locks (up to a minute cold-start with a clear sky view).
The map ships with a search box across the top of the screen that
finds named places over the whole region covered by your MBTiles.
It's an optional feature — the backend gracefully degrades to a
503 on /api/search when the POI database isn't present, and the
rest of the map (tiles + GPS dot) keeps working.
Every OSM node/way with a name= tag plus one of the common
categorization tags (amenity, shop, tourism, leisure,
historic, place, aeroway, railway, sport, healthcare,
natural). In practice that covers:
- Businesses: named cafés, restaurants, gas stations, stores
- Category searches: typing
coffeematches both places named "Coffee X" and things taggedamenity=cafe - Places: cities, neighborhoods, parks, campgrounds, peaks
- Transit: rail stations, airports, ferry terminals
- Free-form street addresses. "123 Main St, Springfield IL" won't parse — that's the problem Nominatim was designed for. If your demo needs address geocoding, run mediagis/nominatim in the compose and drop this feature.
- Roads by number. "I-90" or "Route 66" won't match unless the
road segment happens to have a
name=tag with that string.
The build script runs on your dev laptop (needs 4-8 GB RAM, ~50 GB scratch, and 1-3 hours for North America; 10-30 min for a US state).
# Uses the same PBF you built the MBTiles from.
SRC_PBF=/path/to/your/north-america-latest.osm.pbf \
./scripts/build-poi-index.shOutput: data/tileserver/pois.sqlite — a self-contained SQLite
database with three tables:
pois— row data (id, name, category, subcategory, lat, lon)pois_fts— FTS5 full-text search on (name, category, subcategory)pois_rtree— R-tree spatial index for bbox filtering
Approximate output sizes:
| PBF region | POIs indexed | pois.sqlite |
|---|---|---|
| Chicago metro | ~250,000 | ~30 MB |
| Illinois (single state) | ~1.5 million | ~200 MB |
| North America (matches the demo MBTiles) | ~30-40 million | ~4-8 GB |
./scripts/copy-pois-to-sd.shRsyncs the file to /mnt/unoq-sd/tiles/pois.sqlite next to
map.mbtiles, then does a quick FTS query to verify the schema.
The backend picks up the file automatically on next docker compose up — no config changes needed. The compose bind-mount is:
backend:
volumes:
- "${POI_DB_PATH:-./data/tileserver/pois.sqlite}:/data/pois.sqlite:ro"If pois.sqlite isn't present on the host, Docker mounts a
zero-byte placeholder and the backend logs no pois.sqlite at /data/pois.sqlite — search endpoint will return 503 on startup.
The map still works; just the search box returns empty.
Handy for testing without the frontend:
curl -sk "https://$UNOQ_IP/api/search?q=coffee&bbox=-88.3,41.6,-87.4,42.1&near=41.88,-87.63&limit=5"Parameters:
q— query text (multi-word, ANDed, prefix-matched)bbox— restricts results to inside the map viewport,west,south,east,northin decimal degreesnear— biases results by squared euclidean distance,lat,lonlimit— max results (default 20, cap 100)
Returns:
[
{ "id": 1234567, "name": "Intelligentsia Coffee", "category": "amenity",
"subcategory": "cafe", "lat": 41.888, "lon": -87.634 },
...
]Measured on the running board with the North America POI index loaded and steady-state queries:
- Backend memory: 16 MiB → ~80 MiB (SQLite page cache + memory- mapped index working set)
- Per-query latency: 5-40 ms typical, up to ~150 ms on cold cache
- CPU: negligible under bursty typing (debounced 220 ms)
- SD card space: adds 4-8 GB to the 25 GB MBTiles
These are measured on the actual running demo (17 minutes uptime, one browser client connected, GNSS locked with 18 satellites). Numbers give you a sense of how much headroom you have on the Uno Q if you want to add features.
| Load average (1 / 5 / 15 min) | 0.16 / 0.17 / 0.20 |
|---|---|
| Load as % of a 4-core box | ~4 % idle steady state |
| Total CPU %, all three containers | ~1.2 % |
Per-container instantaneous (from docker stats):
| Container | CPU % |
|---|---|
| unoq-map-backend | 1.04 % |
| unoq-map-tileserver | 0.08 % |
| unoq-map-frontend | 0.03 % |
Backend's 1 % is the WebSocket keepalive plus the 1 Hz MCU message forwarding. Tileserver goes up (~10–30 %) briefly during a pan where MapLibre requests 10–30 fresh tiles at once, then drops back to near-zero once the client's tile cache is warm.
| Total | 3.6 GiB |
|---|---|
| Used (all processes, kernel included) | 808 MiB (~22 %) |
| Cached | 894 MiB |
| Available for new work | 2.8 GiB (~78 %) |
| Swap total / used | 1.8 GiB / 0 B |
Per-container memory usage:
| Container | Memory | % of system |
|---|---|---|
| unoq-map-tileserver | 282 MiB | 7.7 % |
| unoq-map-backend | 16 MiB | 0.4 % |
| unoq-map-frontend | 3.6 MiB | 0.1 % |
| Total container footprint | ~302 MiB | ~8 % |
Tileserver dominates memory because it loads the OpenMapTiles style JSON + glyph indexes + a fair chunk of the MBTiles metadata into RAM. Backend and frontend are essentially free.
Swap has never been touched since boot — we have ~2.8 GiB of real RAM headroom before the kernel would even consider it.
The Uno Q's eMMC has an unusual multi-partition layout (69 partitions, most of them tiny system regions). The user-visible partitions:
| Partition | Size | Used | Available | Notes |
|---|---|---|---|---|
/ (mmcblk0p68) |
9.8 GiB | 8.9 GiB | 390 MiB (96 %) | System image. Effectively full by design — do not install packages here. |
/home/arduino (mmcblk0p69) |
18 GiB | 3.4 GiB | 14 GiB (20 %) | Where Docker data-root lives after setup-unoq-docker.sh |
/boot/efi (mmcblk0p67) |
488 MiB | 135 MiB | 353 MiB (28 %) | UEFI ESP |
The / partition being at 96 % looks alarming but it's the default
Arduino image state — you can't safely install more system packages
regardless of what we do. Everything the demo produces (Docker
images, container writable layers, logs) lives in /home/arduino,
which has 14 GiB of headroom.
docker system df output:
| Total | Active | Size | Reclaimable | |
|---|---|---|---|---|
| Images | 15 | 3 | 3.6 GB | 3.5 GB (96 %) |
| Containers | 3 | 3 | 1.1 kB | 0 B |
| Volumes | 11 | 1 | 1.2 GB | 1.0 GB (90 %) |
The active 3 images and 3 containers are what actually run:
| Image | Size |
|---|---|
| unoq-map-tileserver:latest | 1.02 GB (bulk = fonts + node runtime) |
| unoq-map-backend:latest | 145 MB |
| unoq-map-frontend:latest | 62 MB |
Steady-state disk usage for the actual demo is thus ~1.23 GB in images + a few hundred KB in container writable layers. The other 12 images and 10 volumes are leftovers from our iteration cycles during development. To reclaim ~4.5 GB of eMMC:
ssh arduino@$UNOQ_IP 'docker system prune -a --volumes -f'Safe as long as no other Docker workload uses those images.
Not eMMC, but included for completeness since the map data lives there:
| Size | Used | Available | |
|---|---|---|---|
/mnt/unoq-sd (sdb1) |
59 GiB | 25 GiB | 34 GiB (43 % used) |
Contents:
/mnt/unoq-sd/tiles/map.mbtiles— 25 GiB, the Planetiler-built North America tile file/mnt/unoq-sd/wifi.conf— present or shredded depending on last boot's provisioning state
Direct on-board power measurement is not available. The Uno Q's
default Debian image doesn't expose voltage or current sensors via
/sys/class/power_supply/ or the Qualcomm PMIC drivers — there's
no battery-gauge or VBUS-current sensor readable from user space.
What you can read as indirect proxies:
- 10 thermal zones in
/sys/class/thermal/thermal_zone*covering CPU cluster 0, CPU cluster 1, GPU, modem 0, modem 1, WiFi, camera, video, and two aggregate zones. Higher temperature ⇒ higher power because the SoC's power → heat conversion is close to 1:1 for short-term dynamics. - Per-core CPU frequencies in
/sys/devices/system/cpu/cpu[0-3]/cpufreq/scaling_cur_freq. Theschedutilgovernor scales between ~600 MHz and 2016 MHz based on load; cores at max frequency are drawing near the SoC's dynamic power budget.
Sample thermal deltas we measured on the running demo:
| State | cpuss0 | cpuss1 | GPU | WiFi |
|---|---|---|---|---|
| Idle (demo running, one client) | 46 °C | 46 °C | 39 °C | 38 °C |
4-core yes >/dev/null stress test |
53 °C | 52 °C | 43 °C | 42 °C |
| 15 s after stress ends | 44 °C | 44 °C | 40 °C | 39 °C |
A +7 °C ΔT under 4-core max load is modest, which suggests the demo under steady state is nowhere near thermal-throttling territory. The SoC's throttle points are typically 90–100 °C, so we have 40+ °C of headroom.
Quick-look script for temperatures:
ssh arduino@$UNOQ_IP 'for z in /sys/class/thermal/thermal_zone*; do
printf "%-25s %d°C\n" "$(cat $z/type):" "$(( $(cat $z/temp) / 1000 ))"
done'For actual watts, you need an external inline meter. The three practical options, in decreasing ease and increasing accuracy:
| Option | Cost | Accuracy | How |
|---|---|---|---|
| USB-C power meter | $15–40 | ±0.05 V / ±10 mA | Plug the meter between the USB-C power source and the Uno Q. Some (ChargerLAB Power-Z KM003C etc.) log to a laptop over USB or Bluetooth for time-series data. Also shows PD negotiation state. |
| INA219 / INA226 breakout | $5–15 | ±1 % | Wire the shunt inline with the Uno Q's power lead, read voltage + current over I²C from any Arduino / Pi / laptop. This project's spare GPIO on the header can host it, though it'd need its own sketch since our existing sketch is single-purpose. |
| Bench PSU with current display | $50+ | ±10 mA | Power the Uno Q from the bench PSU's 5 V rail (bypass the USB-C PD negotiation). Read current on the front panel. Best for lab-style measurements. |
For a demo/documentation use case, a USB-C inline meter is almost always the right answer — accurate enough, non-invasive, and gives you the actual power-supply-to-board number (not just the SoC's share of it, but everything on the board including the STM32U585 and any USB peripherals drawing from the same rail).
systemd-analyze reports:
| Phase | Time |
|---|---|
| Firmware (UEFI + early init) | 4.6 s |
| Bootloader | 1.5 s |
| Kernel | 5.7 s |
| Userspace | 19.8 s |
| Total to graphical.target | 31.7 s |
The Docker stack + MCU sketch reach steady state within a few more seconds — call it under a minute from power-on to a browser-loadable map. The MCU reset service adds ~2 s of intentional delay after arduino-router comes up.
Each of these bit us during development. All are addressed by the scripts / services in this repo; documenting them so future you (or future contributors) don't re-litigate the debugging.
-
10 GB root partition fills instantly with Docker images. Default eMMC layout gives
/only ~3 GB free; installing Docker fills it. Fix:scripts/setup-unoq-docker.shrelocates/var/lib/dockerto/home/arduino/docker(~17 GB free). -
Header SPI is disabled by qupfw firmware. The MPU-side of the header has hardware SPI locked to I²C read-only by the boot ROM firmware. We route the SD card via USB (not SPI) and route the GNSS via the MCU's I²C (which works fine).
-
USB-C role switch defaults to
deviceunder PD supplies. A PD-negotiating power source can put the USB-C in device mode, which disables VBUS to any downstream USB peripherals. Fix: udev rule atfirmware/boot-config/50-unoq-usb-host.ruleswriteshostinto the role sysfs the moment the kernel exposes it. -
MCU sketch's serial doesn't reach
arduino-routerafter MPU reboot. The router opens/dev/ttyHS1during its own startup; this races the sketch's Serial init on the internal bridge. Fix:firmware/boot-config/unoq-mcu-reset.serviceinvokesarduino-resetafterarduino-router.serviceis active. -
fstab USB-mount races enumeration. A
LABEL=UNOQ_TILESmount line without USB-aware timings fails at boot before the SD reader enumerates. Fix:nofail,x-systemd.device-timeout=30sin the fstab line (installed byscripts/setup-unoq-docker.sh— wait, see below). -
L76K I²C direction registers are swapped in the DFRobot library. The library reads
I2C_LAT_DISintolat.latDirectionbut the module's firmware stores the longitude direction letter there (and vice versa). Fix: the sketch crosses the fields when applying the sign. Debug fieldslatDir/lonDirare exposed in everyfixmessage so future users can verify the same swap. -
App Lab's Python console closes when the sketch stops. Only relevant if you use App Lab; this project doesn't need it past step 1 of first-boot.
-
arduino-cli monitor doesn't work over the network transport. Use
nc 127.0.0.1 7500from the Uno Q instead — that's the MCU's serial output stream on the router side.
UnoQ/
├── README.md This file
├── docker-compose.yml Three-service stack
├── .env.example Copy to .env and edit
├── .gitignore Excludes secrets, generated files
│
├── containers/ Docker sources
│ ├── frontend/ nginx + PWA, TLS-terminated
│ ├── backend/ Express + WS + router-bridge
│ └── tileserver/ tileserver-gl + 3d/3d-dark styles + fonts
│
├── data/ Runtime data (mostly gitignored)
│ ├── keys/ TLS CA + server cert (gitignored)
│ └── tileserver/ Where map.mbtiles lands locally (gitignored)
│
├── docs/
│ ├── wiring-diagram.svg/.png microSD + GNSS wiring reference
│ └── PHASE-3-SD-TILE-BRIDGE.md Future work: MCU-hosted SD via SPI
│
├── firmware/
│ ├── stm32u585/ MCU sketch (Zephyr / arduino:zephyr:unoq)
│ ├── wifi-provisioning/ systemd + shell for wifi.conf-on-SD workflow
│ └── boot-config/ udev USB-role rule + MCU-reset service
│
└── scripts/ All laptop-side tools
├── format-sd-card.sh Wipe + format an SD card as UNOQ_TILES
├── copy-mbtiles-to-sd.sh rsync existing MBTiles onto the SD card
├── build-chicago-mbtiles.sh Planetiler build: Chicago-only (~200 MB)
├── build-us-tiles.sh Planetiler build: CONUS z0-12 + Chicago z13-15
├── generate-certs.sh Self-signed CA + server cert
├── setup-unoq-docker.sh Install Docker + relocate data-root (run on board)
└── deploy-to-unoq.sh Build + ship + docker-compose up
The "SD card via SPI on the MCU" story is still open. The wiring diagram accounts for it but the sketch doesn't currently talk to an SPI SD card — the tiles live on a USB-attached SD reader instead.
The tradeoffs and three viable architectures for moving the SD to
the MCU's SPI bus (with block-serving to the MPU) are written up in
docs/PHASE-3-SD-TILE-BRIDGE.md.
Also open:
- Second GNSS antenna failover (
gnssMode:7already selects GPS+BeiDou+GLONASS constellations, but antenna redundancy would cover the case of a dropped cable). - A11y/PWA polish (the map is currently zoom-and-pan-only; no layer toggles, no offline caching status UI).
- CI: a workflow that lints the sketch, docker builds, and
runs
docker compose configon push.
Pull requests welcome. Each of the numbered gotchas in Known issues is also fair game — the fixes are in the repo but if you find a cleaner way, that's a valuable contribution.