Arduino UNO Q: load 32-bit words from CSV and send them to an FPGA over 5 MHz SPI
  • C++ 91.8%
  • Python 4.2%
  • Shell 2.1%
  • C 1.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-03 09:49:29 +01:00
app Correct the SPI clock in the main.py comment and app description 2026-11-03 09:45:46 +01:00
docs Release v0.1.0 2026-11-02 19:01:30 +01:00
offline Release v0.1.0 2026-11-02 19:01:30 +01:00
tests Release v0.1.0 2026-11-02 19:01:30 +01:00
tools Release v0.1.0 2026-11-02 19:01:30 +01:00
.gitattributes Release v0.1.0 2026-11-02 19:01:30 +01:00
.gitignore Release v0.1.0 2026-11-02 19:01:30 +01:00
install.sh Release v0.1.0 2026-11-02 19:01:30 +01:00
pytest.ini Release v0.1.0 2026-11-02 19:01:30 +01:00
README.org Explain what RPC means in the README 2026-11-03 09:49:29 +01:00

unoq-fpga-spi

Send an array of 32-bit words, read from a text/CSV file, to an FPGA over SPI and repeat it 5000 times per second for as long as the app runs, using an Arduino UNO Q (ABX00162). SCK is 10 MHz.

Quick start on a new board

