Skip to content

Firmware (ESP-IDF)

Firmware source code lives in main/ and is built with ESP-IDF for ESP32‑S3.

Responsibilities

  • Radio telemetry (aircraft → winch)
  • Sensor readout (IAS via MS4525DO)
  • OLED UI (device-local status and (winch) last received telemetry)
  • Web server
    • HTTPS on :443 (UI + REST API)
    • HTTP on :80 redirects to HTTPS
    • Details: HTTPS

Key folders (repo-relative)

  • main/web/: HTTP server, route registration, static file serving, REST handlers
  • main/sensors/: sensor drivers + abstractions
  • main/aircraft/: aircraft configuration and runtime state
  • main/radio/: SX1262 integration
  • main/display/: OLED rendering
  • main/status/: latched hardware-error registry
  • main/proto/: telemetry protobuf + generated Nanopb sources

CI builds and releases

Forgejo Actions workflow .forgejo/workflows/firmware.yml builds on pushes to main and on tags.

Trigger OWTS_VERSION Output
Semver tag X.Y.Z (e.g. 1.0.1) that tag Short-lived workflow artifact (3 days) and a durable Forgejo Release asset OWTS_X.Y.Z.tar.gz
Push to main (no semver tag) short git SHA Short-lived workflow artifact only

The tarball includes bootloader, partition table, ota_data_initial, versioned OWTS / SPIFFS images (plus stable symlinks), docs/flash.sh, and docs/README-FLASH.md. Flash offsets follow partitions.csv (ESP32-S3, 8 MB)—see the README in the package.

Local builds can set the same define: idf.py build -DOWTS_VERSION=1.0.1. That value is also written into the ESP-IDF app descriptor (PROJECT_VER) so OTA version checks see the same string as /api/info.fw_version.

OTA update (About page)

Devices already have dual OTA app slots plus SPIFFS (partitions.csv). From the web UI About page, upload matching OWTS_<version>.bin and spiffs_<version>.bin from a release tarball (or CI artifact). The firmware streams both via POST /api/ota_upload (SPIFFS first, then app), applies a version gate (use Force to override), then reboots.

Partition layout changes still require USB erase-flash and a full image flash — HTTP OTA alone is not enough when partitions.csv changes. See docs/README-FLASH.md.

Radio (SX1262)

The Sub‑GHz link uses Semtech SX1262 via LibDriver under main/radio/. Aircraft TX bursts and winch continuous RX share the same centre frequency and TX power from NVS (/api/radio); the modem type is compile-time.

Modem backend (OWTS_RADIO_USE_LORA_BACKEND)

Defined in main/radio/owts_radio_settings.h:

Value PHY Notes
0 GFSK Default for bring-up; bitrate / RX bandwidth profiles in the same header (and optional NVS test blob for lab tuning).
1 LoRa SF, BW, CR, preamble, sync word, and CAD parameters in the same header.

Rebuild both ends (aircraft + winch) with the same setting. The application RF payload is always 1 byte length + Nanopb AircraftRadioDownlink; REST and OLED decode the same logical telemetry regardless of PHY.

Telemetry framing: full vs delta

main/proto/owts_aircraft_telemetry.proto defines AircraftRadioDownlink as a oneof:

  • full (AircraftTelemetryFull): registration, aircraft type, \(v_{min}\), \(v_{opt}\), \(v_{max}\), \(v_{current}\), speed unit (identity + static band limits).
  • delta (AircraftTelemetryDelta): identity_hash (metadata digest for registration + type) and v_current only.

Full frames repeat on a fixed schedule so the winch can resynchronise after loss:

  • OWTS_RADIO_AIRCRAFT_TX_HZ — packets per second during a burst (must divide 1000; default 2 Hz in main/owts.h).
  • OWTS_RADIO_TELEMETRY_FULL_INTERVAL_S — wall-clock seconds between mandatory full frames; period in packets is OWTS_RADIO_TELEMETRY_FULL_PERIOD_PKTS = OWTS_RADIO_TELEMETRY_FULL_INTERVAL_S * OWTS_RADIO_AIRCRAFT_TX_HZ.

Between full frames, deltas reduce airtime (important especially for LoRa).

Pre-TX channel access (mandatory)

Always enabled for SX1262 aircraft TX. Runs before protobuf encode on each slot inside an armed burst; if the check says “busy”, the firmware skips that packet, waits one nominal period, and tries again on the next slot (ESP_LOGW).

LBT (GFSK path) — listen-before-talk (energy): open a short RX window (OWTS_RADIO_LBT_RX_US, OWTS_RADIO_LBT_SETTLE_US in owts_radio_settings.h), sample instantaneous RSSI, return to standby. If RSSI is warmer (less negative dBm) than OWTS_RADIO_LBT_BUSY_RSSI_DBM, treat the channel as occupied. This is not format-specific: any in-band energy can block; change the defines for your noise floor / bench coupling.

CAD (LoRa path) — channel activity detection: hardware looks for LoRa-like on-air energy over a configured symbol count (OWTS_RADIO_LORA_CAD_* in owts_radio_settings.h, programmed in owts_radio_configure_lora). Defer only when the modem reports activity detected. CAD timeouts or driver errors fail open (transmit anyway) with a warning so a broken CAD path does not silence the link.

Logging

Typical messages: [TX] LBT defer: …, [TX] CAD defer: …, and fail-open warnings for RSSI read / CAD timeout / set_rx failures.

Aircraft SRD duty / airtime accounting

