Production-grade OTA agent for Linux-based vehicles — MQTT communication backbone.
Project description
Jetstan Agent
A production-grade OTA agent for Linux-based vehicles. It currently runs on a Raspberry Pi, but the architecture is designed to move to automotive Linux hardware without major changes.
It connects securely to HiveMQ Cloud, subscribes to the update topic,
routes incoming commands, and — for ota.available — upgrades itself
via pip and restarts. See INSTALL.md for running it
as a real systemd service (boot-start, crash/hang recovery, the OTA
update loop).
Project layout
jetstan_agent/
__init__.py Package version.
__main__.py Enables `python -m jetstan_agent`.
cli.py argparse entrypoint (`jetstan-agent` console script).
config.py All tunable values and environment-variable loading.
mqtt.py TLS connection, subscribe, publish, auto-reconnect.
agent.py Wires MQTT to message routing; keeps the process alive.
message_router.py Parses JSON commands and dispatches by "type".
updater.py Self-upgrade via pip + restart-to-apply.
sd_notify.py Minimal systemd readiness/watchdog notifications.
logger.py Central logging configuration.
system_checks.py Read-only root/systemd/distro checks for the installer.
service_unit.py Renders the packaged systemd unit template.
service_cli.py systemctl/journalctl wrappers.
installer.py `jetstan-agent-install` entrypoint.
uninstaller.py `jetstan-agent-uninstall` entrypoint.
templates/ Packaged systemd unit + default agent.env.
tests/
(one test module per file above)
Why each file exists
config.py— the single source of truth for connection settings. Non-secret values (port, keepalive, topic) have defaults; the broker host, username, and password have none and must come from environment variables, so credentials never live in source control.mqtt.py— everything specific to talking to the broker (TLS handshake, subscribe, reconnect, systemd readiness/watchdog signaling). It knows nothing about OTA — it only hands raw(topic, payload)pairs to a callback.message_router.py— parses each payload as JSON and dispatches by"type". Unknown types and invalid JSON are logged and swallowed, never raised — a single bad message can't crash the agent.updater.py— the actual OTA logic:pip install --upgrade jetstan-agent==<version>, then a deliberate process exit so systemd'sRestart=alwaysrelaunches into the newly installed code. No custom download/signing pipeline — PyPI already does atomic package installs.agent.py— the composition root. It owns the MQTT client and hands incoming payloads tomessage_router.logger.py— configures the root logger once, at startup.cli.py—runis the in-process foreground agent (what systemd'sExecStart=invokes);status/start/stop/restart/logsare a separate concern — they operate on the systemd service itself viaservice_cli.py, so "start" never means two different things.installer.py/uninstaller.py/service_unit.py/system_checks.py— turnpip install jetstan-agentinto a managed, hardened systemd service. See INSTALL.md for the full flow and design rationale.__main__.py— enablespython -m jetstan_agent(defaults torun).
Configuration
All configuration is via environment variables (read by config.py).
| Variable | Required | Default |
|---|---|---|
MQTT_HOST |
Yes | — |
MQTT_USERNAME |
Yes | — |
MQTT_PASSWORD |
Yes | — |
MQTT_PORT |
No | 8883 |
MQTT_TOPIC |
No | jetstan/demo/update |
JETSTAN_MQTT_CLIENT_ID |
No | jetstan-agent |
JETSTAN_LOG_LEVEL |
No | INFO |
In production (systemd on the vehicle, CI, etc.) set these as real
environment variables — e.g. a systemd unit's EnvironmentFile= —
never hardcode them in a file that gets committed.
Local development: .env file
For local development, config.py automatically loads a .env file
from the project root (via python-dotenv) before reading any
configuration — no manual export needed, and no code changes.
cp .env.example .env
# then edit .env and fill in MQTT_HOST / MQTT_USERNAME / MQTT_PASSWORD
.env is git-ignored, so credentials never get committed. If a real
environment variable is already set, it takes precedence over .env —
so this has no effect on production deployments where .env won't
even exist.
Arduino firmware updates
software/arduino_updates/ extends the OTA pipeline to also flash an
Arduino connected to the Pi over USB, as one more step of every agent
startup (which includes right after every self-update, since that ends
in a restart):
Agent update finishes -> Check firmware -> Flash Arduino (if changed) -> Verify -> Continue startup
It's opt-in and off by default. Add to /etc/jetstan/agent.env (or the
local .env for development):
| Variable | Required | Default |
|---|---|---|
ARDUINO_ENABLED |
No | false |
ARDUINO_BOARD |
No | uno (also: nano, mega2560) |
ARDUINO_PORT |
No | auto (or an explicit /dev/tty...) |
ARDUINO_BAUD |
No | 115200 |
ARDUINO_FIRMWARE_PATH |
No | /etc/jetstan/firmware.hex |
ARDUINO_STATE_DIR |
No | /var/lib/jetstan |
ARDUINO_FLASH_RETRIES |
No | 3 |
ARDUINO_FLASH_TIMEOUT_SECONDS |
No | 60 |
Requires avrdude on the Pi (sudo apt install avrdude) and the service
account in the dialout group — the installer handles the group
membership automatically; see INSTALL.md.
How it decides whether to flash: the firmware at ARDUINO_FIRMWARE_PATH
is fingerprinted (SHA-256 of its bytes), and that fingerprint is compared
against the last one successfully flashed (persisted under
ARDUINO_STATE_DIR) — so redeploying the same firmware.hex is always a
no-op, and any real change is always picked up, with no manually-tracked
version number to forget to bump.
Module layout, matching the rest of this repo's one-file-one-concern style:
exceptions.py— oneArduinoUpdateErrorsubclass per failure mode (board missing, permission denied, timeout, verification mismatch, ...), so callers/logs are specific about what went wrong.config.py—ArduinoConfig.from_env(), plus the board-name -> avrdude-parameters registry (BoardProfile).discovery.py— finds/dev/ttyUSB*//dev/ttyACM*device nodes and resolves which one to use; refuses to guess if there's more than one and none is configured explicitly.firmware.py— content-fingerprints a.hexfile and persists the last-flashed fingerprint (atomic write, same pattern asinstaller.py).flasher.py— an abstractBoardFlasherinterface plusAVRDudeFlasher. Adding STM32/ESP32/RP2040 support later means adding anotherBoardFlashersubclass and aFLASHERSentry — nothing in the OTA pipeline changes.verifier.py— runs the board-specific verify step and turns a mismatch into aVerificationError.__init__.py—check_and_flash()(raises on failure) andsync_firmware_safe()(never raises — whatjetstan_agent/cli.pyactually calls before starting the MQTT loop).sample_firmware/motor_test/— a minimal sketch (drives a motor driver's RPWM/LPWM pins at ~30% duty cycle from boot, nothing else) for validating the flashing pipeline end-to-end without needing real application firmware yet. See itssample_firmware/README.mdfor how to compile it.
Install locally (editable)
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
Run
Either export the variables directly:
export MQTT_HOST="xxxxxxxx.s1.eu.hivemq.cloud"
export MQTT_USERNAME="your-username"
export MQTT_PASSWORD="your-password"
or, for local development, create a .env file as shown above and skip
the export lines entirely. Then:
python -m jetstan_agent
# or, equivalently:
jetstan-agent run
On startup the agent connects over TLS, subscribes to
jetstan/demo/update, and logs every message it receives. If the
connection drops (or the broker is briefly unreachable), it reconnects
automatically with backoff — no restart needed.
Test connectivity
-
Run the agent as shown above and leave it running.
-
From the HiveMQ Cloud console (or any MQTT client, e.g.
mosquitto_pub), publish a message tojetstan/demo/update:mosquitto_pub -h <your-cluster-host> -p 8883 --cafile /etc/ssl/certs/ca-certificates.crt \ -u <username> -P <password> -t jetstan/demo/update -m '{"version": "1.0.1"}'
-
Confirm the agent's log output prints the received message.
Run the test suite
pytest
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file jetstan_agent-0.1.15-py3-none-any.whl.
File metadata
- Download URL: jetstan_agent-0.1.15-py3-none-any.whl
- Upload date:
- Size: 48.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
05ab97ce05ba627339705ed5f058286f4f42271986e883514b6522ef24826d30
|
|
| MD5 |
9a6df24d41caa9caad55e1ee9695e6ff
|
|
| BLAKE2b-256 |
668a81c217b56ee3a1f46ecd49bfad8f1d789896b8d232492ef32a714e789cae
|