RoadTest: Test out Arduino's Uno Q - The new Single-Board Computer!
Author: ralphjy
Creation date:
Evaluation Type: Development Boards & Tools
Did you receive all parts the manufacturer stated would be included in the package?: True
What other parts do you consider comparable to this product?: I don’t think that there are equivalent boards to The Uno Q unless you are looking to compare very specific features. The Uno Q seems optimized for lightweight AI processing combined with capable real-time control. For my application I might have used a Raspberry Pi5 or Radxa X4.
What were the biggest problems encountered?: Lack of adequate storage for sandboxed/containerized environment. MCU device libraries not updated for Zephyr. Brick API documentation shows functionality not currently implemented.
Detailed Review:
The Uno Q RoadTest caught my interest since there are relatively few entry level development boards that offer hybrid MPU/MCU architecture. The RPi5 does include the RP1 which is a dedicated IO controller, and maybe the most comparable board is the Radxa X4 which uses an Intel N100 and the RP2040.
The Uno Q makes some interesting design tradeoffs. It uses a Qualcomm Dragonwing QRB2210 MPU which uses the lower performance A53 cores that are used in the RPi3. And it lacks the normal IO ports that are generally found on MPU boards (USB host, Ethernet, HDMI) for standalone operation. It depends on using a powered USB-C hub to provide that functionality. It also does not have the CSI and DSI connectors that you find on other MPU boards that allow easily interfacing cameras and displays. The pins for these functions are available on the JMEDIA connector on the underside of the board, but these IO are coming directly off the MPU at 1.8V levels. To utilize this functionality, you need to use an adapter or shield that provides the IO level translation and connectors for the 3.3V CSI and DSI. Shields should be available later this year. The tradeoff is to use USB cameras and displays through the USB hub.
I wanted to see in this roadtest whether I could discover a use case that would be compelling to use the Uno Q. One of the highlighted features is the AppLab development environment that allows unified software development for both the MPU (Python) and MCU (C).
This roadtest is going to be about fitness for use in the types of applications that I develop rather than verification of performance specifications. Other reviewers have done an excellent job of documenting the Uno Q hardware and setup, so I'm not going to repeat that except as it pertains to my specific application. I have also provided links at the end of the review to relevant posts that I made.
.
I have been doing a lot of Edge Vision AI, so there is some emphasis on that functionality. I’d like to determine the difficulty of interfacing different camera types (USB, IP, SPI). I would have liked to have tried CSI cameras, but there are currently no available adapters.
The title of my roadtest, Uno Q Headless Smart Camera, reflects the end application that I am trying to achieve.
This is a description of what was included in this roadtest. Also, a list of tests that I wanted to do but did not complete due to schedule constraints.
Setup and Software Installation
The Uno Q comes with the Linux environment pre-installed with AppLab. AppLab needs some initial configuration which needs to be done using a direct USB connection to a PC host for data and power. I cover this in Arduino Uno Q - AppLab initial configuration and examples. AppLab automatically checks firmware and software status and will recommend updates.
There are a couple of caveats:
First, updating is a very resource intensive operation. Firmware updates require 10GB of free space on the host PC for downloading and unpacking the data.
Second, there is a paradigm shift from the Standalone Arduino IDE. Even though the AppLab interface might be running on the PC, all of the source files for your applications are only stored on the Uno Q. Therefore, you need to be sure to backup those files before a firmware update because they will be overwritten.
Configurations
Example Tests
Custom Tests
Tests not completed
Arduino has done a great job of providing many sample tests to demonstrate the features of the Uno Q and AppLab
There are 29 apps provided in AppLab 0.7.9. The latest AppLab update available during my review timeframe was 0.7.0.

The app repository is https://github.com/arduino/app-bricks-examples/tree/main/examples

There are also 22 Bricks provided for use. A Brick is a pre-built, modular software component that adds advanced functionality (like a “super” library). These "plug-and-play" blocks bundle Python scripts, containers, and AI models to accelerate development.

The Brick repository is https://github.com/arduino/app-bricks-py/tree/main/src/arduino/app_bricks

The MQTT brick caught me eye, but it turns out that the MQTT functionality is embedded in the Arduino_Cloud brick and does not use a more general-purpose broker like Mosquitto. I guess I’ll do it the old-fashioned way by adding Mosquitto to my application as I don't intend to use the Arduino Cloud.
Blink LED
The standard test now demonstrates the hybrid MPU/MCU architecture. The Arduino sketch controls the GPIO and the LED hardware and the Python script handles the timing and state logic. The Router Bridge provides direct communication between Python and Arduino.
Blink LED with UI
This example toggles an LED on the board using a simple web user interface. The application listens for user input through a web browser and updates the LED state accordingly. It shows how to interact with hardware from a Linux environment and provides a basis for building more complex hardware-interfacing applications.
The assets folder contains the frontend components of the application. Inside, you'll find the JavaScript source files along with the HTML and CSS files that make up the web user interface. The python folder includes the application backend.
The interactive toggle switch UI is generated with JavaScript, while the Arduino sketch manages the LED hardware control. The Router Bridge enables communication between the web interface on the Linux processor and the microcontroller.
Bricks Used:
web_ui: Brick to create a web interface to display the LED control toggle switch.


Blinking LED from Arduino Cloud
This Blinking LED from Arduino Cloud example allows us to remotely control the onboard LED on the Arduino® UNO Q from the Arduino Cloud. The LED is controlled by creating a dashboard with a switch in the Arduino Cloud.
Bricks Used:
arduino_cloud: Brick to create a connection to the Arduino Cloud
This example worked as intended. It does require some configuration (add device to cloud account and add app key in Arduino_Cloud Brick). I don’t generally use the Arduino Cloud as I normally run my own local Node-Red dashboard. The Cloud Free Plan also restricts use to 2 devices. I just tried it to verify that it works.
Weather forecast on LED matrix
The App fetches weather data from the open-meteo.com API for a specified city and converts weather codes into animated patterns on the 8 x 13 LED matrix. Each weather condition triggers its own distinctive animation sequence with carefully timed frame changes that simulate natural weather behavior.
The Python® script handles API communication and weather processing, while the Arduino sketch manages LED matrix animations and polling. The Router Bridge enables parameter passing between the Python environment and the microcontroller.
Bricks Used:
weather_forecast: Brick to fetch weather data from the open-meteo.com API and convert weather codes into simple categories.
The example defaults to showing the data from Turin on the LED matrix. I made a copy of it to modify the forecast for Portland, but apparently it only allows a city name which is not unique so I'm apparently getting the results for Portland, Maine. There is a provision in the API to enter GPS coordinates, but I didn't try that.
Home climate monitoring and storage
The Home Climate Monitoring example records temperature & humidity data from the Modulino® Thermo node, and streams it to a web interface.
The data is stored on the board, where we can view the data from the latest 24 hour period.
Bricks Used:
dbstorage_tsstore - makes it possible to save, read, and manage time-based data.
web_ui - used to host a web server on the board, serving HTML, CSS & JavaScript files.
I decided to use this example because I wanted to try out the Modulino Thermo and the QWIIC interface.
This was the first example app that failed to run. It had a linking failure during compilation.

