MQTT-based services for an NVIDIA Jetson Orin Nano: a bridge to a local Ollama LLM server, and a system telemetry publisher.
Contents: mqtt_llm.py · mqtt_telemetry.py · Running checks locally · Deploying as systemd services · Reference · Design rationale
Bridges MQTT to a local Ollama server: subscribes to a request topic, forwards each request to Ollama's /api/generate, streams the generated response back as a single MQTT reply, and publishes an online/offline presence status.
python3 mqtt_llm.py --broker 192.168.1.10 --topic orin/ollamaRequires an MQTT broker reachable at --broker and an Ollama server reachable at --ollama-host/--ollama-port (default localhost:11434). Full CLI, MQTT topics, and payload formats: see Reference.
Publishes Jetson system telemetry (CPU/GPU load, RAM, temperatures, fan, power) to MQTT on a fixed interval, via jetson-stats. Only runs on actual Jetson hardware with the jetson_stats service active.
python3 mqtt_telemetry.py --broker 192.168.1.10 --topic orin/status --interval 10Requires uv. uv sync sets up everything else, including a matching Python - no Jetson hardware needed, the test suite fakes out the Jetson-only parts.
./scripts/ci.sh # everything CI runs: lint + type check + tests
./scripts/lint.sh # ruff check
./scripts/typecheck.sh # mypy
./scripts/test.sh # pytestThe integration tests spin up a real local Mosquitto broker as a subprocess, so the mosquitto binary needs to be on PATH (e.g. apt install mosquitto); scripts/test.sh warns if it's missing.
systemd/ has unit files and an install script for running both scripts as systemd services on the Orin Nano itself:
sudo systemd/install.sh --broker 192.168.1.10 --username orin-mqtt --password 'the-mqtt-password'Run from a clone of this repo on the device itself (it installs from its own working copy, not from the network). This creates a dedicated orin-mqtt system user (no login, no home directory), adds it to the jtop group (required for mqtt_telemetry.py to reach jtop.sock — see jtop lifecycle), copies both scripts to /opt/orin-mqtt, writes --password into /etc/orin-mqtt/mqtt_password (root-only, mode 600), installs orin-mqtt-telemetry.service / orin-mqtt-llm.service into /etc/systemd/system with --broker/--username substituted in, then runs systemctl daemon-reload, enable, and start for both units. It's safe to re-run — re-running with new values doubles as how you rotate the broker host/username/password later. --password on the command line ends up in your shell history and briefly in ps output for other local users; fine for a single-user device behind a router, but clear your shell history afterwards if that matters to you.
Both units run as the unprivileged orin-mqtt user with a baseline of systemd sandboxing (NoNewPrivileges, ProtectSystem=full, ProtectHome, etc.) and Restart=always, so a genuinely unexpected crash still gets a systemd restart as the last-resort fallback - the normal case of a dependency outage (Ollama or jtop bouncing) is handled in-process by each script instead, without needing a restart. The telemetry unit orders itself after jtop.service (jetson-stats' own background daemon) and the LLM unit after ollama.service (Ollama's default systemd unit) - both are just ordering hints, not hard requirements, since each script already retries a dependency outage on its own.
Useful commands once installed (easy to forget, worth having on hand):
# check status
sudo systemctl status orin-mqtt-telemetry.service
sudo systemctl status orin-mqtt-llm.service
# follow logs
sudo journalctl -u orin-mqtt-telemetry.service -f
sudo journalctl -u orin-mqtt-llm.service -fmqtt_llm.py CLI options
| Flag | Default | Description |
|---|---|---|
--broker (required) |
– | MQTT broker IP or hostname |
--port |
1883 |
MQTT broker port |
--username |
– | MQTT username |
--credpath |
– | Path to a file holding the MQTT password (e.g. systemd LoadCredential=) |
--topic |
orin/ollama |
Base MQTT topic; see topics below |
--ollama-host |
localhost |
Ollama server host |
--ollama-port |
11434 |
Ollama server port |
--connect-timeout |
5.0 |
Max seconds to establish the Ollama TCP connection |
--stream-timeout |
120.0 |
Max seconds of silence between streamed chunks before giving up |
--shutdown-timeout |
5.0 |
Max seconds to wait for in-flight work to finish on shutdown |
--interval |
10 |
Status message publish interval in seconds |
--loglevel |
INFO |
DEBUG / INFO / WARNING / ERROR / CRITICAL |
mqtt_llm.py MQTT topics and payloads
-
{topic}/request(subscribed, qos 1) - a request:{"request_id": "abc123", "model": "llama3", "system": "You are helpful.", "user": "Say hi", "temperature": 0.7, "top_p": null, "top_k": null}request_id,model,system,userare required;temperature/top_p/top_kare optional. -
{topic}/response(published, qos 1) - the reply:{"request_id": "abc123", "message": "Hi there!", "error_code": 0, "error_message": "OK"}error_code:0OK,1invalid JSON,2schema validation error,3Ollama error. -
{topic}/status(published, qos 1, retained) - presence plus the current request queue:{ "status": "online", "heartbeat": 1735689600, "queue": [ {"request_id": "abc123", "response_chars": -1, "thinking_chars": -1}, {"request_id": "def456", "response_chars": 0, "thinking_chars": -1}, {"request_id": "ghi789", "response_chars": 42, "thinking_chars": 17} ] }queuelists every request not yet fully processed, in the order it will be (or is being) processed. Each entry'sresponse_chars/thinking_charstrack that request's own generation progress independently (a "thinking" phase and a final-response phase can advance separately for reasoning models).response_charsis-1queued but not yet started,0started but no response text generated yet, otherwise the response character count so far.thinking_charsfollows the same-1/0/count shape, but since not every model streams a "thinking" field at all,-1also covers "no thinking activity observed for this request yet" - it only leaves-1once Ollama actually sends one (even an empty one, which reads as0), and simply stays-1for the whole request if the model never does.heartbeatisint(time.time())at publish time, so a subscriber can tell a retained message apart from a live one.Published on the same schedule as
mqtt_telemetry.py's telemetry (every--intervalseconds), plus immediately whenever a request is enqueued or finishes (successfully, with an error, or cancelled by shutdown) - so a requestor can see theirrequest_idland in the queue right away, or notice it disappear again without a normal{topic}/responsereply (e.g. an error that pulled it back out before it ever ran).When offline (either the MQTT Last Will firing on an ungraceful exit, or the explicit publish during a clean shutdown),
heartbeatandqueuearenull-statusis the only field guaranteed meaningful at that point. A clean shutdown also publishes this offline payload explicitly, since a graceful disconnect cancels LWT delivery.
mqtt_telemetry.py CLI options
| Flag | Default | Description |
|---|---|---|
--broker (required) |
– | MQTT broker IP or hostname |
--port |
1883 |
MQTT broker port |
--username |
– | MQTT username |
--credpath |
– | Path to a file holding the MQTT password (e.g. systemd LoadCredential=) |
--topic |
orin/status |
MQTT topic telemetry is published to |
--interval |
10 |
Publish interval in seconds |
--loglevel |
INFO |
DEBUG / INFO / WARNING / ERROR / CRITICAL |
mqtt_telemetry.py MQTT topic and payload
Published to {topic} (qos 1) on every interval, and once more (qos 1, retained) with heartbeat: 0, status: "offline" on shutdown - also the MQTT Last Will, for the same ungraceful-exit reason as mqtt_llm.py.
{
"heartbeat": 1735689600, "status": "online",
"uptime_s": 3725, "cpu_avg": 5.5, "cpu_max": 8.0,
"ram_used_ratio": 0.35, "swap_used_ratio": 0.0, "gpu_load": 12.5,
"fan_pwm": 60, "fan_rpm": 1500,
"temp_max": 45.0, "temp_cpu": 45.0, "temp_gpu": 44.0,
"power_total": 4.2
}Any field is null if the corresponding sensor/stat isn't available. If jtop itself is unavailable (e.g. the jetson_stats service restarting for an update), that's reported the same way, live on {topic} with status: "offline" and every sensor field null - but with a real, current heartbeat, distinguishing "process alive, sensors degraded" from the heartbeat: 0 shutdown/LWT message. The service retries in the background on its own (starting at 5s, backing off to a 60s cap the longer the outage lasts, resetting once jtop is reachable again) and flips back to status: "online" automatically - no restart needed.
Repository layout
mqtt_llm.py,mqtt_telemetry.py- the two services, each a single standalone scripttests/- pytest suite: a real local Mosquitto broker, a fake Ollama HTTP server, and a fakedjtopmodule (seetests/conftest.py)scripts/- local entry points for the same checks CI runssystemd/- unit files and an install script for running both services on the Orin Nano itself (see Deploying as systemd services).github/workflows/ci.yml- CI:mypy,ruff check,pytest, each also runnable viascripts/pyproject.toml- single source of truth for dependencies (uv) and tool config (ruff,mypy,pytest)
The "why" behind non-obvious code, kept out of the way of the top-level flow in both scripts - each links back here from a short pointer comment. See also CLAUDE.md, which carries the same knowledge organized around what future edits need to preserve.
Connection and shutdown races
client.connect() only sends the CONNECT packet - the CONNACK that flips is_connected() to True arrives asynchronously on the network loop thread. wait_for_mqtt_connection() blocks on an Event until that CONNACK lands (or shutdown is requested) before anything assumes a live connection. Without it, a shutdown signal arriving between connect() and the CONNACK could see is_connected() return False (skipping the explicit offline publish), only for the CONNACK to land moments later and let the eventual disconnect() go out "clean" - which cancels Last Will delivery too. Net effect: no offline announcement at all. handle_exit() also sets the same event so a shutdown request wakes this wait immediately rather than waiting out the log interval.
The shutdown sequence itself, once triggered, runs once and in order: drain the request queue, interrupt any in-flight Ollama calls (see below), join the worker thread with a timeout, publish the offline status explicitly (a graceful disconnect() cancels the Last Will, so a clean exit needs its own offline publish), then stop the network loop and disconnect. Both the worker and status-publisher threads are daemons, so a stuck thread past its join timeout only delays shutdown, never blocks it forever - the OS reclaims it on process exit.
ActiveGeneration exists to make that "interrupt in-flight calls" step possible. http.client's getresponse() closes and nulls HTTPConnection.sock as soon as it reads a response with no Content-Length (true for our streamed NDJSON responses) - effectively handing the live socket to the response object instead of leaving it on the connection. So conn.sock can't be relied on once streaming has started. ActiveGeneration captures the raw socket reference right after connect(), before getresponse() can null it, and calls shutdown(SOCK_RDWR) on it directly during teardown - the underlying socket object stays valid even though conn's reference to it is gone, and shutdown() still interrupts a thread blocked reading it.
Registering a call in active_responses and checking shutdown_event happen under the same active_responses_lock acquisition in stream_ollama_generate. That pairing closes a register-vs-shutdown race: without it, a request could be dequeued just as shutdown begins, register itself a moment after the shutdown block already took its snapshot of active_responses, and then run uninterrupted to completion.
request_id is not a key
request_id is caller-supplied, and pydantic only validates it as str - not for uniqueness or non-emptiness. Two, or two hundred, queued requests can legitimately share one request_id, or all use "". Nothing internal may use it as a lookup key.
queue_status is keyed instead by _queue_seq_counter, a private, always-unique counter assigned per request at enqueue time (seq in on_message/stream_ollama_generate/ollama_worker). Each entry's own request_id field is carried purely to report back to subscribers, never as an identity. This was a real, reported bug before the fix: duplicate-request_id requests used to collapse onto one queue_status entry.
Insertion order into queue_status also doubles as the queue's processing order (it's a plain dict, and request_queue is FIFO), so no separate structure is needed to answer "what order will these process in".
response_chars / thinking_chars semantics
Each queue entry tracks its own response_chars and thinking_chars, because a "thinking" phase and a final-response phase can advance independently for reasoning models. Both follow the same shape: -1 queued but not started, 0 started but nothing generated yet, otherwise the running character count.
thinking_chars's -1 carries a second meaning beyond "queued": "no thinking activity observed yet". Not every model streams a thinking field at all, so it only leaves -1 once stream_ollama_generate actually sees a "thinking" key in a chunk (even an empty one, which then reads as 0) - for a model that never sends one, it simply stays -1 for the whole request. This was also a real, reported bug: it used to default to 0 alongside response_chars the moment a request started, even for models that never stream a thinking field.
publish_status() wraps snapshotting queue_status and publishing as one atomic unit under status_publish_lock, across every thread that calls it (on_message, ollama_worker, status_publisher). Without it, two concurrent publishes could interleave so a stale, smaller snapshot reaches the broker after a fresher, larger one and overwrites it.
Ollama stream parsing robustness
Two defensive checks in stream_ollama_generate guard against a misbehaving Ollama server rather than a well-formed one: a chunk that decodes to JSON but isn't an object is rejected explicitly (it would otherwise fail confusingly inside chunk.get(...)), and a connection that ends - closes, resets, hits EOF - without a final "done" chunk is treated as an error, not a complete response. Without the second check, a caller could easily mistake a truncated stream for a finished one.
Connection race (telemetry service)
Same underlying issue as mqtt_llm.py's connection race (see above): client.connect() only sends the CONNECT packet, and the CONNACK that flips is_connected() to True lands asynchronously. wait_for_mqtt_connection() blocks on it before jtop() is ever started, so a shutdown signal arriving early can't slip through the same "looks connected but the Last Will already fired" gap. paho's own reconnect_delay_set backoff is retrying in the background regardless; this just waits for it to succeed (or a shutdown signal to interrupt the wait), logging periodically so a persistent outage stays visible.
jtop lifecycle: reads, staleness, and slow closes
jetson.stats/.fan are plain in-memory reads with zero I/O - they cannot hang, but they also never raise for a lost connection. jtop's background reader thread catches that internally and only surfaces it if the caller calls jetson.ok(). publish_telemetry() calls jetson.ok(spin=True) specifically for this; without it, a mid-run outage would silently return stale data forever, with no error and no retry. spin=True skips jtop's normal blocking wait for a fresh sample, since the publish loop already paces itself via --interval.
jtop.__exit__ does not stop the background thread or close the connection - close() does, but close() joins an internal thread that shells out to dpkg -l/nvcc/opencv_version with no timeout of its own, and dpkg can be locked for the length of a whole system update - exactly the scenario the retry loop exists to survive. So close() is never called synchronously; _close_jtop_in_background() runs it in its own daemon thread, best-effort, so a stuck close only delays that one instance's cleanup, never recovery or shutdown. Don't "simplify" this back to an inline jetsonTop.close() call.
jtop() failing to open and jtop breaking mid-run are handled identically in the main loop: discard whatever jtop state exists and retry a fresh jtop() instance after a backoff delay (JTOP_RETRY_DELAY_MIN doubling to JTOP_RETRY_DELAY_MAX, reset once an open succeeds) - a fresh instance isn't free (see above), so backing off avoids piling up retries for the length of an outage. SystemExit, raised by handle_exit() on SIGTERM/SIGINT, is not an Exception subclass, so it always passes through this retry loop untouched, including while blocked in the time.sleep() between attempts.
A jtop outage is reported on the telemetry topic itself - real, current heartbeat, every sensor field null, status: "offline" - distinct from the heartbeat: 0 message that's the MQTT Last Will and the explicit clean-shutdown publish. The former means "process alive, sensors degraded, watch it recover"; the latter means "the process itself is gone."