- C++ 91.8%
- Python 4.2%
- Shell 2.1%
- C 1.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| app | ||
| docs | ||
| offline | ||
| tests | ||
| tools | ||
| .gitattributes | ||
| .gitignore | ||
| install.sh | ||
| pytest.ini | ||
| README.org | ||
unoq-fpga-spi
- Quick start on a new board
- How it works
- Stream timing
- Heartbeat LED
- Payload file
- Layout
- Quick start
- MCU RPC contract
- Dependencies
- Hardware status
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.
- Connect the board, open Arduino App Lab and finish its first-time setup (joining Wi-Fi is optional for this app).
-
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.shIt builds the sketch, flashes the MCU, starts the app, makes it start at every power-up, and ends with
install: OK: Streaming: .... - 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:
- parse
app/python/data.csv(a parse error stops here, before the MCU is touched); - wait for the MCU (up to 15 s after boot);
- load the words, then check the word count and read back the first and last words;
- send one burst; with
LOOPBACK = True(D11 jumpered to D12, no FPGA), compare MISO with the payload; - start the stream: the MCU repeats the burst
RATE_HZtimes per second (main.py, default 5000) and the log reports the measured rate; - 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
- Wiring, pin locations and electrical limits: /pficagna/unoq-fpga-spi/src/branch/main/docs/wiring.org
- Official pinout and schematics: /pficagna/unoq-fpga-spi/src/branch/main/docs/board/README.org
- Home (Arch/Omarchy, board on USB): /pficagna/unoq-fpga-spi/src/branch/main/docs/setup-home.org
- Work (Windows laptop, ssh only): /pficagna/unoq-fpga-spi/src/branch/main/docs/setup-work.org
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 |