What you need: an Arduino UNO Q, a USB-C cable, a computer with Arduino App Lab (for the board's first-time setup) and Android's adb (Google's "platform-tools" zip; it runs without installing). The board needs no internet: the zip carries the Arduino libraries the first build would otherwise download. Nothing has to be installed on the board with apt.

  1. Connect the board, open Arduino App Lab and finish its first-time setup (joining Wi-Fi is optional for this app).
  2. Copy this zip to the board and run the installer (from the computer):

    adb push unoq-fpga-spi-<version>.zip /home/arduino/
    adb shell
    cd ~ && unzip -o unoq-fpga-spi-*.zip && unoq-fpga-spi/install.sh

    It builds the sketch, flashes the MCU, starts the app, makes it start at every power-up, and ends with install: OK: Streaming: ....

  3. Wire the FPGA as in /pficagna/unoq-fpga-spi/src/branch/main/docs/wiring.org (D10-D13 and GND; 3.3 V).

From then on the board streams the array at every power-up and the green LED blinks while it does. To send different data, edit ~/ArduinoApps/unoq-fpga-spi/python/data.csv on the board (or replace app/python/data.csv before running install.sh), then:

arduino-app-cli app stop ~/ArduinoApps/unoq-fpga-spi
arduino-app-cli app start ~/ArduinoApps/unoq-fpga-spi

To stop starting it at power-up: arduino-app-cli properties set default none.

Reaching the board over its own Wi-Fi (optional)

To keep the board reachable where it knows no Wi-Fi network, install with --with-ap (it asks for the arduino password once):

unoq-fpga-spi/install.sh --with-ap

This turns on the board's ssh server and adds a Wi-Fi network of the board's own, unoq-setup, which it starts only when none of its saved networks is in range. The installer prints that network's password once; read it again on the board with nmcli -s -g 802-11-wireless-security.psk con show unoq-setup.

To use it: power the board where it knows no network and wait about a minute. On the laptop join the Wi-Fi network unoq-setup (Windows first asks for an 8-digit PIN: choose "Connect using a security key instead"), then log in with the arduino password:

To put the board on a new network: nmtui (Edit a connection, Add, Wi-Fi), then nmcli con up "<network>". The ssh session ends there; if the switch fails (say, a wrong password) the board is back on unoq-setup within seconds. Once on its own network the board stays there until nmcli con up "<network>" or a reboot.

Tested with arduino-app-cli 0.6.6 and the Arduino Zephyr core 0.51.0; install.sh says so if the board has another version.

How it works

The UNO Q has two processors, and the design splits the job along that line:

Part Chip Runs Role here
MPU Qualcomm QRB2210, 4x Cortex-A53 Debian 13 (arm64) parses the file, loads words to the MCU
MCU STM32U585, Cortex-M33 @ 160 MHz Arduino core on Zephyr owns the header SPI, clocks the burst
Link Bridge: RPC over msgpack, through the arduino-router service a few ms per call

The header SPI pins are wired to the MCU, so Linux cannot drive them. Python parses the file and loads the words into an MCU RAM buffer, one Bridge call per word. The MCU then sends the whole buffer in SPI bursts, paced by its own clock. Python never touches pin timing, so the waveform stays deterministic; the Bridge latency only affects the load, not the bursts.

RPC means Remote Procedure Call: Python calls a C++ function on the STM32 by name, as if it were local. Bridge.call("load_word", w) packs the name and arguments with msgpack, the router forwards them over a UART to the sketch, which registered that name with Bridge.provide, and the return value comes back the same way.

Each run:

  1. parse app/python/data.csv (a parse error stops here, before the MCU is touched);
  2. wait for the MCU (up to 15 s after boot);
  3. load the words, then check the word count and read back the first and last words;
  4. send one burst; with LOOPBACK = True (D11 jumpered to D12, no FPGA), compare MISO with the payload;
  5. start the stream: the MCU repeats the burst RATE_HZ times per second (main.py, default 5000) and the log reports the measured rate;
  6. keep running, sending a heartbeat to the MCU once a second. The stream and the heartbeat LED stop about 3 s after the heartbeats do.

Stream timing

One burst is the whole array inside one CS-low frame (or one frame per word with CS_PER_WORD). SCK runs continuously within a burst, and SCK and MOSI are held low between bursts.

Quantity Value
SCK 10 MHz (160 MHz / 16), read back from SPI2's registers
Burst of the 36-word example 118-119 us (115.2 us of clocking + ~3 us overhead)
Period at 5000/s 200 us, bursts start on the 100 us kernel tick
Longest array at 5000/s about 60 words (0.8 us per byte + ~3 us per burst)
Measured rate 4987/s on the Linux clock, 0 missed periods over 30 s

The rate is exact on the MCU's clock: with no missed periods, the counters put the MCU's clock about 0.26 % slow against Linux's (derived from the counters, not measured directly). In the first second or two after a start some periods are missed (439 in one run, none afterwards); the cause is not known yet.

If a burst does not fit in the period the app stops with a message naming both times; lower RATE_HZ or the word count.

Heartbeat LED

The green element of RGB LED 3 (the MCU-side RGB LED, see the pinout) blinks at 1 Hz while the app runs, and goes dark about 3 s after it stops. The Python side sends a heartbeat RPC every second and the sketch blinks only while those keep arriving, so the LED shows the whole chain is alive: Linux app, Bridge and MCU. The sketch alone, which keeps running from flash after the app stops, does not light it.

A steady blue light next to it is Linux's Bluetooth power LED (blue:bt, RGB LED 2) and says nothing about the app.

Payload file

Numbers separated by commas, semicolons, spaces or newlines; # starts a comment. 0x and 0b prefixes are hex and binary; bare numbers are decimal, or hex when BARE_NUMBER_BASE = 16 in main.py (for .mem-style files; then 0b... is read as hex digits). Negative values (down to -2^31) are sent as two's complement. Errors name the file, line and token.

At most MAX_WORDS (8192) words; loading takes about 7 ms per word. For the stream, the array must also fit in one period (see Stream timing).

Layout

Path What
install.sh run on the board: install, start at power-up, start now, check
app/ the App Lab app; app.yaml and sketch.yaml were generated on the board
app/sketch/sketch.ino MCU: buffer, SPI burst, readback and loopback RPCs
app/python/main.py Linux entry point: wires the steps to Bridge.call
app/python/loader.py RPC sequence (wait, load, check, send), testable off-board
app/python/words.py payload parser, no Arduino imports
app/python/data.csv example payload
tests/ pytest suite (parser, and loader against a fake MCU)
tools/deploy.sh home: build the release zip, copy it to the board, install
tools/make-release.sh builds the zip for someone else (dev files left out)
docs/ wiring, the board's pinout and schematics, the setup guides
offline/ the Arduino libraries the build needs, for boards without internet

Quick start

Run the tests with uvx pytest (host) or python3 -m pytest (board).

MCU RPC contract

RPC Args Returns
clear_words none 0; also the MCU-ready probe
load_word uint32 new count, or -1 if full
word_count none words loaded
read_word index the stored word, or -1 if out of range
send_words none words sent (runs in main-loop context)
loopback_errors none bytes where MISO differed in the last burst, or -1 if none sent
heartbeat none 0; keeps the LED blinking and the stream running for 3 s
start_stream rate (/s) period in us, or minus the burst time if a burst does not fit
stream_bursts none bursts sent since the stream started
stream_overruns none periods missed since the stream started
burst_time_us none duration of the last burst

Bridge.call on the Python side raises on failure (ValueError for an RPC error such as an unknown method, TimeoutError after timeout, default 10 s); it does not return error values.

Dependencies

Proven with these versions:

Where Dependency Version
board Debian (preinstalled), kernel 13 (trixie), 6.16.7
board arduino-app-cli (preinstalled) 0.6.6
board Arduino Zephyr core arduino:zephyr (FQBN arduino:zephyr:unoq) 0.51.0
board Arduino_RouterBridge / Arduino_RPClite (in the core) 0.2.2 / 0.2.0
board sketch libraries pinned in sketch.yaml: MsgPack, DebugLog, ArxContainer, ArxTypeTraits 0.4.2, 0.8.4, 0.7.0, 0.3.1
board Python runtime image ghcr.io/arduino/app-bricks/python-apps-base 0.5.0 (Python 3.13.9)
board emacs-nox (apt; optional editor, nano is preinstalled) Debian 13
board the sketch's libraries: bundled in offline/ (no internet needed)
home machine android-tools (adb), git, openssh, uv, unzip android-tools 37.0.0
work laptop Windows OpenSSH client (built in)

Hardware status

Check Status
Sketch builds and the app starts via arduino-app-cli verified on the board
pytest on the host 43 passed
Home deploy: tools/deploy.sh (zip over ssh) verified: zip, copy, install, stream report
Words above 0x7FFFFFFF survive the Bridge into MCU RAM verified: readback of 0xFFFFFFFF, 0x80000000 and 1000 random words
Work setup: Windows ssh over a phone hotspot not yet tested
Fallback access point unoq-setup (docs/setup-work.org) verified: AP only when no saved network, falls back after a failed switch (Wi-Fi restarts); a Windows laptop joined, got DHCP and logged in over ssh; a power-up with home Wi-Fi in range joined it, never the AP
send_words via provide_safe with an empty loop() verified: returns the word count
Load speed measured: 7.3 ms per word
Release zip installed through adb, as a recipient would verified: install.sh reports the stream; a second run reinstalls cleanly
install.sh --with-ap verified: AP profile created (or kept) as intended; the sudo step that enables ssh not re-run here
Install with no internet at all verified: Wi-Fi off, library cache empty; restored copies identical, 4984/s
App starts by itself at power-up (default app) verified: container up ~60 s after power-on, streaming 4990/s, 0 missed
Heartbeat LED blinks while the app runs seen blinking, and off after app stop
Loopback over D11-D12 (MOSI -> MISO) verified: data.csv and 1008 random + edge words, 3 bursts, 0 errors
SCK 10 MHz, Mode 0, MSB first read back from SPI2's registers; not seen on a scope
CS framing and SCK continuity within a burst burst time matches continuous clocking; not seen on a scope
Stream at 5000/s while the app runs measured: 4987/s (Linux clock), 0 missed periods over 30 s
D10 driven as GPIO although SPI2 muxes it as hardware NSS verified in the GPIO registers (output mode); timing not scoped
FPGA receives the array not tested