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
:80redirects to HTTPS - Details: HTTPS
- HTTPS on
Key folders (repo-relative)
main/web/: HTTP server, route registration, static file serving, REST handlersmain/sensors/: sensor drivers + abstractionsmain/aircraft/: aircraft configuration and runtime statemain/radio/: SX1262 integrationmain/display/: OLED renderingmain/status/: latched hardware-error registrymain/proto/: telemetry protobuf + generated Nanopb sources- See Protobuf (Nanopb) README for the regenerate workflow.
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) andv_currentonly.
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 inmain/owts.h).OWTS_RADIO_TELEMETRY_FULL_INTERVAL_S— wall-clock seconds between mandatory full frames; period in packets isOWTS_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_sbefore 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 furthert_sendwindows untillanding_hold_sbelowv_encompletes (launch_tx_usedinowts_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_unitis_landed,flight_phase(landed|airborne|landing_hold)landing_hold_elapsed_s/landing_hold_sduringlanding_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
aircraftmode. - 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
aircraftmode, and /api/info.fw_versioncontains the substringDEBUG(case-insensitive)