It is something related to Zephyr. One of the changes to the Arduino IDE used for AppLab is the replacement of the Mbed hardware libraries with Zephyr due to the end of Mbed support. I'm not completely sure what this error is, but an AI query suggested that Zephyr needed to recompile (due to an update) and it did not have permissions to update the sketch directory because examples are read only. I created a copy of the app which allows me to modify it and it worked without modifying the sketch source code.
The app uses the WebUI Brick to publish data to a web browser to display the data. The browser URL is unoq_ipaddr:7000.

Detect Objects on Camera
The Detect Objects on Camera example lets you detect objects on a live feed from a USB camera and visualize bounding boxes around the detections in real-time.
Note: This example must be run in Network Mode in the Arduino App Lab, since it requires a USB-C hub and a USB camera. I discovered that I had issues with the PC host USB data connection to AppLab when I was using the USB-C hub.
Bricks Used:
video_objectdetection - used for detecting objects in real time from a USB camera video stream.
web_ui - used to host a web server on the board, serving HTML, CSS & JavaScript files.
I did not use a USB-C hub for this example, but instead tried a OTG adapter with USB-C PD passthrough to connect the USB camera.
That is covered in the post Arduino Uno Q and USB3 adapter with USB-C PD.
.
I initially had an OTG adapter that failed to switch the Uno Q into USB Host mode. I found a different one that worked and the USB camera was detected and the app ran successfully.
The browser window at unoq_ipaddr:7000 showing the camera stream with bounding boxes and detection results.
Home climate monitoring and storage with proximity detection
For my first custom test I decided to modify the Home climate monitoring example to add proximity detection using a Modulino Distance module. I modified the Home climate monitoring app to display the temperature on the Uno Q LED matrix. I used proximity detection to only display data when a person is detected within 500mm. So in addition to the Thermo, I needed to add a Distance module. The setup is shown below. The Distance (TOF) module is on the left and the Thermo (temperature, humidity) module is on the right with daisy chaining to the QWIIC connector on the Uno Q.
Here is a view of my app file structure. Basically the same as the original app.
My new app just modifies the example sketch.ino file.

main.py
import datetime
import math
from arduino.app_bricks.dbstorage_tsstore import TimeSeriesStore
from arduino.app_bricks.web_ui import WebUI
from arduino.app_utils import App, Bridge
db = TimeSeriesStore()
def on_get_samples(resource: str, start: str, aggr_window: str):
samples = db.read_samples(measure=resource, start_from=start, aggr_window=aggr_window, aggr_func="mean", limit=100)
return [{"ts": s[1], "value": s[2]} for s in samples]
ui = WebUI()
ui.expose_api("GET", "/get_samples/{resource}/{start}/{aggr_window}", on_get_samples)
def record_sensor_samples(celsius: float, humidity: float):
"""Callback invoked by the board sketch via Bridge.notify to send sensor samples.
Stores temperature and humidity samples in the time-series DB and forwards them to the Web UI.
"""
if celsius is None or humidity is None:
print("Received invalid sensor samples: celsius=%s, humidity=%s" % (celsius, humidity))
return
ts = int(datetime.datetime.now().timestamp() * 1000)
# Write samples to time-series DB
db.write_sample("temperature", float(celsius), ts)
db.write_sample("humidity", float(humidity), ts)
# Push realtime updates to the UI
ui.send_message('temperature', {"value": float(celsius), "ts": ts})
ui.send_message('humidity', {"value": float(humidity), "ts": ts})
# --- Derived metrics ---
T = float(celsius)
RH = float(humidity)
# Dew point (Magnus formula) - compute only when RH > 0 to avoid math.log(0)
a = 17.27
b = 237.7
dew_point = None
if RH > 0.0:
# clamp RH into (0,100] and avoid exact zero
rh_frac = max(min(RH, 100.0), 1e-6)
gamma = (a * T) / (b + T) + math.log(rh_frac / 100.0)
dew_point = (b * gamma) / (a - gamma)
# Heat Index (using Rothfusz regression). Convert to Fahrenheit and back to Celsius.
T_f = T * 9.0 / 5.0 + 32.0
R = max(min(RH, 100.0), 0.0)
HI_f = (-42.379 + 2.04901523 * T_f + 10.14333127 * R - 0.22475541 * T_f * R
- 0.00683783 * T_f * T_f - 0.05481717 * R * R
+ 0.00122874 * T_f * T_f * R + 0.00085282 * T_f * R * R
- 0.00000199 * T_f * T_f * R * R)
heat_index = (HI_f - 32.0) * 5.0 / 9.0
# Absolute humidity (g/m^3)
absolute_humidity = None
if RH is not None and RH >= 0.0:
es = 6.112 * math.exp((17.67 * T) / (T + 243.5))
absolute_humidity = es * (R / 100.0) * 2.1674 / (273.15 + T)
# Store and forward derived metrics if computed
if dew_point is not None:
db.write_sample("dew_point", float(dew_point), ts)
ui.send_message('dew_point', {"value": float(dew_point), "ts": ts})
if heat_index is not None:
db.write_sample("heat_index", float(heat_index), ts)
ui.send_message('heat_index', {"value": float(heat_index), "ts": ts})
if absolute_humidity is not None:
db.write_sample("absolute_humidity", float(absolute_humidity), ts)
ui.send_message('absolute_humidity', {"value": float(absolute_humidity), "ts": ts})
print("Registering 'record_sensor_samples' callback.")
Bridge.provide("record_sensor_samples", record_sensor_samples)
print("Starting App...")
App.run()
sketch.ino
#include <Arduino_Modulino.h>
#include <Arduino_RouterBridge.h>
#include "Arduino_LED_Matrix.h"
ArduinoLEDMatrix matrix;
// Create object instance
ModulinoThermo thermo;
ModulinoDistance distance;
unsigned long previousMillis = 0; // Stores last time values were updated
const long interval = 1000; //Every second
void setup() {
Bridge.begin();
matrix.begin();
Monitor.begin(9600);
// Initialize Modulino I2C communication
Modulino.begin(Wire1);
// Detect and connect to temperature/humidity sensor module
thermo.begin();
distance.begin();
}
void loop() {
unsigned long currentMillis = millis(); // Get the current time
if (currentMillis - previousMillis >= interval) {
// Save the last time you updated the values
previousMillis = currentMillis;
if (distance.available()) {
// 1. Force a measurement update
int currentDistance = distance.get(); // Get distance in mm
Monitor.print("Distance: ");
Monitor.print(currentDistance);
Monitor.println(" mm");
// 2. Check your specific 500mm condition
if (currentDistance < 500) {
// Read temperature in Celsius from the sensor
float celsius = thermo.getTemperature();
Monitor.print("Temperature: ");
Monitor.print(celsius);
Monitor.println(" C");
// Read humidity percentage from the sensor
float humidity = thermo.getHumidity();
Monitor.print("Humidity: ");
Monitor.print(humidity);
Monitor.println(" %");
Bridge.notify("record_sensor_samples", celsius, humidity);
// Clear and draw the temperature
matrix.beginDraw();
matrix.stroke(0xFFFFFFFF);
matrix.textFont(Font_5x7);
matrix.beginText(0, 1, 0xFFFFFFFF);
matrix.print((int)celsius); // Cast to int to save space on the small matrix
matrix.print("C");
matrix.endText();
matrix.endDraw();
} else {
matrix.clear(); // Turn off the matrix if no one is there
}
delay(200);
} else {
matrix.clear(); // Turn off the matrix if no one is there
// This will still print even if the sensor sees "nothing"
Monitor.println("Out of range.");
}
}
}
A quick demo of the app.
A Serial Monitor output shows the result of my approaching from beyond 2m (Out of range)
Then temperature and humidity data starts logging when I am within 500mm and stops when I am beyond it.

