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/proto/: telemetry protobuf + generated Nanopb sources- See Protobuf (Nanopb) README for the regenerate workflow.
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["GET /api/aircraft/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 REST snapshot (GET /api/aircraft/live)
Registered only in aircraft mode. Returns the same runtime snapshot as the OLED (no NVS read on every poll 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
Supports ETag + If-None-Match (304). During landing_hold, elapsed whole
seconds are part of the ETag so clients see the countdown advance even when IAS
stays at 0.
Handler: aircraft_live_get_handler in main/web/owts_web.c. OpenAPI:
/api/aircraft/live.
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)