In aircraft mode, optional enforcement of the selected SRD profile’s duty_cycle_max_ppm uses per-packet on-air intervals from sx1262_set_tx through TX_DONE, summed into per-calendar-second buckets over a 3600 s sliding window (RAM only since boot); the UI/API expose this as duty_airtime_est_ppm vs duty_cycle_max_ppm_profile. LBT/CAD time is not included. Set duty_cycle_lab_override (NVS, via /api/radio) to skip the gate for bench testing.

Two device modes

The same firmware image runs in one of two modes stored in NVS:

  • aircraft: reads sensors and transmits telemetry
  • winch: receives telemetry and serves it via HTTP + UI

See the API spec for mode-specific route behavior and reboot semantics: docs/assets/api/openapi.json.

In winch mode, an optional WS2812 LED strip can be used as a speed indicator (CONFIG_OWTS_WINCH_USE_WS2812). On boot, the strip runs a quick self-test RED → BLUE → GREEN (~500 ms each). The final green state remains on during boot/WiFi setup, then the strip is turned off right before continuous RX is armed.

Same firmware image, two roles

Treat mode (aircraft / winch) as a first-class runtime concern. The frontend and any tooling should assume that some endpoints are mode-specific or may intentionally return 404.

Aircraft runtime state machine (IAS / launch / landing)

OWTS keeps aircraft runtime logic DRY by maintaining a single authoritative runtime state snapshot:

  • IAS input (real sensor or simulator)
  • unit conversion + rounding (km/h ↔ knots for thresholds and UI)
  • launch/burst arming when \(IAS \ge v_{en}\)
  • landing confirmation: after a flight, require \(IAS < v_{en}\) continuously for landing_hold_s before the device considers the aircraft "landed" again
  • OLED runtime rendering of IAS / LANDED

Implementation lives in:

  • main/aircraft/owts_aircraft_state.{h,c}

High-level flow

flowchart TD
  subgraph iasSources["IAS sources"]
    ms4525[MS4525DO task] -->|"ias_update_kmh(kmh, valid)"| aircraftState[owts_aircraft_state]
    sim[IAS simulator task] -->|"ias_update_kmh(kmh, valid)"| aircraftState
  end

  aircraftState -->|snapshot| radioTx[radio_aircraft_tx_task]
  aircraftState -->|snapshot| sensorsRezero[ms4525_auto_rezero]

  aircraftState -->|"poll: burst_active + tick + arm"| launchLoop[aircraft_state_loop_task]
  aircraftState -->|snapshot| liveApi["WebSocket /api/live"]
  launchLoop --> oled[OLED_runtime_IAS_LANDED]
  launchLoop --> radioArm["arm_burst(t_send)"]

Landing semantics (boot vs confirmed landing)

The state machine preserves the existing UI semantics:

  • On boot: treat the device as landed until OWTS observes \(IAS \ge v_{en}\) at least once. This prevents "not yet flown" from looking like a spurious in-flight state.
  • After a flight: a landing is confirmed only when:
    • a burst ended (arms the landing-hold window), and
    • \(IAS < v_{en}\) for at least landing_hold_s.
  • One TX burst per launch: while IAS stays above v_en, OWTS does not chain further t_send windows until landing_hold_s below v_en completes (launch_tx_used in owts_aircraft_state).

Live IAS snapshot (GET /api/live WebSocket)

In aircraft mode the public /api/live socket pushes the same runtime snapshot as the OLED (no NVS read on every update beyond the state task’s periodic config refresh):

  • ias / ias_valid / speed_unit
  • is_landed, flight_phase (landed | airborne | landing_hold)
  • landing_hold_elapsed_s / landing_hold_s during landing_hold

A frame is sent when that snapshot changes, and at most every 200 ms. During landing_hold, elapsed whole seconds are part of the change check so clients see the countdown advance even when IAS stays at 0. Winch mode uses the same URI and pushes the winch telemetry object instead.

The browser sends a text frame {"type":"ping"} at least every 5 s. The handler ignores it and returns; snapshots are queued from an esp_timer.

OpenAPI: /api/live.

Hardware error registry

main/status/owts_hw_errors.c keeps a small RAM table. Each code latches once (pressure_sensor, display, wifi, radio, winch_led). Later retries and a successful start do not clear a latch. The table is lost on reboot, so the next boot can latch the same code again.

GET /api/hw/errors is public. The response ETag is W/"<revision>"; a matching If-None-Match returns 304. DELETE /api/hw/errors clears one code query value, or every latched code when code is omitted. DELETE needs the bearer token when an API password is set.

IAS simulator (debug builds)

For development and demo purposes (e.g. exercising the launch/landing FSM without a pitot sensor), the firmware exposes a volatile IAS simulator:

  • Volatile: state is not persisted in NVS; after reboot the simulator is off by default.
  • Aircraft mode only: the REST endpoints are registered only when the device is in aircraft mode.
  • Secured: mutating calls follow the standard API auth rules (Bearer token required only when configured).

REST API

  • GET /api/aircraft/sim → { "enabled": true | false }
  • PUT /api/aircraft/sim → enable simulator (empty body)
  • PATCH /api/aircraft/sim → enable simulator (empty body; alias of PUT)
  • DELETE /api/aircraft/sim → disable simulator (empty body)

Frontend visibility

The web UI shows simulator controls only on debug firmware builds:

  • Device is in aircraft mode, and
  • /api/info.fw_version contains the substring DEBUG (case-insensitive)