Video showing the same sequence on the Uno Q LED Matrix (displays temperature only within 500mm).
USB Audio with USB Speaker and USB Drive for storage
I created 2 apps to use with the USB Speaker: Sound Generator Fur Elise and MP3 Player. I discussed the development of these apps in the post Arduino Uno Q - USB Audio. I'll include the code and the demo videos below. Please reference the post for more info.
Sound Generator Fur Elise
This app demonstrates using the Sound Generator Brick to play tones for Beethoven's Fur Elise.
The python program controls the sound generation and playback over the USB speaker and the handshake for the LED matrix.
main.py
from arduino.app_bricks.sound_generator import SoundGenerator, SoundEffect
from arduino.app_utils import *
import time
# Use 'master_volume' instead of 'volume'
player = SoundGenerator(master_volume=0.01, sound_effects=[SoundEffect.adsr()])
fur_elise = [
("E5", 1/4), ("D#5", 1/4), ("E5", 1/4), ("D#5", 1/4), ("E5", 1/4),
("B4", 1/4), ("D5", 1/4), ("C5", 1/4), ("A4", 1/2),
("C4", 1/4), ("E4", 1/4), ("A4", 1/4), ("B4", 1/2),
("E4", 1/4), ("G#4", 1/4), ("B4", 1/4), ("C5", 1/2),
("E4", 1/4), ("E5", 1/4), ("D#5", 1/4), ("E5", 1/4), ("D#5", 1/4), ("E5", 1/4),
("B4", 1/4), ("D5", 1/4), ("C5", 1/4), ("A4", 1/2),
("C4", 1/4), ("E4", 1/4), ("A4", 1/4), ("B4", 1/2),
("E4", 1/4), ("C5", 1/4), ("B4", 1/4), ("A4", 1.0),
]
# Start an infinite loop
while True:
for i, (note, duration) in enumerate(fur_elise):
# Toggle between frame 0 and frame 1
frame_to_show = i % 2
# This calls the C++ setFrame function
Bridge.call("setFrame", frame_to_show)
player.play(note, duration)
time.sleep(1)
App.run()
The sketch just controls the matrix.
sketch.ino
#include "Arduino_LED_Matrix.h"
#include "Arduino_RouterBridge.h"
Arduino_LED_Matrix matrix;
// Define a "Musical Note" frame (13x8)
const uint32_t frameA[] = { 0xfe04102, 0x8104082, 0x470e387, 0x0 };
// Define a "Bar" frame
const uint32_t frameB[] = { 0x0, 0x0, 0x0, 0x0 };
void setFrame(int frameId) {
if (frameId == 0) {
matrix.loadFrame(frameA);
} else {
matrix.loadFrame(frameB);
}
}
void setup() {
matrix.begin();
Bridge.begin();
// Register the function so Python can call it
Bridge.provide("setFrame", setFrame);
}
void loop() {
// Bridge processes requests here
}
MP3 Player
This app demonstrates playing an mp3 file from the USB drive on the USB speaker using pygame to play the audio. It also uses the Modulino knob to adjust the volume and the LED matrix to display the numeric volume percentage.
main.py
import os
import time
import pygame
from arduino.app_utils import App, Bridge
pygame.mixer.init()
current_dir = os.path.dirname(os.path.abspath(__file__))
MUSIC_PATH = os.path.join(current_dir, "music")
# 1. This function only handles the volume updates
def update_volume(vol_data):
try:
new_vol = float(vol_data) / 100.0
pygame.mixer.music.set_volume(new_vol)
print(f"Volume updated to: {vol_data}")
except Exception as e:
print(f"Volume error: {e}")
# 2. This function starts the song and returns immediately
def start_playback():
songs = [f for f in os.listdir(MUSIC_PATH) if f.endswith('.mp3')]
if not songs:
print("No music found!")
return
pygame.mixer.music.load(os.path.join(MUSIC_PATH, songs[0]))
pygame.mixer.music.play(-1) # -1 means loop forever
print(f"Playing: {songs[0]}")
# Register the volume callback
# Every time the MCU calls Bridge.notify("volume", val), this runs
print("Registering 'volume' callback.")
Bridge.provide("volume", update_volume)
# Start the music
start_playback()
print("Starting App Service...")
# App.run() keeps the script alive and listens for Bridge events
App.run()
sketch.ino
#include <Arduino_Modulino.h>
#include <Arduino_LED_Matrix.h>
#include <Arduino_RouterBridge.h>
ModulinoKnob knob;
ArduinoLEDMatrix matrix;
int lastVolume = -1;
void setup() {
Bridge.begin();
Monitor.begin();
Modulino.begin(Wire1);
knob.begin();
matrix.begin();
knob.set(50);
}
void loop() {
int vol = knob.get();
if (vol > 100) { vol = 100; knob.set(100); }
else if (vol < 0) { vol = 0; knob.set(0); }
if (vol != lastVolume) {
Monitor.print("VOL:");
Monitor.println(vol);
Bridge.notify("volume", vol);
matrix.beginDraw();
matrix.clear(); // This is the fix for the "ghost digits"
matrix.stroke(0xFFFFFFFF);
matrix.textFont(Font_4x6);
//matrix.beginText(0, 1, 0xFFFFFFFF);
//matrix.print("V");
// Small adjustment for positioning
// If it's a single digit, move it right so it's centered
if (vol < 10) {
matrix.beginText(9, 1, 0xFFFFFFFF);
} else if (vol < 100) {
matrix.beginText(5, 1, 0xFFFFFFFF);
} else {
matrix.beginText(1, 1, 0xFFFFFFFF);
}
matrix.print(vol);
matrix.endText();
matrix.endDraw();
lastVolume = vol;
}
delay(50);
}
Web Browser Viewer for SPI Camera
I was really hoping that the Arduino Uno Media Carrier would be available in time for the roadtest, so that I could try a RPi CSI camera or two. In the meantime, I thought that I would try an Arducam Mini 2MP+ SPI camera that I've used in with other MCUs and the Arduino IDE.
This was my opportunity to try the Uno Q using the tried and true Arduino IDE on the host PC rather than using AppLab and connecting to the Uno Q via USB. At the start it felt like an old shoe. I could compile and upload MCU only examples like always, but as soon as I got to trying the Arducam camera library it failed immediately. The Uno Q requires using the new Zephyr OS instead of the older Mbed OS. Understandable since Mbed is now EOL, but it causes issues with older libraries that have not been updated to Zephyr.
The other problem I had with the IDE is "where is my serial debug output". Not in the usual Serial Monitor of the IDE. Makes sense since the MCU doesn't have access to the USB serial on the Uno Q. So, reverted to using a USB to UART interface connected the TX and RX pins on the Digital header and received the output in a PuTTY terminal on the PC. I have FTDI boards somewhere, but I had an Adafruit MCP2221 that I was using elsewhere for I2C. Maybe I'll need that for the Uno Q later...The hookup is shown below.
The library problems were class definition issues with data types and functions that were causing it not to compile. Modification to the Arducam_Mini.h and .cpp files got the camera working and I decided it was time to move it into AppLab.
Created a new app and installed the Arducam Mini library. This is the same version of the library that is installed by the standalone IDE, so I needed to modify it. Next problem, where is the library? I couldn't find it by searching the file system using the find command. This is one of the quirks or features of AppLab that I've learned. Installing a library using the Library manager does not actually install the library until you try to compile. Of course, compiling also has the benefit of showing you the library path whether it compiles or not. The location was /home/arduino/.arduino15/internal/Arducam_mini_1.0.1_becf652cd4feef8c/Arducam_mini. A bit more obfuscated path than the standalone IDE where libraries are installed in a directory with the sketches. Before I discovered where the library was (I was overthinking things and wanted to fix the library before I compiled), I took the cheaters way out and installed a copy of the modified library in the sketch folder. Anyway, more than one way to skin a cat.
The Python code requests frames from the MCU over the Bridge. The MCU captures the frame and returns it over the Bridge. The Python code then stores the frame in the "/app/assets". The WebUI starts a browser window at unoq_ipaddr:7000 and and the browser pulls the new frames for display.
I was having a terrible time with intermittent behavior capturing frames over SPI when using either D9 or D10 as the SPI CS pin (I had used D10 without issues using the standalone IDE). When I switched CS to D7, the problems went away. The Arducam Mini has its own proprietary bridge chip on the SPI bus and there may be timing issues, but slowing the bus down didn't help - only changing the CS pin. There is stuff going on behind the curtain with pin and function mux'ing when using AppLab. As an example, I noticed on the debug UART that you get boot messages from the MCU before it switches to being Serial1 for serial print output. I believe that the D9 and D10 pins were glitching when the MCU was initializing and causing the bridge chip in the Arducam to hang intermittently.
main.py
from arduino.app_utils import App, Bridge
from arduino.app_bricks.web_ui import WebUI
import time
import struct
import threading
import os
ui = WebUI()
IMAGE_PATH = "assets/frame.jpg"
TMP_PATH = "assets/frame_tmp.jpg"
def camera_reader():
shared_bridge = Bridge()
print("Bridge connected. Requesting frames...")
while True:
try:
# Explicitly wait for the capture to finish
# The timeout on the call itself might need to be higher
image_data = shared_bridge.call("cam_frame", timeout=15)
if image_data and len(image_data) > 0:
# filename = f"capture_{int(time.time())}.jpg"
# with open(filename, "wb") as f:
# f.write(image_data)
with open(TMP_PATH, "wb") as f:
f.write(image_data)
os.replace(TMP_PATH, IMAGE_PATH)
print(f"Frame saved: {len(image_data)} bytes")
else:
print("Received empty frame or error from MCU.")
time.sleep(0.1)
except Exception as e:
print(f"Error: {e}")
time.sleep(2)
# ----------------------------
# START BACKGROUND THREADS
# ----------------------------
threading.Thread(target=camera_reader, daemon=True).start()
# ----------------------------
# APP ENTRY POINT
# ----------------------------
App.run()
sketch.ino
#include <Arduino_RouterBridge.h>
#include <Wire.h>
#include <SPI.h>
#include "ArduCAM_local.h"
#include <vector>
const int CS = 7;
ArduCAM myCAM(OV2640, CS);
// Use a fixed-size return to stabilize the RPC response
std::vector<uint8_t> get_camera_frame() {
const int CS_PIN = 7;
// Force a hard toggle to ensure the pin isn't "stuck" in Zephyr's driver
digitalWrite(CS_PIN, HIGH);
delay(1);
digitalWrite(CS_PIN, LOW);
delay(1);
digitalWrite(CS_PIN, HIGH);
delay(1);
Serial1.println("RPC: Capture Requested");
// 1. Force a HARD Reset of the FIFO logic on every call
digitalWrite(CS, LOW);
SPI.transfer(0x84); // Register 0x04 (Write)
SPI.transfer(0x01); // Reset FIFO
digitalWrite(CS, HIGH);
delay(5);
// 2. Start Capture
digitalWrite(CS, LOW);
SPI.transfer(0x84);
SPI.transfer(0x02); // Start capture
digitalWrite(CS, HIGH);
// 3. Polling for Done
uint32_t timeout = millis();
bool capture_done = false;
while (millis() - timeout < 2000) {
digitalWrite(CS, LOW);
SPI.transfer(0x41); // Register 0x41 (Read)
uint8_t status = SPI.transfer(0x00);
digitalWrite(CS, HIGH);
if (status & 0x08) {
capture_done = true;
break;
}
delay(1);
}
if (!capture_done) {
Serial1.println("RPC: Capture Timeout");
return {};
}
digitalWrite(CS, LOW);
SPI.transfer(0x00); // Send a dummy byte to wake up the MISO line
digitalWrite(CS, HIGH);
delay(1);
// 4. THE ATOMIC LENGTH READ
// We read the length registers back-to-back without the library wrapper
digitalWrite(CS, LOW);
SPI.transfer(0x42); uint32_t l1 = SPI.transfer(0x00);
digitalWrite(CS, HIGH);
digitalWrite(CS, LOW);
SPI.transfer(0x43); uint32_t l2 = SPI.transfer(0x00);
digitalWrite(CS, HIGH);
digitalWrite(CS, LOW);
SPI.transfer(0x44); uint32_t l3 = SPI.transfer(0x00);
digitalWrite(CS, HIGH);
uint32_t len = (l3 << 16) | (l2 << 8) | l1;
Serial1.print("Atomic Size Read: ");
Serial1.println(len);
// 5. If size is still 0x2A2A2A (2763306) or 0xFFFFFF (16.7M),
// it means the Bridge Chip is not "latching" the sensor data.
if (len == 0 || len > 100000 || len == 2763306) {
return {};
}
// 6. Data Transfer
std::vector<uint8_t> buffer(len);
digitalWrite(CS, LOW);
SPI.transfer(0x3C); // Burst Read Command
for (uint32_t i = 0; i < len; i++) {
buffer[i] = SPI.transfer(0x00);
}
digitalWrite(CS, HIGH);
return buffer;
}
void setup() {
Serial1.begin(115200);
pinMode(CS, OUTPUT);
digitalWrite(CS, HIGH);
Wire.begin();
SPI.begin();
SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0));
// 1. Hard reset and wake up
myCAM.write_reg(0x05, 0x01);
delay(100);
myCAM.write_reg(0x05, 0x00);
delay(100);
myCAM.InitCAM();
myCAM.set_format(JPEG);
myCAM.InitCAM();
myCAM.OV2640_set_JPEG_size(OV2640_640x480);
// 2. THE THROW-AWAY CAPTURE
// This forces the sensor to start outputting JPEG data once
// before the Bridge takes over.
myCAM.clear_fifo_flag();
myCAM.start_capture();
uint32_t t = millis();
while(!myCAM.get_bit(ARDUCHIP_TRIG, CAP_DONE_MASK) && (millis() - t < 1000));
myCAM.clear_fifo_flag();
Bridge.begin();
Bridge.provide("cam_frame", get_camera_frame);
Serial1.println("RouterBridge & Camera Ready.");
}
void loop() {
Bridge.update();
delay(10);
}
Anyway, I got it working, but I would say that it isn't worth the effort. There is no shared storage between the MCU and MPU, so capturing on the MCU doesn't make sense because the update rate using the bridge is too slow. It's better to wait for for the CSI interface to become available and do everything on the MPU..
Capture Images from IP Camera using RTSP
I use IP cameras for monitoring and security. I've been using Frigate (with and without Home Assistant integration) to do object detection. I thought that I would see how well an RTSP stream would fare with the Uno Q using AppLab. Since the VideoObjectDetection and VideoObjectClassification bricks work well with the Webcam it seems that processing an RTSP stream shouldn't be an issue. If you look at the API documentation for the VideoObjectDetection brick, the class definition has a camera parameter - class VideoObjectDetection(camera: BaseCamera | None, confidence: float, debounce_sec: float, camera_preview: bool). As a matter of fact, supposedly the IPCamera option was added in Arduino App Bricks / CLI version 0.7.x+. I am running 0.7.0 and I could not get it accept anything - always returned "no camera device found". I tracked down the culprit - when the brick is added the app.yaml is modified to the following:
name: RTSP VideoObjectDetection
description: ""
ports: []
bricks:
- arduino:video_object_detection: {}
icon:
So, the definition is forcing default parameters and the default camera is USB. It won't let me edit the app.yaml, so I guess I can't use the brick.
But, this is a Linux system and OpenCV is installed in AppLab - so, I should be able to capture images and display them in a browser which is the purpose of this app.
Everything runs in the Python script... I had to put flask in requirements.txt to get that module installed.
main.py
import os
import cv2
import time
from flask import Flask, send_from_directory
from arduino.app_utils import Bridge
import threading
app = Flask(__name__)
# Match the path where you see files in WinSCP
WWW_DIR = "/app/assets"
os.makedirs(WWW_DIR, exist_ok=True)
RTSP_URL = "rtsp://admin:adminpw@rtspaddr"
bridge = Bridge()
def capture_loop():
while True:
cap = cv2.VideoCapture(RTSP_URL, cv2.CAP_FFMPEG)
for _ in range(5): cap.grab()
success, frame = cap.read()
if success:
cv2.imwrite(os.path.join(WWW_DIR, "latest.jpg"), frame)
cap.release()
time.sleep(1)
@app.route('/')
def index():
# Simple HTML returned directly by Python, just like the climate app
return '''
<html>
<body style="background:#111; color:white; text-align:center;">
<h1>RTSP Camera Brick (Port 7000)</h1>
<img src="/image" id="feed" style="width:80%;">
<script>
setInterval(() => {
document.getElementById('feed').src = '/image?t=' + Date.now();
}, 1000);
</script>
</body>
</html>
'''
@app.route('/image')
def get_image():
return send_from_directory(WWW_DIR, "latest.jpg")
if __name__ == "__main__":
# Start the camera capture in the background
threading.Thread(target=capture_loop, daemon=True).start()
# Run the server on Port 7000 - the "Known Good" port
print("🚀 Camera Server launching on Port 7000...")
app.run(host='0.0.0.0', port=7000)
The program captures images from the RTSP stream and stores the latest in a local directory "/app/assets". A browser window is opened at unoq_ipaddr:7000 and the browser requests images every second.
And it works, albeit very slowly (if you watch the camera timestamp, the image is updating every 5 seconds or so). You can tell by the Title that I was somewhat optimistic - I was considering trying to make a custom brick. At this point I'm hoping that the Arduino Video Bricks get updated in the near future so that the IP camera option works...
Object Detection Stream for IP Camera not using AppLab
I couldn't get the VideoObjectDetection brick to work with the RTSP stream, but I thought that I could interface directly to the Edge Impulse model within AppLab using OpenCV. I found the YoloX model being used by the VideoObjectDetection brick. It's located at /var/lib/docker/overlay2/2a14d4e8a65ffc2837c54d4ff4ff0264f92bf5b9a574d86deab1e2e0d82e71d4/diff/models/ootb/ei/yolo-x-nano.eim. I was struggling in AppLab and in frustration decided to move out into regular Linux to try to see if I could get the model to run with the RTSP stream using the Edge Impulse command line tools. This was a risky decision as these tools are not by default installed outside of AppLab and it consumed a lot of precious "disk" space to install them. It should have been as easy as running this command "edge-impulse-linux-runner --model-file /PATH/TO/YOUR/MODEL.eim --url rtsp://YOUR_RTSP_URL". After fighting through permissions issues and missing dependencies like gstreamer, I discovered that the Edge Impulse tools installed on the Uno Q somehow are also constrained to using the webcam as camera input? It almost worked.
[RUN] Starting the image classifier for Arduino / yolo-x (v7)
[RUN] Parameters
[RUN] image size 416x416 px (3 channels)
[RUN] classes [
'person', 'bicycle', 'car', 'motorcycle',
'airplane', 'bus', 'train', 'truck',
'boat', 'traffic light', 'fire hydrant', 'stop sign',
'parking meter', 'bench', 'bird', 'cat',
'dog', 'horse', 'sheep', 'cow',
'elephant', 'bear', 'zebra', 'giraffe',
'backpack', 'umbrella', 'handbag', 'tie',
'suitcase', 'frisbee', 'skis', 'snowboard',
'sports ball', 'kite', 'baseball bat', 'baseball glove',
'skateboard', 'surfboard', 'tennis racket', 'bottle',
'wine glass', 'cup', 'fork', 'knife',
'spoon', 'bowl', 'banana', 'apple',
'sandwich', 'orange', 'broccoli', 'carrot',
'hot dog', 'pizza', 'donut', 'cake',
'chair', 'couch', 'potted plant', 'bed',
'dining table', 'toilet', 'tv', 'laptop',
'mouse', 'remote', 'keyboard', 'cell phone',
'microwave', 'oven', 'toaster', 'sink',
'refrigerator', 'book', 'clock', 'vase',
'scissors', 'teddy bear', 'hair drier', 'toothbrush'
]
[RUN] Thresholds: 12.min_score=0.3 (override via --thresholds <value>)
[GST] checking for /etc/os-release
[RUN] Failed to run impulse Cannot find any webcams
So, I'm still missing a piece of the puzzle.
One last try - back to running everything in Python without using the Edge Impulse CLI.
Tried to install the edge_impulse_linux module, but ran into the modern Linux protection from yourself (the requirement to install user libraries in a virtual environment)..
pip3 install edge_impulse_linux
error: externally-managed-environment
Decided not to fight it and created a virtual environment (venv), but made sure I inherited all the existing system python modules so that I didn't have to install them.
Running in the venv, I finally have a smart streamer script that will stream the image with bounding boxes to a web browser. Still has warts. I haven't quite got the bounding box scaling right, but I'm still tweaking it.
The first video shows that it started off awful, the scale and offset of the BBs was off and the labels on a lot of the BBs end up out of the image frame.. There were some sync issues also. I had used the higher resolution 1280x720 stream from the camera and that was causing processing delays scaling the model input to 416x416. For the second video I switched to the lower resolution 640x352 stream and did some scale and offset tweaking and shifted the labels to the bottom. Still needs work, but it is usable.
smart_stream.py
import cv2
import threading
from flask import Flask, Response
from edge_impulse_linux.image import ImageImpulseRunner
app = Flask(__name__)
MODEL_PATH = "/home/arduino/rtsp_ai_project/yolo-x-nano.eim"
RTSP_URL = 'rtsp://admin:adminPW@rtspaddr'
class VideoStreamer:
def __init__(self, url):
self.cap = cv2.VideoCapture(url)
self.ret, self.frame = False, None
self.stopped = False
# Start a background thread to keep the buffer empty
threading.Thread(target=self.update, daemon=True).start()
def update(self):
while not self.stopped:
self.ret, self.frame = self.cap.read()
def read(self):
return self.ret, self.frame
def stop(self):
self.stopped = True
self.cap.release()
def gen_frames():
stream = VideoStreamer(RTSP_URL)
with ImageImpulseRunner(MODEL_PATH) as runner:
runner.init()
print("AI Model Loaded. Buffering RTSP...")
# Manually set these since the SDK is being difficult
model_width = 416
model_height = 416
while True:
success, frame = stream.read()
if not success or frame is None:
continue
# --- ADD THESE TWO LINES HERE ---
height, width, _ = frame.shape
# --------------------------------
# Run inference
features, img = runner.get_features_from_image(frame)
res = runner.classify(features)
# Draw results
if "bounding_boxes" in res["result"]:
# 1. Determine the scale based on the largest dimension (width)
# This assumes the SDK is letterboxing (adding bars) rather than stretching
# scale = width / model_width
scale = 1.0
for bb in res["result"]["bounding_boxes"]:
# Only draw if the AI is more than 70% sure
if bb['value'] >= 0.70:
# Use 'scale' for both to preserve the object's shape
w = int(bb['width'] * scale)
h = int(bb['height'] * scale)
# Use your /4 offset that worked, or center it dynamically:
x_offset = (width - (model_width * scale)) / 1.9
y_offset = (height - (model_height * scale)) / 10#0
y_offset = -5
x = int((bb['x'] * scale) + x_offset)
y = int((bb['y'] * scale) + y_offset)
#x = int(bb['x'] * scale)
#y = int(bb['y'] * scale)
w = int(bb['width'] * scale)
h = int(bb['height'] * scale)
label = f"{bb['label']} {int(bb['value']*100)}%"
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.putText(frame, label, (x, y + h + 15),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
# Encode and send
_, buffer = cv2.imencode('.jpg', frame)
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + buffer.tobytes() + b'\r\n')
@app.route('/video_feed')
def video_feed():
return Response(gen_frames(), mimetype='multipart/x-mixed-replace; boundary=frame')
@app.route('/')
def index():
return "<html><body><h1>Uno Q AI Stream</h1><img src='/video_feed'></body></html>"
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
Object Detection Stream for IP Camera using AppLab
I decided that I would try one last variation within Applab. I am going to try to run without the Edge Impulse integration and without using the Edge Impulse generated model. I am using OpenCV with a YoloV8 Nano Onnx model that I downloaded from the Ultralytics repository - "yolov8n.onnx". This model will provide the correct scaling and offset for the bounding boxes and has the advantage of having a model input of 640x640 which matches up nicely with the 640x352 stream from the camera.
videoobjectdetection-webui.py
import cv2
import numpy as np
import threading
import time
from arduino.app_bricks.web_ui import WebUI
from arduino.app_utils import App
from fastapi.responses import StreamingResponse, HTMLResponse
# --------------------------------------------------
# CONFIG
# --------------------------------------------------
RTSP_URL = "rtsp://admin:adminPW@rtspaddr"
MODEL_PATH = "yolov8n.onnx"
INPUT_SIZE = 640
CONF_THRESHOLD = 0.6
NMS_THRESHOLD = 0.45
CLASS_NAMES = [
"person", "bicycle", "car", "motorcycle", "airplane",
"bus", "train", "truck", "boat", "traffic light",
"fire hydrant", "stop sign", "parking meter", "bench", "bird",
"cat", "dog", "horse", "sheep", "cow",
"elephant", "bear", "zebra", "giraffe", "backpack",
"umbrella", "handbag", "tie", "suitcase", "frisbee",
"skis", "snowboard", "sports ball", "kite", "baseball bat",
"baseball glove", "skateboard", "surfboard", "tennis racket", "bottle",
"wine glass", "cup", "fork", "knife", "spoon",
"bowl", "banana", "apple", "sandwich", "orange",
"broccoli", "carrot", "hot dog", "pizza", "donut",
"cake", "chair", "couch", "potted plant", "bed",
"dining table", "toilet", "tv", "laptop", "mouse",
"remote", "keyboard", "cell phone", "microwave", "oven",
"toaster", "sink", "refrigerator", "book", "clock",
"vase", "scissors", "teddy bear", "hair drier", "toothbrush"
]
last_fps_time = time.time()
inference_count = 0
# --------------------------------------------------
# LOAD MODEL
# --------------------------------------------------
net = cv2.dnn.readNetFromONNX(MODEL_PATH)
net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
# --------------------------------------------------
# OPEN RTSP STREAM
# --------------------------------------------------
cap = cv2.VideoCapture(RTSP_URL, cv2.CAP_FFMPEG)
if not cap.isOpened():
raise RuntimeError("Failed to open RTSP stream")
# --------------------------------------------------
# WEB UI
# --------------------------------------------------
ui = WebUI()
latest_frame = None
lock = threading.Lock()
# --------------------------------------------------
# MJPEG STREAM ROUTE
# --------------------------------------------------
@ui.app.get('/video_feed')
def video_feed():
def generate():
global latest_frame
while True:
with lock:
if latest_frame is None:
continue
ret, jpeg = cv2.imencode('.jpg', latest_frame)
if not ret:
continue
frame_bytes = jpeg.tobytes()
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' +
frame_bytes +
b'\r\n')
time.sleep(0.03)
return StreamingResponse(
generate(),
media_type='multipart/x-mixed-replace; boundary=frame'
)
# --------------------------------------------------
# HOME PAGE
# --------------------------------------------------
@ui.app.get('/')
def index():
return HTMLResponse('''
<html>
<head>
<title>RTSP YOLOv8 Stream</title>
</head>
<body>
<h2>RTSP YOLOv8 Detection</h2>
<img src="/video_feed" width="960">
</body>
</html>
''')
# --------------------------------------------------
# INFERENCE THREAD
# --------------------------------------------------
def inference_loop():
global latest_frame
global last_fps_time
global inference_count
frame_counter = 0
while True:
# ------------------------------------------
# DRAIN OLD RTSP FRAMES
# ------------------------------------------
for _ in range(3):
cap.grab()
ret, frame = cap.read()
if not ret:
print("Frame read failed")
time.sleep(1)
continue
frame_counter += 1
# ------------------------------------------
# ONLY RUN INFERENCE EVERY 3RD FRAME
# ------------------------------------------
if frame_counter % 2 != 0:
continue
print("running inference", frame_counter)
h, w = frame.shape[:2]
# ------------------------------------------
# PREPROCESS
# ------------------------------------------
blob = cv2.dnn.blobFromImage(
frame,
scalefactor=1/255.0,
size=(INPUT_SIZE, INPUT_SIZE),
swapRB=True,
crop=False
)
net.setInput(blob)
# ------------------------------------------
# RUN YOLO
# ------------------------------------------
outputs = net.forward()[0].transpose()
inference_count += 1
now = time.time()
elapsed = now - last_fps_time
if elapsed >= 1.0:
fps = inference_count / elapsed
print(f"Inference FPS: {fps:.2f}")
inference_count = 0
last_fps_time = now
boxes = []
confidences = []
class_ids = []
# ------------------------------------------
# PARSE DETECTIONS
# ------------------------------------------
for det in outputs:
x, y, bw, bh = det[:4]
class_scores = det[4:]
class_id = np.argmax(class_scores)
confidence = class_scores[class_id]
if confidence < CONF_THRESHOLD:
continue
x1 = int((x - bw / 2) * w / INPUT_SIZE)
y1 = int((y - bh / 2) * h / INPUT_SIZE)
x2 = int((x + bw / 2) * w / INPUT_SIZE)
y2 = int((y + bh / 2) * h / INPUT_SIZE)
boxes.append([x1, y1, x2 - x1, y2 - y1])
confidences.append(float(confidence))
class_ids.append(class_id)
# ------------------------------------------
# NMS
# ------------------------------------------
indices = cv2.dnn.NMSBoxes(
boxes,
confidences,
CONF_THRESHOLD,
NMS_THRESHOLD
)
# ------------------------------------------
# DRAW RESULTS
# ------------------------------------------
for i in indices:
if isinstance(i, (tuple, list, np.ndarray)):
i = i[0]
x, y, bw, bh = boxes[i]
conf = confidences[i]
class_id = class_ids[i]
label = CLASS_NAMES[class_id]
cv2.rectangle(
frame,
(x, y),
(x + bw, y + bh),
(0, 255, 0),
2
)
cv2.putText(
frame,
f"{label} {conf:.2f}",
(x, y - 5),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(0, 255, 0),
1
)
# ------------------------------------------
# PUBLISH FRAME TO WEBUI
# ------------------------------------------
with lock:
latest_frame = frame.copy()
# --------------------------------------------------
# START THREAD
# --------------------------------------------------
thread = threading.Thread(target=inference_loop, daemon=True)
thread.start()
# --------------------------------------------------
# START APP
# --------------------------------------------------
# WebUI is automatically served by AppLab.
# Default port is typically 7000.
App.run()
The result is better. The image and BB sync is good, but I noticed by watching the camera timestamp that the output was lagging behind real time. The camera is streaming at 15 FPS, but the inferencing can only achieve 1-2 FPS depending on the quality of the image and the relative size of the target. I needed to discard some of the captured frames and inferences to prevent the input buffer from overflowing. This has the downside of reducing the confidence level the of the inference, so I needed to reduce the detection threshold. It also makes it more difficult to capture fast moving targets, but when you watch the video - it is stable and real time at 1-2 FPS. I believe that the Edge Impulse implementation takes advantage of the Adreno GPU and dual ISPs, so the inference FPS should be a lot better when the VideoDetection brick is fixed to allow IP camera input.
A quick pic showing that it does detect other classes like person. I've also observed it detect dogs and bikes.

