Skip to main content

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's Restart=always relaunches 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 to message_router.
  • logger.py — configures the root logger once, at startup.
  • cli.pyrun is the in-process foreground agent (what systemd's ExecStart= invokes); status/start/stop/restart/logs are a separate concern — they operate on the systemd service itself via service_cli.py, so "start" never means two different things.
  • installer.py / uninstaller.py / service_unit.py / system_checks.py — turn pip install jetstan-agent into a managed, hardened systemd service. See INSTALL.md for the full flow and design rationale.
  • __main__.py — enables python -m jetstan_agent (defaults to run).

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 — one ArduinoUpdateError subclass per failure mode (board missing, permission denied, timeout, verification mismatch, ...), so callers/logs are specific about what went wrong.
  • config.pyArduinoConfig.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 .hex file and persists the last-flashed fingerprint (atomic write, same pattern as installer.py).
  • flasher.py — an abstract BoardFlasher interface plus AVRDudeFlasher. Adding STM32/ESP32/RP2040 support later means adding another BoardFlasher subclass and a FLASHERS entry — nothing in the OTA pipeline changes.
  • verifier.py — runs the board-specific verify step and turns a mismatch into a VerificationError.
  • __init__.pycheck_and_flash() (raises on failure) and sync_firmware_safe() (never raises — what jetstan_agent/cli.py actually 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 its sample_firmware/README.md for 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

  1. Run the agent as shown above and leave it running.

  2. From the HiveMQ Cloud console (or any MQTT client, e.g. mosquitto_pub), publish a message to jetstan/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"}'
    
  3. Confirm the agent's log output prints the received message.

Run the test suite

pytest

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

jetstan_agent-0.1.15-py3-none-any.whl (48.9 kB view details)

Uploaded Python 3

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

Hashes for jetstan_agent-0.1.15-py3-none-any.whl
Algorithm Hash digest
SHA256 05ab97ce05ba627339705ed5f058286f4f42271986e883514b6522ef24826d30
MD5 9a6df24d41caa9caad55e1ee9695e6ff
BLAKE2b-256 668a81c217b56ee3a1f46ecd49bfad8f1d789896b8d232492ef32a714e789cae

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page