A Dual-Brain Dengue Risk Classifier with llama.cpp and Bridge RPC

This chapter wires the SLM tooling from Generative AI at the Edge into a real physical-computing application — the loop closes through hardware, not just a terminal. Qwen3.5-0.8B runs as a persistent llama-server systemd service on the Linux (MPU) side. A Python application built with the arduino-app-cli framework uses Bridge RPC to expose the SLM to an Arduino sketch on the MCU side, which reads real sensors and drives real actuators. An optional Flask endpoint exposes the same service over HTTP to other devices on the network.
The Project
Imagine you have a camera monitoring potential stagnant water sources that are suitable for mosquito larval development. A YOLO model could be trained, for example, to detect water on discarded car tires or buckets. The output of such a model would be one of the inputs to a Dengue Risk Classifier. The classifier will also have other inputs, such as temperature and humidity, which are also important conditions for mosquito larvae development. Based on those inputs, the classifier should categorize the situation by turning on external LEDS based on the risk and also providing an explanation through a webpage.

The Architecture
The worked example is a dengue risk classifier: the MCU reads temperature, humidity, and a water-presence signal from sensors; the SLM categorizes the situation as low, medium, or high risk with a one-sentence explanation; the MCU drives the RGB LEDs to match the risk level. The pattern generalizes to any application where sensors produce structured data and a language model produces a structured verdict.

Why this approach?
llama-server runs persistently as a service — it survives reboots and restarts on failure, which is what a real deployment needs instead of a terminal you have to keep open.llama-server binary speaks an OpenAI-compatible HTTP API, so the same Python code that talks to a local SLM today can fall back to a cloud LLM tomorrow with a one-line URL change.By the end of this tutorial you will have a complete UNO Q application that runs from boot, reads sensor data on the MCU, classifies it on the MPU using Qwen3.5-0.8B, drives an RGB LED based on the verdict, and exposes a /classify HTTP endpoint for off-board clients (phones, browsers, other boards).
This tutorial assumes you have completed:
llama.cpp built from source, a Qwen3.5-0.8B GGUF downloaded, and comfort running llama-server by hand and calling it from Python.Also useful, but not required for this chapter: Multimodal AI at the Edge, if you want the vision pathway too.
You’ll also want a comfortable way to edit and transfer a multi-file project (app.yaml, python/main.py, sketch/sketch.ino) — Section 2 below sets up VS Code with Remote-SSH for that. nano over SSH plus scp works too if you’d rather stay terminal-only; everything in this chapter is plain text and nothing depends on the editor.
If any of the above is unfamiliar, work through those chapters first. This one builds directly on them rather than repeating the tooling.
Visual Studio Code with the Remote-SSH extension gives you a full IDE experience — file browsing, an integrated terminal, IntelliSense, extensions — running directly against the UNO Q’s filesystem. It’s not required (everything below also works from nano and scp/rsync), but for a project with three files spread across two languages, it’s a meaningfully better workflow than juggling terminal windows.
Download and install VS Code for your OS from: https://code.visualstudio.com/
Ctrl+Shift+X / Cmd+Shift+X).><).+ or Connect to Host…arduino@<UNO_Q_IP_ADDRESS>
<Enter>. The Arduino UNO IP Address will appear under SSH.terminal + icon.![]()
VS Code will install a lightweight server component on the UNO Q. This may take a minute on the first connection.
Once connected:
/home/arduino/ArduinoApps/
You now have full file-browsing, editing, and integrated terminal access to the UNO Q.

