arduino · uno-q
Setting Up My 4GB Arduino UNO Q: Two Scripts and Five Traps
The Arduino team sent me a 4GB UNO Q to replace the 2GB one I’d been learning on. The datasheet recommends 4GB for single-board mode, which is how I want to run my robot.
Setting it up took a day. I deployed an app by hand three times, got tired of it, wrote a script, and then found the board’s software was nine months out of date. Reflashing fixed that and wiped everything: WiFi, the account password, my SSH key, the apps. So I wrote a second script.
This is what a fresh board gets wrong, and what the two scripts do about it. Both are on GitHub: prahari-tech/uno-q-scripts.
The board
I’m building HomeGuard Parivaar, an autonomous home health robot for Indian families managing eldercare from a distance.
The UNO Q is its brain, and it has two of them: a Qualcomm QRB2210 running Debian Linux, and an STM32U585 running Zephyr. The Linux side does ML and networking. The microcontroller side owns sensors and motors.
That split is good for the robot and awkward for the workflow. Every change has to reach a board that is only reachable over USB or WiFi, through a toolchain that lives on the board itself.
Deploying by hand
Here is what deploying one app looked like before:
adb push src/mcu/q-bridge /home/arduino/ArduinoApps/q-bridge
adb shell
# then, on the board:
TMPDIR=/tmp arduino-app-cli app start ~/ArduinoApps/q-bridge
Three things in that snippet had already caught me out.
- The apps directory is
~/ArduinoApps, not~/apps. I lost twenty minutes toinvalid app pathbefore runningarduino-app-cli config get. TMPDIR=/tmpis load-bearing. Without it the build dies onstat /data/local/tmp: no such file or directory, an Android path, on a Debian board.adb pushof a directory behaves differently depending on whether the destination already exists.
That last one bit me again while writing the script, so it gets its own section.
Script one: q-deploy
The first script (bin/q-deploy) wraps that sequence, so the traps are encoded once instead of remembered every time.
It speaks two transports and picks automatically:
ssh_reachable() { ssh -o BatchMode=yes -o ConnectTimeout=5 "$1" true >/dev/null 2>&1; }
SSH if the board answers, ADB over USB otherwise. BatchMode=yes matters more than it looks. If my key stops working the probe fails immediately and falls through to ADB, instead of sitting at a password prompt. The script never handles a password.
The whole transport abstraction is two functions:
remote_sh() {
if [ "$TRANSPORT" = "ssh" ]; then
ssh "${SSH_OPTS[@]}" "$SSH_HOST" "$1"
else
adb shell "TMPDIR=/tmp $1"
fi
}
TMPDIR=/tmp now appears exactly once in the entire script, right there.
The bug I wrote into my own script
My push step prunes the app directory before copying, so a renamed file can’t leave a stale .ino behind for the compiler to find. It creates the directory first with mkdir -p.
Then this happened:
/home/arduino/ArduinoApps/q-bridge
/home/arduino/ArduinoApps/q-bridge/q-bridge
/home/arduino/ArduinoApps/q-bridge/q-bridge/app.yaml
adb push had nested the app inside itself, and arduino-app-cli responded with descriptor app.yaml file missing from app.
The rule turns out to be:
adb push src/mcu/q-bridge <dest> | result |
|---|---|
<dest> absent | creates <dest> with the contents, which is what you want |
<dest> exists | nests it: <dest>/q-bridge/ |
<dest>/ (trailing slash) | flattens the contents into <dest> |
My earlier note to self, “name the destination explicitly”, was only right by accident. The destination hadn’t existed that first time.
The deterministic fix is to push the children, never the directory. Given several sources, adb always treats the last argument as a destination directory:
adb push src/mcu/q-bridge/app.yaml src/mcu/q-bridge/python src/mcu/q-bridge/sketch \
/home/arduino/ArduinoApps/q-bridge/
The board was nine months old
With the script working, I went to add a pre-flight compile so I could catch errors on my laptop before pushing. It failed on all three of my apps.
zephyr/0.90.0/libraries/stubs/Arduino_RouterBridge.h:34:2: error: #error
"Please install the Arduino_RouterBridge library from the Library Manager..."
My laptop had Zephyr core 0.90.0. The board had 0.51.0, and the two disagree about something fundamental: 0.51.0 bundles a real Arduino_RouterBridge inside the core, while 0.90.0 replaced it with a stub that refuses to compile and tells you to install it from the Library Manager. The same sketch, same profile, builds on one and fails on the other.
The fix that makes this moot is to pin the platform version in every profile. An unpinned platform: arduino:zephyr builds against whatever core the machine happens to have, and a pre-flight compile that builds a different world than the board is not a pre-flight check at all.
Digging further explained an earlier error that had confused me:
ls -la ~/.arduino15/library_index.json
# -rw-r--r-- 1 arduino arduino 48642640 Nov 11 2025
The board’s library index was a factory snapshot from 2025-11-11. Asked what versions it knew about:
Arduino_RouterBridge -> 0.1.0 … 0.2.2
Arduino_RPClite -> 0.1.0 … 0.2.0
Nine months stale, and everything else on the board was too: the apt lists, the package index, the library index, all stamped the same day.
That reframed a bug I thought I had solved the week before. I had concluded those libraries weren’t in the Library Manager at all. They were. The board just couldn’t see the versions I was asking for. The one command that would have broken the story, asking the index what versions it actually knew, took ten seconds once I finally ran it.
Reflashing
Arduino’s docs give two routes: sudo apt update && sudo apt upgrade for routine updates, and reflashing the Linux image for major ones.
I tried the incremental path first. arduino-app-cli system update --only-arduino upgraded all five Arduino packages correctly, then died pulling container images:
[log] Pulling image 2/5 (ghcr.io/arduino/app-bricks/ei-models-runner): 79%
ERROR An error occurred event="{... Err:signal: killed}"
[done] Upgrade completed successfully
The apt half genuinely succeeded. The image half didn’t, and the final line still claims success.
I assumed out of memory or a full disk. Both wrong. The images were only about 0.4GB compressed, and the journal said:
dockerd: level=error msg="Not continuing with pull after error: context canceled"
Cancelled by its caller. arduino-app-cli puts a deadline on the whole image stage, and on a slow link it ran out of clock. Pulling the same images with plain docker pull finished without complaint. signal: killed doesn’t mean out of memory, and the kernel log would have told me in one line if I’d looked there first.
With a nine-month-old image underneath everything, I reflashed:
sudo arduino-flasher-cli flash latest
The new image was dated 2026-05-28. Kernel 6.16.7 became 7.0.0, Zephyr core 0.51.0 became 0.55.2, and the library index jumped forward by six months. One thing fixed itself in the process: the TMPDIR=/data/local/tmp problem that cost me an hour last week is gone in the newer adbd package, so my workaround is now insurance rather than the thing holding the build together.
I ran the flash without --preserve-user, which meant /home/arduino went too: WiFi credentials, the account password, my SSH key, ~/.arduino15, and all my apps.
Losing the apps cost nothing, since they’re in git. Losing the rest is what produced the second script.
Script two: q-setup
bin/q-setup handles the rest. A freshly flashed board has no network, so the only way in is USB. Everything below runs over ADB and ends with a board I can reach over SSH.
Here is what the finished script reports on a board that isn’t ready yet:
$ bin/q-setup doctor
board
ok ADB: homeguard — Debian GNU/Linux 13 (trixie)
-- WiFi: no address on wlan0
-- ssh service: inactive
-- account password: NOT set — SSH sessions will be refused
-- clock: NOT synchronised, 86 days behind
ok app-cli 0.11.0, core 0.55.2
this machine
-- board.env absent — values will be prompted
-- SSH key auth: not working
Every one of those lines was a separate discovery.
Trap 1: there are no SSH host keys
I enabled SSH the obvious way and it refused to start:
sshd: no hostkeys available -- exiting.
ls /etc/ssh/ssh_host_*
# ls: cannot access '/etc/ssh/ssh_host_*': No such file or directory
The image ships without any. They have to be generated:
sudo dpkg-reconfigure openssh-server
That has to be the bare command. The board’s sudoers uses env_reset, so even prefixing it fails:
sudo: sorry, you are not allowed to set the following environment variables: DEBIAN_FRONTEND
Trap 2: the account has no password, and that breaks key auth
With sshd running and my key installed, connections still failed. Verbose output showed authentication succeeding:
debug1: Server accepts key: /home/ashish/.ssh/id_ed25519 ED25519 SHA256:...
Authenticated to 192.168.0.20 ([192.168.0.20]:22) using "publickey".
And then:
You are required to change your password immediately (administrator enforced).
WARNING: Your password has expired.
Password change required but no TTY available.
The key worked. PAM refused the session, because the account ships with no password and a forced-change flag:
passwd -S arduino
# arduino NP 1970-01-01 0 99999 7 -1
NP means no password. Normally the board’s first-setup wizard clears this. If you never run it, every non-interactive SSH fails while looking exactly like a broken key. Authentication and session establishment are separate stages, and PAM can refuse the second while the first reports success.
Trap 3: the clock is months behind
No network means no NTP, so the board boots believing it’s the image build date:
date -u # board: Thu May 28 11:28:01 AM UTC 2026
# laptop: Sun Aug 23 04:55:47 AM UTC 2026
86 days behind. That breaks every TLS handshake against certificates issued since, so apt, docker pull and the index refresh all fail on “not yet valid” certs, which reads like a network fault and isn’t.
So the script refuses to run anything that downloads until the clock is right:
cmd_all() {
cmd_wait; cmd_name; cmd_wifi; cmd_ssh; cmd_passwd || true; cmd_hosts
cmd_clock || die "refusing to run downloads against a wrong clock"
cmd_indexes; cmd_system; cmd_bricks; cmd_cleanup; cmd_verify
}
Trap 4: set-name works, but nothing advertises it
Setting a board name is supposed to give you <name>.local over mDNS:
arduino-app-cli system set-name homeguard
It reported success. homeguard.local resolved nowhere.
systemctl is-active avahi-daemon # inactive
systemctl is-enabled avahi-daemon # disabled
The daemon that does the advertising ships switched off.
Trap 5: the friendly wrappers all want a password
arduino-app-cli system network-mode enable and system update both prompt for the account password, which makes unattended scripting impossible.
But the board’s sudoers already permits the underlying commands without one:
(ALL) NOPASSWD: /usr/local/bin/arduino-passwd
(ALL) NOPASSWD: /usr/sbin/dpkg-reconfigure openssh-server, /usr/bin/systemctl enable ssh,
/usr/bin/systemctl start ssh, /usr/bin/systemctl enable avahi-daemon, ...
(ALL) NOPASSWD: /usr/bin/apt-get update, /usr/bin/apt-get install --only-upgrade -y *
Driving those directly is what turned my script from interactive into unattended, and going straight to apt-get also skips the image-pull deadline that killed the earlier upgrade.
arduino-passwd is Arduino’s own helper, and it’s the way out of trap 2:
#!/bin/bash
read -s "pass"
[ -n "$pass" ] || exit 1
user=$(getent passwd 1000 | cut -d: -f1)
echo "$user:$pass" | chpasswd
It reads the new password on stdin and needs no existing one.
Keeping secrets out of the repo
The WiFi passphrase and board password live in a gitignored bin/board.env at mode 600, with a committed board.env.example beside it. That matters more now that the scripts are published.
Both are only ever piped over stdin, never passed as arguments, or they would sit in the board’s process list for anyone running ps:
printf '%s\n' "$WIFI_PSK" | adb shell \
"sh -c 'read -r p; nmcli device wifi connect \"$WIFI_SSID\" password \"\$p\"'"
One command
From blank board to running app:
$ bin/q-setup all
==> waiting for the board over ADB (up to 180s)
ok board is up (homeguard)
==> setting board name to 'homeguard'
ok homeguard.local is advertised
ok already on WiFi (192.168.0.20)
ok SSH service already running
==> installing id_ed25519.pub in authorized_keys
ok key installed (1 key(s) authorised)
==> setting the account password from bin/board.env
ok password set — SSH sessions will now be accepted
==> dropping stale host keys from ~/.ssh/known_hosts
ok removed homeguard.local
ok removed 192.168.0.20
ok clock synchronised (2026-08-23T05:20:24Z)
==> refreshing arduino-cli indexes
ok core: 0.90.0
==> refreshing apt and upgrading the Arduino packages
ok arduino-app-cli 0.13.0
ok arduino-app-lab 0.10.0
ok arduino-router 0.10.0
ok arduino-cli 1.5.1
==> pre-pulling app-bricks images at 0.12.0
ok python-apps-base already present
pulling ei-models-runner ... done
==> removing obsolete app images (free: 2.7G)
ok free: 2.7G -> 3.7G
==> verifying
ok key auth works: [email protected]
==> done — the board is provisioned
The ssh-keygen -R step is small but essential. A reflash regenerates the host key, so without dropping the old one every later connection fails with a host-key-changed warning.
Then the app itself:
bin/q-deploy deploy q-bridge
✓ App "Bridge Round-Trip" started successfully
What’s next
The board is current, provisioned, and reproducible. If I wipe it again, and I will, it’s one command back.
Next up is Arduino App Lab: putting the robot’s hazard readings on an actual dashboard. That was the real argument for doing this upgrade first, since the board was running App Lab 0.2.0 and the current release is 0.10.0.
This is part of my journey building HomeGuard Parivaar — an autonomous eldercare robot for Indian families, built with Arduino UNO Q.
This is a hobby project and I’m learning by building. If you have suggestions, corrections, or criticism — I’d genuinely love to hear it.
Co-authored with Claude Code (Anthropic) — my AI pair-programming partner for this build. Cover image generated with Gemini (Google).
Comments
Hosted on GitHub Discussions. Clicking loads github.com, and replying needs a GitHub account.