The 2GB RAM/16GB Flash version of the Uno Q started shipping in October 2025 and the 4GB/32GB in January 2026. With Uno Q there has been a paradigm shift relative to the traditional hardware/software architecture used by the Arduino UNO.
The hardware is a hybrid processor SBC with a Linux MPU and real time Zephyr MCU in the traditional Uno footprint. The software has been redesigned to support and simplify development on the new hardware architecture using an integrated development environment, Arduino App Lab, that allows you to create applications using Python scripts and Arduino IDE sketches.
AppLab has the following features:
Comparison between the Uno Q and the latest “traditional” Uno:
Key Differences: Uno Q vs. Uno R4/R3
|
Feature |
Arduino Uno Q |
Arduino Uno R4 Wi-Fi |
|
Primary Goal |
Edge AI + Linux Applications |
Modernized Low-Power DIY |
|
Processor |
Quad-Core ARM MPU + Cortex M33 MCU |
Renesas Cortex M4 MCU + ESP32 |
|
OS |
Linux (Debian) + Zephyr |
Bare Metal/RTOS |
|
Storage |
16/32 GB eMMC |
Flash Memory (internal) |
|
Power |
High (Needs USB-C PD) |
Low (Battery friendly) |
I'm not sure why this board is still named an Uno. I guess because the footprint has been maintained and there is some shield compatibility. I am not a big user of legacy shields, so I would have preferred that other functionality (CSI, DSI, host USB-C) have been added in lieu of maintaining compatibility. I also think that they could have ditched the LED matrix as the low resolution is not very useful, especially for something that will probably end up in an enclosure.
User experience
When I started the roadtest, my general expectation was that I would struggle with learning curve issues with the new hardware and the new application development environment. And I also expected that there would be issues with documentation due to being an early adopter of the platform. All these of these expectations came to pass...
This is not a beginner's board/development environment, at least not yet. The good news and bad news is that changes are occurring frequently. I expect that in another 6 months, many of the issues that I encountered will be resolved. And adapters for CSI/DSI are due within the next quarter.
So, after using the Uno Q and AppLab to develop the types of applications that I use, what are my impressions of the platform? I am not convinced that this is a good general purpose development platform. There is not enough available storage for the sandboxed/container environment that is being used. One thing that Arduino should do is move the container storage to the user partition on the 32GB version. I'm not fully convinced that the 16GB version will be popular other than for playing around. I am constantly having to prune the Docker containers to maintain available space. I guess if you're not using many Bricks or AI it won't be a problem, but in that case what's the point of using this board?
The Good
The setup and installation was straightforward and well documented.
Good set of examples available. Examples work well and use is well documented.
The Bad
Container use obfuscates file locations.
Lack of space in eMMC due to partitioning.
Developing applications outside the context of the examples can be challenging.
Bricks appear not to be fully functional relative to their API (an example is the inability to select camera input type for video detection).
MCU device libraries have not been fully updated to Zephyr (I expect this will take a long time due to the large number of older devices currently supported - this will also apply to the standalone IDE if legacy Mbed support is discontinued - Zephyr is required for Uno Q).
Pros
Cons
My roadtest blog posts