If not opened, use Ctrl+` (backtick) to open a terminal inside VS Code. This terminal runs directly on the UNO Q, so you can execute arduino-app-cli commands, install packages, and manage your project — all from within VS Code. Every terminal command in the rest of this chapter can run here instead of a separate SSH session.
The UNO Q has limited RAM (especially the 2 GB variant). To avoid memory issues while llama-server is also running:
You can disable extensions selectively for the SSH connection without affecting your local setup: right-click the extension and choose Disable (SSH: arduino@…).
| Tool | Purpose |
|---|---|
| Debian Linux (latest image) | Base OS on the MPU |
arduino-app-cli |
Build/run dual-brain apps |
| Python 3.13 | Application code |
| SSH server | Remote access |
llama.cpp built from source, Qwen3.5-0.8B GGUF |
From chapter 2 |
| Tool | Purpose |
|---|---|
flask, requests (Python) |
HTTP endpoint + API client, installed via the app’s requirements.txt |
No further build tools are needed — build-essential, cmake, and libcurl4-openssl-dev were already installed when you built llama.cpp in chapter 2.
So far, every llama-server you’ve started (chapters 2 and 3) ran interactively, in the foreground, in a terminal you had to keep open. That’s fine for testing, but this project needs the model available continuously — surviving reboots, restarting if it crashes, with no terminal attached. That’s what a systemd service gives you.
Since the UNO Q is on the local network, binding to 0.0.0.0 exposes the SLM endpoint to any device on that LAN, which is exactly what Section 10 later does via Flask too.
sudo nano /etc/systemd/system/llama-server.service
Paste:
[Unit]
Description=llama.cpp server (Qwen3.5-0.8B on UNO Q)
After=network-online.target
[Service]
User=arduino
WorkingDirectory=/home/arduino/llama.cpp
ExecStart=/home/arduino/llama.cpp/build/bin/llama-server \
-m /home/arduino/models/Qwen_Qwen3.5-0.8B-Q8_0.gguf \
--host 0.0.0.0 --port 8081 \
-c 1024 -t 4 \
--reasoning off \
--reasoning-budget 0 \
--alias qwen3.5-0.8b
Restart=on-failure
RestartSec=10
Nice=-5
[Install]
WantedBy=multi-user.target
Note: If you moved the binaries to
~/llama-runtime/(per chapter 2’s optional disk-cleanup step), adjustWorkingDirectoryandExecStartaccordingly, and addEnvironment=LD_LIBRARY_PATH=/home/arduino/llama-runtime.
Key flags (most are unchanged from chapter 2 — the new ones are Restart, RestartSec, and Nice, which only make sense in a persistent service):
--reasoning off --reasoning-budget 0 — critical. Disables Qwen 3.5’s thinking mode, as covered in chapter 2. The server log should report thinking = 0 when this is working.-c 1024 — context length. Larger values eat KV-cache RAM; 1024 is enough for structured classification and short Q&A.--alias qwen3.5-0.8b — same stable client-facing name used throughout chapters 2 and 3.Restart=on-failure / RestartSec=10 — if the process crashes (e.g., OOM), systemd restarts it automatically after 10 seconds instead of leaving the endpoint dead.Nice=-5 gives llama-server slightly higher scheduling priority than user-space apps. Don’t go lower than -5 or you risk starving the kernel.sudo systemctl daemon-reload
sudo systemctl enable --now llama-server.service
systemctl status llama-server --no-pager
journalctl -u llama-server -f
Wait for HTTP server listening in the journal output, then Ctrl+C to exit the log tail.
sudo reboot
After the board comes back up, SSH in and check:
systemctl status llama-server --no-pager
curl -s http://127.0.0.1:8081/health
If both work, the service is solid. You now have a persistent local AI endpoint that starts on boot, restarts on failure, and serves any client on the board via HTTP — the foundation the rest of this chapter builds on.
The UNO Q’s dual-brain architecture maps naturally onto the SLM use case: real-time sensing on the MCU, AI reasoning on the MPU, with Bridge RPC as the glue between them.

Three data paths run through this architecture:
Bridge.call("classify", ...) and gets a verdict back. This is the on-board, low-latency path.localhost:8081 to run inference. Internal to the Linux side.http://<UNO_Q_IP>:7000/classify. The optional off-board path.The same classify() logic powers all three. That’s the design.
sequenceDiagram
autonumber
participant S as STM32U585 (sketch)
participant P as Python main.py
participant L as llama-server :8081
participant M as Qwen3.5-0.8B
S->>P: Bridge.call("classify",<br/>29.5, 82, true)
P->>P: build system + few-shot prompt
P->>L: POST /v1/chat/completions<br/>response_format=json_object
L->>M: tokenize prompt
loop generate
M-->>L: next token
end
L-->>P: {"risk":"high","reason":"..."}
P->>P: parse JSON, map risk → code (0/1/2)
P-->>S: 2.0 (risk code as float)
Note over S: set LED red
Create a standard UNO Q app following the structure introduced in chapter 1. The app is called risk-classifier.
risk-classifier/
├── app.yaml
├── README.md
├── python/
│ ├── main.py
│ └── requirements.txt
└── sketch/
├── sketch.ino
└── sketch.yaml
On the UNO Q:
cd ~/ArduinoApps
arduino-app-cli app new "risk-classifier"
cd risk-classifier
You can also create the project directly in VS Code with Remote-SSH (Section 2 above):

Or in the Arduino App Lab:

app.yamlname: Risk Classifier (SLM + Bridge)
description: "Dengue risk classification using a local SLM via Bridge RPC and Flask"
icon: 🦟
version: "1.0.0"
ports:
- 7000
bricks: []
The ports: [7000] line tells the App Lab runtime to forward port 7000 so external clients can reach the Flask endpoint. Without this, Flask binds inside the container only and is invisible from outside the board.
The Python side does three jobs:
classify_risk() function to the MCU sketch over Bridge RPC (returns a numeric code the sketch can use directly).python/requirements.txtUnder the python folder, create requirements.txt:
flask==3.0.3
requests==2.32.3
python/main.py"""
risk-classifier: Dengue risk classification on the UNO Q.
Two interfaces over the same classify() function:
1. Bridge RPC for the on-board MCU sketch (returns a float code).
2. Flask HTTP endpoint for off-board clients (returns the full JSON verdict).
"""
import json
import re
import socket
import struct
import threading
import time
import requests
from flask import Flask, request, jsonify
from arduino.app_utils import *
# ─── Container-aware host discovery ────────────────────────────────
# Inside the App Lab container, 127.0.0.1 is the *container's* loopback,
# not the UNO Q's. The default gateway in /proc/net/route is the host
# as seen from this container, which is where llama-server listens.
def _host_gateway():
"""Return the default gateway IP (the UNO Q host from inside the container)."""
try:
with open("/proc/net/route") as f:
for line in f.readlines()[1:]:
fields = line.strip().split()
if fields[1] == "00000000" and int(fields[3], 16) & 2:
return socket.inet_ntoa(struct.pack("<L", int(fields[2], 16)))
except OSError:
pass
return "127.0.0.1" # fallback when running outside a container
# ─── Configuration ─────────────────────────────────────────────────
LLM_HOST = _host_gateway()
LLM_URL = f"http://{LLM_HOST}:8081/v1/chat/completions"
LLM_HEALTH_URL = f"http://{LLM_HOST}:8081/health"
TIMEOUT_S = 120
FLASK_PORT = 7000
# Mapping from risk label to numeric code for the MCU
RISK_CODE = {"low": 0.0, "medium": 1.0, "high": 2.0}
# Qwen3.5 non-thinking mode parameters (from Unsloth docs)
LLM_PARAMS = {
"temperature": 0.7,
"top_p": 0.8,
"presence_penalty": 1.5,
}
SYSTEM_PROMPT = (
"You are an environmental risk classifier for dengue surveillance. "
"Given temperature (Celsius), humidity (%), and whether standing water "
"is reported, output ONLY a JSON object with two fields: "
'"risk" (one of: low, medium, high) and '
'"reason" (one short sentence, max 20 words). '
"Do not include any prose outside the JSON."
)
FEW_SHOTS = [
{"role": "user",
"content": '{"temp_c": 18.0, "humidity_pct": 40, "standing_water": false}'},
{"role": "assistant",
"content": '{"risk":"low","reason":"Cool and dry conditions with no water; Aedes mosquito activity unlikely."}'},
{"role": "user",
"content": '{"temp_c": 29.5, "humidity_pct": 82, "standing_water": true}'},
{"role": "assistant",
"content": '{"risk":"high","reason":"Warm humid conditions plus standing water create ideal Aedes breeding habitat."}'},
]
# ─── Core inference function (used by both Bridge and Flask) ───────
def strip_think_blocks(text):
"""Remove residual <think>...</think> tags that Qwen3.5 emits even with
--reasoning off. A known behavior in current llama.cpp builds."""
cleaned = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
return cleaned.strip()
def build_messages(payload):
user_msg = {"role": "user",
"content": json.dumps(payload, ensure_ascii=False)}
return [{"role": "system", "content": SYSTEM_PROMPT}, *FEW_SHOTS, user_msg]
def call_llm(messages, max_tokens=80):
body = {
"model": "qwen3.5-0.8b",
"messages": messages,
"max_tokens": max_tokens,
"response_format": {"type": "json_object"},
**LLM_PARAMS,
}
t0 = time.perf_counter()
r = requests.post(LLM_URL, json=body, timeout=TIMEOUT_S)
r.raise_for_status()
latency_ms = (time.perf_counter() - t0) * 1000
content = r.json()["choices"][0]["message"]["content"]
content = strip_think_blocks(content) # remove residual <think> tags
return content, latency_ms
def parse_verdict(content):
"""Defensive parse: tolerate stray code fences if the model adds them."""
try:
verdict = json.loads(content)
except json.JSONDecodeError:
cleaned = content.strip().lstrip("`").rstrip("`").strip()
if cleaned.startswith("json"):
cleaned = cleaned[4:].lstrip()
verdict = json.loads(cleaned)
if verdict.get("risk") not in {"low", "medium", "high"}:
raise ValueError(f"unexpected risk value: {verdict!r}")
return verdict
def classify(temp_c, humidity_pct, standing_water):
"""Inner classifier. Returns the full verdict dict.
Used by the Flask endpoint and wrapped by classify_risk() for Bridge."""
payload = {
"temp_c": float(temp_c),
"humidity_pct": float(humidity_pct),
"standing_water": bool(standing_water),
}
messages = build_messages(payload)
content, latency_ms = call_llm(messages)
try:
verdict = parse_verdict(content)
except (json.JSONDecodeError, ValueError):
# one retry with stricter parameters
content, latency_ms2 = call_llm(messages, max_tokens=60)
verdict = parse_verdict(content)
latency_ms += latency_ms2
verdict["latency_ms"] = round(latency_ms, 1)
print(f"[classify] {payload} -> {verdict}")
return verdict
def classify_risk(temp_c, humidity_pct, standing_water):
"""Bridge-facing wrapper. Returns a float code: 0=low, 1=medium, 2=high.
The Bridge serializes float cleanly in both directions; the sketch
reads the code into a float and drives the LEDs."""
verdict = classify(temp_c, humidity_pct, standing_water)
return RISK_CODE.get(verdict.get("risk", "unknown"), -1.0)
# ─── Flask app (off-board interface) ───────────────────────────────
flask_app = Flask(__name__)
@flask_app.route("/healthz", methods=["GET"])
def healthz():
try:
r = requests.get(LLM_HEALTH_URL, timeout=5)
return jsonify({"flask": "ok", "llm": r.json()}), 200
except Exception as e:
return jsonify({"flask": "ok", "llm_error": str(e)}), 503
@flask_app.route("/classify", methods=["POST"])
def classify_endpoint():
p = request.get_json(force=True)
required = {"temp_c", "humidity_pct", "standing_water"}
if not required.issubset(p):
return jsonify({"error": f"missing: {required - set(p)}"}), 400
verdict = classify(p["temp_c"], p["humidity_pct"], p["standing_water"])
return jsonify(verdict), 200
def run_flask():
# threaded=False because the model handles one request at a time anyway
flask_app.run(host="0.0.0.0", port=FLASK_PORT, threaded=False)
# ─── Main entry: register Bridge function, start Flask, run loop ──
# Expose the float-returning wrapper to the MCU sketch.
# (The sketch reads the result with rpc.result(float_var), so we MUST
# return a numeric type, not a dict.)
Bridge.provide("classify", classify_risk)
# Start Flask in a background thread
threading.Thread(target=run_flask, daemon=True).start()
print(f"[init] llama-server target: {LLM_URL}")
print(f"[init] Bridge registered, Flask on :{FLASK_PORT}")
def loop():
# The main loop is idle. All work is event-driven
# (Bridge calls from MCU, HTTP requests from Flask).
time.sleep(1)
App.run(user_loop=loop)
Design choices highlights:
classify() function. The Bridge gets a wrapper that returns a numeric code; Flask gets the full JSON verdict. Separation of concerns between interface and logic.rpc.result(), so the Bridge wrapper collapses the verdict to a float. Off-board HTTP clients get the richer JSON.response_format: json_object tells llama-server to constrain decoding to valid JSON. Not a prompt hint — a real grammar constraint on token selection.presence_penalty=1.5 is critical for Qwen3.5 Small models to prevent repetition loops, per the Unsloth recommendation.threaded=False on Flask. The model serves one request at a time; threading just queues backpressure on a board with no extra cores to spare.The sketch reads sensors (temperature, humidity, water presence), calls Bridge.call("classify", ...), and drives actuators (RGB LEDs).
Connect the sensors (DHT22 and button) and the actuators (RGB LEDs):
Red LED : D9 → LED → 220Ω → GND (high risk)
Yellow LED : D10 → LED → 220Ω → GND (medium risk)
Green LED : D11 → LED → 220Ω → GND (low risk)
DHT22:
VCC → 3.3V (NOT 5V — STM32U585 GPIO is 3.3V)
GND → GND
DATA → D2
10kΩ pull-up between DATA and VCC
Button: one leg → D3
other leg → GND

sketch/sketch.ino#include "Arduino_RouterBridge.h"
#include <DHT.h>
#define DHTPIN 2
#define DHTTYPE DHT22
const int BTN_PIN = 3;
const int LED_R = 9; // red = high risk
const int LED_Y = 10; // amber = medium risk
const int LED_G = 11; // green = low risk
DHT dht(DHTPIN, DHTTYPE);
unsigned long lastReading = 0;
const unsigned long READING_PERIOD_MS = 30000;
// ── Button state with debounce ──────────────────────────────
bool water_state = false;
int last_btn_reading = HIGH;
int btn_state = HIGH;
unsigned long last_debounce_time = 0;
const unsigned long DEBOUNCE_MS = 50;
void updateButton() {
int reading = digitalRead(BTN_PIN);
if (reading != last_btn_reading) {
last_debounce_time = millis();
}
if ((millis() - last_debounce_time) > DEBOUNCE_MS) {
if (reading != btn_state) {
btn_state = reading;
if (btn_state == LOW) { // falling edge = press
water_state = !water_state;
Serial.print("[btn] water_state -> ");
Serial.println(water_state ? "YES" : "no");
}
}
}
last_btn_reading = reading;
}
void setLEDs(bool r, bool y, bool g) {
digitalWrite(LED_R, r ? HIGH : LOW);
digitalWrite(LED_Y, y ? HIGH : LOW);
digitalWrite(LED_G, g ? HIGH : LOW);
}
// Print float as "X.YY" without depending on dtostrf or printf-float support.
void printFloat2(float v) {
if (isnan(v)) { Serial.print("nan"); return; }
if (v < 0) { Serial.print("-"); v = -v; }
int whole = (int)v;
int frac = (int)((v - whole) * 100.0f + 0.5f);
if (frac >= 100) { whole++; frac -= 100; }
Serial.print(whole);
Serial.print(".");
if (frac < 10) Serial.print("0");
Serial.print(frac);
}
void setup() {
pinMode(LED_R, OUTPUT);
pinMode(LED_Y, OUTPUT);
pinMode(LED_G, OUTPUT);
pinMode(BTN_PIN, INPUT_PULLUP);
Serial.begin(115200);
dht.begin();
Bridge.begin();
// boot blink
for (int i = 0; i < 3; i++) {
setLEDs(true, true, true); delay(150);
setLEDs(false, false, false); delay(150);
}
setLEDs(false, false, true); // green = ready
}
void loop() {
updateButton(); // poll button every loop pass
if (millis() - lastReading < READING_PERIOD_MS) return;
lastReading = millis();
float temp_c = dht.readTemperature();
float humidity_pct = dht.readHumidity();
bool standing_water = water_state;
if (isnan(temp_c) || isnan(humidity_pct)) {
Serial.println("DHT22 read failed");
setLEDs(true, true, false); // R+Y = sensor error
return;
}
Serial.print("Classifying: ");
printFloat2(temp_c); Serial.print("C, ");
printFloat2(humidity_pct); Serial.print("%, water=");
Serial.println(standing_water ? "yes" : "no");
setLEDs(true, true, true); // all three on = inference in progress
// Bridge.call() returns an RpcCall object — do NOT assign to String or use >>.
// Use .result(var) to extract the return value: returns true on success.
float risk_f = -1.0f;
RpcCall rpc = Bridge.call("classify", temp_c, humidity_pct, standing_water);
if (rpc.result(risk_f)) {
int risk_code = (int)(risk_f + 0.5f); // 0 = low, 1 = medium, 2 = high
Serial.print("[result] risk_code=");
Serial.println(risk_code);
if (risk_code == 0) setLEDs(false, false, true); // green — low
else if (risk_code == 1) setLEDs(false, true, false); // yellow — medium
else if (risk_code == 2) setLEDs(true, false, false); // red — high
else setLEDs(true, false, true); // R+G — unexpected
} else {
Serial.print("[rpc error] code=");
Serial.println(rpc.getErrorCode());
Serial.print("[rpc error] msg=");
Serial.println(rpc.getErrorMessage());
setLEDs(true, false, true); // R+G = RPC-level error
}
}
RpcCall API note: Bridge.call() returns an RpcCall object. To extract the Python function’s return value, call .result(variable) — it returns true on success and fills variable by reference. Don’t attempt direct assignment (String s = Bridge.call(...)) or the stream operator (>> variable); neither is defined for RpcCall. On failure, .getErrorCode() and .getErrorMessage() give diagnostics. The Python wrapper returns a float (0/1/2), which the Bridge serializes cleanly in both directions.
Common mistake: Bridge.call() returns a value. Forgetting to capture it (or treating the call as fire-and-forget) is a silent bug: the sketch compiles and runs, but the MCU never sees what the SLM decided. Always capture the return value and validate it.
Note: The exact
Bridge.callreturn-value access syntax depends on the version ofArduino_RouterBridge. Check the library’sexamples/directory for the version installed on your board if the lines above do not compile cleanly.
sketch/sketch.yamlThe fqbn (arduino:zephyr:uno_q) tells the compiler which board target to use. The DHT sensor library and its Adafruit Unified Sensor dependency are fetched from the Arduino Library Manager. Arduino_RPClite is the low-level transport backing Bridge RPC.
Verify the library is available before the first build:
arduino-cli lib list | grep -i dht
If nothing appears, install it manually:
arduino-cli lib install "DHT sensor library"@1.4.6
arduino-cli lib install "Adafruit Unified Sensor"@1.1.14
arduino-cli lib list | grep -i dht # confirm it's there

Next, if necessary, adapt the sketch.yaml according to the libraries:
profiles:
default:
fqbn:
platforms:
- platform: arduino:zephyr
libraries:
- DHT sensor library (1.4.6)
- dependency: Adafruit Unified Sensor (1.1.14)
- dependency: Arduino_RPClite (0.2.1)
default_profile: default
systemctl status llama-server --no-pager
curl -s http://127.0.0.1:8081/health
You should see "status":"ok".
From inside ~/ArduinoApps/risk-classifier/:
arduino-app-cli app start .
(or the [Start] button if you’re using the Arduino App Lab)
The first run takes 2–3 minutes. arduino-app-cli builds the Python container, installs Flask and requests, compiles the sketch, and flashes the MCU.
How to use it for testing:
[btn] water_state -> YES. Press again to flip it back to no. The state holds between inferences.Classifying: 25.30C, 60.20%, water=yes (or no), then all three LEDs come on for ~30 s during inference, then back to the corresponding color depending on the “risk” when it’s done.
In a second terminal (or the Arduino App Lab Python tab):
arduino-app-cli app logs . --follow
Every 30 seconds you should see lines like:
Test Condition: Ambient temperature in the lab, button not pressed ("no water").
[main] [classify] {'temp_c': 24.299999237060547, 'humidity_pct': 38.70000076293945, 'standing_water': False} -> {'risk': 'low', 'reason': 'Cool and dry temperatures with no standing water make dengue risk low.', 'latency_ms': 10772.6}
Test Condition: Pressing the sensor between fingers, button pressed ("water").
[main] [classify] {'temp_c': 27.600000381469727, 'humidity_pct': 87.80000305175781, 'standing_water': True} -> {'risk': 'high', 'reason': 'Warm humid conditions with standing water create ideal Aedes breeding habitat.', 'latency_ms': 10618.3}
Watch the RGB LEDs on the board. After each inference cycle, exactly one LED stays on: green for low risk, yellow for medium, red for high. Under ambient lab conditions (cool, dry, no water), you should see green; pressing the button and warming the sensor by hand should eventually turn it yellow or red.

arduino-app-cli app stop .
While the app is running, the Flask endpoint at port 7000 is available to any device on the same Wi-Fi network. From your host computer:
curl -X POST http://<UNO_Q_IP>:7000/classify \
-H "Content-Type: application/json" \
-d '{"temp_c": 30.1, "humidity_pct": 85, "standing_water": true}'
curl -X POST http://192.168.5.114:7000/classify \
-H "Content-Type: application/json" \
-d '{"temp_c": 30.1, "humidity_pct": 85, "standing_water": true}'
You should see:
{
"risk": "high",
"reason": "Warm humid conditions with standing water create ideal Aedes breeding habitat.",
"latency_ms": 8234.7
}
This is the same classify() function the MCU calls via Bridge — one logic path, two interfaces. From a phone, browser, or another UNO Q on the same network, the SLM is now a microservice.
Once the Flask server is running, add a GET /status endpoint that returns the latest result, and a GET / route that serves a live dashboard. Two small additions to main.py, no new dependencies needed.
python/main.py1 — Add a global to store the last reading (near the top, after FLASK_PORT):
# Latest classification result — updated on every MCU reading
_last_status = {
"risk": "unknown",
"reason": "No reading yet.",
"temp_c": None,
"humidity_pct": None,
"standing_water": None,
"latency_ms": 0,
}
2 — Update classify_risk to save the full state:
def classify_risk(temp_c, humidity_pct, standing_water):
"""Bridge-facing wrapper. Returns a float code: 0=low, 1=medium, 2=high.
Also updates _last_status so the dashboard can show the latest verdict."""
global _last_status
verdict = classify(float(temp_c), float(humidity_pct), bool(standing_water))
_last_status = {
"risk": verdict.get("risk", "unknown"),
"reason": verdict.get("reason", ""),
"temp_c": round(float(temp_c), 1),
"humidity_pct": round(float(humidity_pct), 1),
"standing_water": bool(standing_water),
"latency_ms": verdict.get("latency_ms", 0),
}
return RISK_CODE.get(verdict.get("risk", "unknown"), -1.0)
3 — Add two Flask routes (alongside the existing /classify and /healthz):
from flask import render_template_string # add to the existing flask import line
@flask_app.route("/status", methods=["GET"])
def status_endpoint():
return jsonify(_last_status), 200
@flask_app.route("/", methods=["GET"])
def dashboard():
return render_template_string(DASHBOARD_HTML)
4 — Add the dashboard HTML (paste this constant before flask_app = Flask(__name__)):
DASHBOARD_HTML = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dengue Risk Monitor · UNO Q</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0;
display: flex; flex-direction: column; align-items: center;
min-height: 100vh; padding: 2rem 1rem; }
h1 { font-size: 1.3rem; letter-spacing: .05em; color: #94a3b8; margin-bottom: 2rem; }
#card {
width: 100%; max-width: 420px; border-radius: 1.5rem;
padding: 2.5rem 2rem; text-align: center;
transition: background .6s, box-shadow .6s;
background: #1e293b; box-shadow: 0 0 0 0 transparent;
}
#card.low { background: #14532d; box-shadow: 0 0 40px 4px #22c55e55; }
#card.medium { background: #713f12; box-shadow: 0 0 40px 4px #eab30855; }
#card.high { background: #7f1d1d; box-shadow: 0 0 40px 4px #ef444455; }
#risk-label { font-size: 4rem; font-weight: 800; letter-spacing: .04em;
text-transform: uppercase; margin-bottom: .5rem; }
#reason { font-size: 1rem; color: #cbd5e1; margin-bottom: 2rem; min-height: 2.5em; }
.metrics { display: grid; grid-template-columns: 1fr 1fr 1fr;
gap: .75rem; margin-bottom: 1.5rem; }
.metric { background: #ffffff18; border-radius: .75rem; padding: .75rem .5rem; }
.metric .val { font-size: 1.4rem; font-weight: 700; }
.metric .lbl { font-size: .7rem; color: #94a3b8; text-transform: uppercase; }
#meta { font-size: .75rem; color: #64748b; }
#dot { display: inline-block; width: .5rem; height: .5rem;
border-radius: 50%; background: #64748b;
margin-right: .3rem; vertical-align: middle; }
#dot.live { background: #22c55e; animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
</style>
</head>
<body>
<h1>🦟 Dengue Risk Monitor · Arduino UNO Q</h1>
<div id="card">
<div id="risk-label">—</div>
<div id="reason">Waiting for first reading…</div>
<div class="metrics">
<div class="metric"><div class="val" id="temp">—</div><div class="lbl">°C</div></div>
<div class="metric"><div class="val" id="hum">—</div><div class="lbl">Humidity %</div></div>
<div class="metric"><div class="val" id="water">—</div><div class="lbl">Water</div></div>
</div>
<div id="meta"><span id="dot"></span><span id="ts">connecting…</span></div>
</div>
<script>
const COLORS = { low: "low", medium: "medium", high: "high" };
const EMOJI = { low: "🟢", medium: "🟡", high: "🔴", unknown: "⚪" };
async function refresh() {
try {
const r = await fetch("/status");
const d = await r.json();
const card = document.getElementById("card");
card.className = COLORS[d.risk] || "";
document.getElementById("risk-label").textContent =
(EMOJI[d.risk] || "") + " " + (d.risk || "unknown").toUpperCase();
document.getElementById("reason").textContent = d.reason || "";
document.getElementById("temp").textContent =
d.temp_c !== null ? d.temp_c.toFixed(1) : "—";
document.getElementById("hum").textContent =
d.humidity_pct !== null ? d.humidity_pct.toFixed(1) : "—";
document.getElementById("water").textContent =
d.standing_water === null ? "—" : d.standing_water ? "YES" : "no";
const dot = document.getElementById("dot");
dot.className = "live";
setTimeout(() => dot.className = "", 800);
document.getElementById("ts").textContent =
"Last reading: " + new Date().toLocaleTimeString() +
" · " + (d.latency_ms / 1000).toFixed(1) + " s inference";
} catch(e) {
document.getElementById("ts").textContent = "⚠ fetch error – retrying";
}
}
refresh();
setInterval(refresh, 5000); // poll every 5 s
</script>
</body>
</html>
"""
The complete
main.pycan be found on the project repo.
While the app is running, open a browser on any device on the same Wi-Fi network:
http://<UNO_Q_IP>:7000/
The page polls /status every 5 seconds and updates without a full reload. The card background changes color to match the risk level (green, amber, or red), matching the LED on the board.
The raw JSON endpoint is still available for other clients:
http://<UNO_Q_IP>:7000/status

Chapter 2 measured raw llama-server throughput in isolation. These numbers are for the full application — Bridge RPC, JSON parsing, and prompt-building overhead included — measured on a UNO Q 4 GB with the factory image, Qwen3.5-0.8B Q8_0, no heatsink, no fan:
| Metric | Value |
|---|---|
| Cold model load (boot of llama-server) | ~4 s |
| Idle RAM (llama-server + app running) | ~700–800 MB |
| Model + context memory usage | ~1,122 MiB |
| Prompt processing throughput | ~9.9 tokens/s |
| Generation throughput | ~4.75 tokens/s |
End-to-end classify() latency |
6–12 s |
| CPU usage during decode | 4 cores @ 100% |
| Idle board temperature (no inference) | ~34 °C |
| Temperature during normal inference | ~54 °C |
| Temperature during sustained long answers | ~62 °C |
| Thermal throttle threshold | 70–80 °C (never reached) |
| Power consuption (Max.) | 3.1 W |
Honest takeaways:
presence_penalty=1.5 compensate for most of it, but some responses are weaker than a 1B+ model would produce. Use Q8_0 or the Unsloth Dynamic quant if storage allows.--reasoning off --reasoning-budget 0.presence_penalty=1.5, the model tends to repeat phrases or produce circular responses. A known behavior of small Qwen3.5 variants, well-documented in the Unsloth guide.Token throughput vs. wall-clock latency
People often optimize for tokens/second when wall-clock latency is what actually matters. A 30-token verdict at 10 tok/s (3 s) feels twice as responsive as a 100-token verdict at 10 tok/s (10 s). Cap
max_tokensaggressively and design prompts to keep responses short.
Check the journal:
journalctl -u llama-server -n 50 --no-pager
Common causes: model file path wrong in the unit file, port 8080 already in use (sudo lsof -i :8080), or the GGUF file is incompatible with your llama.cpp version (rebuild or download newer binaries).
This almost always means thinking mode is active — see chapter 2 for why. Verify the --reasoning off --reasoning-budget 0 flags are present in your systemd service (ExecStart line), not just something you once typed in a terminal. Also check that presence_penalty is set to 1.5 in your API calls.
If you see the deprecation warning about --chat-template-kwargs, update your command line to use --reasoning off --reasoning-budget 0 instead.
If the MCU times out waiting for classify():
arduino-app-cli app logs .).Arduino_RouterBridge examples; some versions default to 5 seconds, which is less than the 6–12 s inference takes).curl http://127.0.0.1:8081/health.If the kernel OOM-killer takes out your Python container:
free -h should show a non-zero swap size.Even with response_format: json_object, occasional models output stray text. The parse_verdict function in main.py already retries once. If failures persist:
"Output MUST start with { and end with }".response_format: {"type":"json_object"} with a GBNF grammar that constrains output to exactly {"risk": "<low|medium|high>", "reason": "<string>"}.UD-Q4_K_XL), which sometimes produces cleaner output.df -h /
The root partition is ~9.8 GB total. If you’re running low:
# Check what is using space
sudo du -sh /var/lib/docker 2>/dev/null
sudo du -sh /home/arduino/* | sort -h
# Clean unused Docker images from arduino-app-cli
arduino-app-cli system cleanup
# Clean apt cache
sudo apt clean
sudo apt autoremove -y
If you deleted the llama.cpp source tree and still need to rebuild later, use a shallow clone: git clone --depth 1 https://github.com/ggml-org/llama.cpp
ports: [7000] is in app.yaml.ping <UNO_Q_IP>.0.0.0.0, not 127.0.0.1.2B Q4 is a better choice for projects, such as the dengue classifier, but 0.8B Q8 is the better choice for interactive demos.
The general rule from the empirical Qwen3.5 work is that parameter count beats quantization. A 4-bit version of Qwen3.5 27B can still be substantially stronger than Qwen3.5 9B while using nearly the same amount of memory, and the same pattern holds further down the stack. The jump from 0.8B to 2B is the largest for agent tasks and long contexts. That is where the extra parameters really show up. The Kaitchup’s broader review concludes that Q4 overall is very safe for Qwen3.5, so the 2B at Q4 keeps most of its raw capability.
On the UNO Q specifically:
| Dimension | 0.8B Q8_0 | 2B UD-Q4_K_XL |
|---|---|---|
| Model file size | ~880 MB | ~1.25 GB |
| RAM footprint (model + 1024 ctx) | ~1.1 GB | ~1.9–2.0 GB |
| Free RAM after load (out of ~3 GB usable) | ~1.7 GB | ~0.8 GB |
| Generation speed (measured / estimated) | ~4.75 tok/s | ~1.8–2.5 tok/s |
End-to-end classify() latency |
6–12 s | 15–25 s (est.) |
| Quality on structured JSON | Good with a strong few-shot | Noticeably more robust |
| Quality on free-form chat | Adequate | Meaningfully better |
| Thermal load | ~54 °C inference | ~58–62 °C inference |
Storage room left on /home/arduino |
comfortable | comfortable |
What this means in practice:
presence_penalty tricks.Two things worth trying before committing:
UD-Q4_K_XL variant of the 2B, not vanilla Q4_K_M. UD‑Q4-K‑XL outperforming other Q4 quants, while being ~8GB smaller in the Unsloth benchmarks — they upcast the sensitive tensors automatically, so you get most of Q6 quality at near-Q4 size. The same trick that helps Q4 on big models helps even more on small ones.llama-server, swapping models is a matter of editing the systemd unit’s ExecStart line, then sudo systemctl daemon-reload && sudo systemctl restart llama-server — the same swap pattern chapter 3 covers for the interactive (non-service) case.Qwen3.5 supports function-calling formats. The natural pattern on the UNO Q is to register the MCU sketch’s capabilities as tools the SLM can call: “read humidity,” “set LED color,” “trigger buzzer.” The Python side mediates: the SLM emits a tool-call, Python forwards it over Bridge to the sketch, the sketch executes, the result goes back to the SLM, which then generates a final reply.
This inverts the data flow from this chapter — instead of the MCU calling Python, the SLM (via Python) calls the MCU. Both patterns are valid; function calling is more flexible but adds an extra round trip per tool use.
Qwen3.5 has native multimodal capabilities, covered hands-on in Multimodal AI at the Edge: the same 0.8B weights used here run a vision pathway with a matching mmproj file. A natural extension of this project replaces the button (a stand-in for a water-presence sensor) with a camera and that vision pathway — the MCU triggers a capture, the vision-enabled classify() describes and judges the frame, and the same LED output logic applies unchanged.
This chapter’s classify() is a single fixed call: sensors in, one verdict out. Agentic AI at the Edge builds the next step — an agent loop where the SLM decides which tool to call, reads the result, and decides what to do next, rather than following a script you wrote. It starts hardware-free (system info, a calculator, the onboard LED and LED matrix) and then shows how this chapter’s own sensor/actuator tools plug into that same loop unchanged. For where the pattern scales beyond that, see QClaw, an on-device agentic AI assistant for the Arduino Uno Q developed by David Laurenvill, which writes, compiles, and uploads Arduino sketches; captures camera frames; drives Linux-side LEDs; reports network state; and scans I²C buses — all running entirely on the board. No internet. No API keys. No cloud.
For applications where you want a trained classifier (rather than a general-purpose SLM doing zero-shot reasoning), Edge Impulse is the production path. The UNO Q has first-class Edge Impulse support. A practical hybrid: an Edge Impulse model handles high-frequency classification, while an SLM handles rare “I’m not sure” cases that need richer reasoning.
For example, the YOLO model mentioned at the beginning of this tutorial could be trained in Edge Impulse Studio to detect standing water in tires, automatically triggering the “Water Switch” input of the Dengue Risk Classifier and thereby replacing the button used in the project.
This tutorial wrapped the SLM tooling from chapter 2 into a complete generative-AI application: llama-server running as a persistent systemd service with Qwen3.5-0.8B, a Python application that exposes it both to the on-board MCU via Bridge RPC and to off-board clients via Flask, and an Arduino sketch that drives an RGB LED based on the SLM’s verdict. We addressed the real constraints of the hardware (a single 9.8 GB partition with limited free space, 4 GB of shared RAM, and CPU-only inference on four Cortex-A53 cores) and found practical workarounds for each.
For ML System Engineering:
Technical advantages:
response_format: json_object and grammar-constrained decoding mean the SLM’s output is reliable enough to drive actuators directly.Being honest about what doesn’t work well at this scale:
presence_penalty mitigate this, but don’t eliminate it.--reasoning off --reasoning-budget 0.
The UNO Q is where generative AI becomes possible at the edge but stays bounded: small models, short outputs, batch-rate inference. Students who understand the constraints here won’t be surprised when they hit the same constraints on a real production deployment.
classify().Tutorial created for IESTI05 — Edge AI Machine Learning System Engineering, UNIFEI. Licensed under GNU General Public License 3.0.