Skip to content

trailcurrentoss/uno-q-map-server

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

UnoQ Map — Arduino Uno Q PWA reference example

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-router bridge 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).


Contents


What you'll need

Hardware

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.

Dev laptop

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.

Architecture

                      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: host on the backend so it can reach the arduino-router at 127.0.0.1:7500 (the router only listens on loopback).
  • extra_hosts: host.docker.internal:host-gateway on the frontend so nginx can proxy /api and /ws to 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.

First-time bring-up — Uno Q side

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.

Step 1 — Get Wi-Fi + SSH up

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 IP

Then set a password for the arduino user (adb shell can't skip it):

adb shell 'passwd arduino'

Step 2 — Install your SSH key + passwordless sudo

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.

Step 3 — Install the boot-config (USB role + MCU reset)

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'

Step 4 — Install the Wi-Fi provisioning service

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'

Step 5 — Install Docker + relocate its data-root

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.


First-time bring-up — dev laptop side

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>.local

The 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.local

You can also add extra hostnames (aliases) via EXTRA_DNS in the same file — see the .env.example for the format.


Wi-Fi provisioning via the SD card

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.

File format — wifi.conf

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, not SSID="Home Network")
  • No spaces around the = (SSID=X, not SSID = X)
  • Lines starting with # are ignored
  • The service tolerates Windows CRLF line endings

When it fires

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.


Building map tiles from OpenStreetMap

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.

1 — Pick a region

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.

2 — Build the MBTiles

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.sh

Each 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.

3 — Adapt for a different region

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.pbf

The 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.

If you already have an MBTiles file

Skip the build. Point SRC= at your existing file:

SRC=/path/to/your/existing.mbtiles \
    ./scripts/copy-mbtiles-to-sd.sh

Formatting the SD card and loading the tiles

The 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.

1 — Format

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.sh

The 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.

2 — Load the MBTiles

./scripts/copy-mbtiles-to-sd.sh

Reads 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.

3 — Add your Wi-Fi credentials (optional but recommended)

# Adjust to your SD mount point — udisks auto-mount is /media/<user>/UNOQ_TILES
$EDITOR /media/$USER/UNOQ_TILES/wifi.conf

Content shown in File format — wifi.conf.

4 — Unmount and move to the Uno Q

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.


Wiring the peripherals

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
  • 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.


Generating the TLS certs

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.sh

The 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. Install ca.crt on 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.

Trust the CA on your devices

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.crt to 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 CertificateLocal Machine → Trusted Root Certification Authorities.

Deploying the docker stack

Once everything above is done, deploys are one command from the laptop:

UNOQ_HOST=arduino@$UNOQ_IP ./scripts/deploy-to-unoq.sh

The script:

  1. Cross-builds frontend, backend, tileserver for linux/arm64
  2. docker saves all three into one gzipped tarball
  3. scps the tarball + docker-compose.yml + the cert files
  4. SSHs in, docker loads the images, docker compose up -ds them
  5. 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).


Flashing the STM32U585 sketch

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).


Verifying the demo

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/         # Linux

Browser 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).


POI search (optional)

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.

What it finds

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 coffee matches both places named "Coffee X" and things tagged amenity=cafe
  • Places: cities, neighborhoods, parks, campgrounds, peaks
  • Transit: rail stations, airports, ferry terminals

What it doesn't find

  • 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.

Build the index

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.sh

Output: 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

Copy to the SD card

./scripts/copy-pois-to-sd.sh

Rsyncs the file to /mnt/unoq-sd/tiles/pois.sqlite next to map.mbtiles, then does a quick FTS query to verify the schema.

Redeploy

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.

Query format

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,north in decimal degrees
  • near — biases results by squared euclidean distance, lat,lon
  • limit — max results (default 20, cap 100)

Returns:

[
  { "id": 1234567, "name": "Intelligentsia Coffee", "category": "amenity",
    "subcategory": "cafe", "lat": 41.888, "lon": -87.634 },
  ...
]

Resource cost when enabled

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

Resource footprint on the live board

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.

CPU — 4 × Cortex-A53 @ 2.0 GHz

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.

Memory — 3.6 GB total on the 4 GB variant

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.

On-board eMMC — 32 GB

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 footprint under /home/arduino/docker

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.

External SD card — 64 GB (USB-mounted)

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

Power draw

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. The schedutil governor 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).

Boot time

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.


Known Uno Q issues this project works around

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.

  1. 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.sh relocates /var/lib/docker to /home/arduino/docker (~17 GB free).

  2. 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).

  3. USB-C role switch defaults to device under 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 at firmware/boot-config/50-unoq-usb-host.rules writes host into the role sysfs the moment the kernel exposes it.

  4. MCU sketch's serial doesn't reach arduino-router after MPU reboot. The router opens /dev/ttyHS1 during its own startup; this races the sketch's Serial init on the internal bridge. Fix: firmware/boot-config/unoq-mcu-reset.service invokes arduino-reset after arduino-router.service is active.

  5. fstab USB-mount races enumeration. A LABEL=UNOQ_TILES mount line without USB-aware timings fails at boot before the SD reader enumerates. Fix: nofail,x-systemd.device-timeout=30s in the fstab line (installed by scripts/setup-unoq-docker.sh — wait, see below).

  6. L76K I²C direction registers are swapped in the DFRobot library. The library reads I2C_LAT_DIS into lat.latDirection but the module's firmware stores the longitude direction letter there (and vice versa). Fix: the sketch crosses the fields when applying the sign. Debug fields latDir/lonDir are exposed in every fix message so future users can verify the same swap.

  7. 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.

  8. arduino-cli monitor doesn't work over the network transport. Use nc 127.0.0.1 7500 from the Uno Q instead — that's the MCU's serial output stream on the router side.


Project layout

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

What's next (Phase 3)

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:7 already 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 config on 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.

About

YouTube sample for deploying code to the Arduino Uno Q

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages