
How I built a drone failsafe monitor in Rust: one no_std core, a simulator and a browser demo
I built an advisory Rust failsafe supervisor with a no_std core, a TOML simulator and a browser demo. This tutorial walks through the decision pipeline, energy heuristic and the limits of the synthetic tests.
I built rust-drone-failsafe as an educational, advisory portfolio project that recommends responses to drone telemetry. It is not flight-tested, not certified, and uses synthetic telemetry. The useful part is a small Rust supervisor shared by a simulator and a browser demo.
Image: rust-drone-failsafe repo.
The browser demo runs the same simulation library compiled to WebAssembly. You can cut links, remove GPS, inject an estimator fault and change battery drain.
Start with a narrow job
I kept the supervisor focused on one question: given the current evidence, what action should it recommend? It returns an action, a reason, active requests and diagnostics. It never sends aircraft commands.
My related C++20 quadrotor flight controller tutorial covers the control loops; this one covers supervision. That boundary lets me study timing and competing faults without pretending to model a complete autopilot.
Keep the core small
I used no_std for the Cortex-M4F target, thumbv7em-none-eabihf. Fixed arrays and optional timestamps keep the core heap-free. The core has only libm as a normal dependency, with no serde or allocation layer. Serialization belongs at the edges.
The source starts with an explicit set of restrictions:
crates/failsafe-core/src/lib.rs:
#![no_std]
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![deny(clippy::panic)]
These are useful constraints, but their scope matters. The heap-free and unsafe-free claims apply to failsafe-core source. They do not describe the browser, dependency internals or every crate. Clippy denying unwrap, expect and panic also does not constitute a proof that every public call is panic-free.
I split the repository into four crates and a static page. failsafe-core holds geometry, validation, energy, triggers and arbitration. failsafe-sim adds TOML scenarios, a point-mass model and CSV output. failsafe-mavlink maps received frames into core telemetry. failsafe-wasm wraps the simulation library, while web/ provides controls and drawing.
Make each tick readable
The normal armed path in Supervisor::step has a fixed order. I validate the clock and inputs, glitch-filter position and compute position_valid, update debounced triggers, select the maximum action rank, apply persistence, recovery and latches, recheck feasibility, then recompute disarm_eligible.
A fresh explicit disarm is an early reset path. It clears trigger and arbiter state and returns Nominal. Missing or stale armed evidence does not take that shortcut.
Validation turns rejected fields into missing data and raises InvalidInput. Position checks include finite coordinates and velocities, a local coordinate envelope and a speed limit. Passing validation does not mean the aircraft is inside its geofence; outside positions must remain available for breach detection.
The glitch filter compares movement with the last accepted sample. Its allowance uses maximum speed, elapsed time and a fixed slack. Rejection makes position unavailable for that tick. After three consecutive rejections, or a gap exceeding two seconds, it permits reacquisition.
position_valid requires a fresh filtered position and fresh healthy EKF evidence. It is immediate, without debounce. GPS fix quality has separate triggers, so a GPS fault and invalid position are related but distinct inputs.
Requests and recovery
Triggers cover GCS and RC links, GPS, estimator health, fence and altitude limits, battery data and energy margin. They use timestamped set and clear conditions. I kept those calculations separate from the decision about which action wins.
Action rank runs from Nominal through Warn, Hold and ReturnToLaunch to Land. The maximum rank wins; a separate priority table chooses the displayed reason among tied requests.
On ordinary forward ticks, escalation is immediate. A lower request must remain below the previous output for the recovery interval, five seconds by default. Recovery then selects the current request directly. Battery-critical and energy-infeasible state have landing latches that survive until confirmed disarm.
Feasibility comes after persistence. This prevents a retained Hold or RTL recommendation from surviving loss of the position evidence it needs:
crates/failsafe-core/src/arbiter.rs:
pub fn apply_feasibility(action: Action, reason: Reason, position_valid: bool) -> (Action, Reason) {
if (action == Action::Hold || action == Action::ReturnToLaunch) && !position_valid {
(Action::Land, Reason::PositionInvalid)
} else {
(action, reason)
}
}
The substituted Land becomes the stored previous output, and substitution resets recovery timing.
Treat the clock as input
Timestamps are wrapping u32 milliseconds. I compare them with wrapping subtraction and a half-range rule:
crates/failsafe-core/src/time.rs:
pub fn age_ms(now: u32, then: u32) -> Option<u32> {
let diff = now.wrapping_sub(then);
if diff >= HALF_RANGE_MS {
None
} else {
Some(diff)
}
}
That accepts a normal counter wrap while rejecting apparent future samples. Comparisons assume intervals shorter than half the counter range. Tests include the transition from the maximum counter value to zero.
A backwards supervisor tick reports a clock fault, freezes trigger timing, holds the persistence output and resets the disarm gate. Feasibility still runs afterward. The implementation leaves last_now unchanged on that path.
After three backwards ticks it updates the epoch marker and clears selected state, including recovery, the glitch anchor and the GCS clear timer. I would not describe this as complete reboot recovery: the epoch marker is not used to reject old samples, and that branch does not re-anchor last_now. The plan describes stronger behavior than the implementation here.
Keep disarming advisory
There is no Disarm action. disarm_eligible is only an advisory boolean, calculated afresh every tick. It does not feed back into action selection.
The gate requires a Land-level requested action, fresh armed evidence and fresh OnGround evidence held for two seconds by default. A stale sample, missing evidence or clock fault resets it. Airborne evidence clears it immediately, even if the action remains Land through persistence or a latch.
Using the requested action matters: a Land produced only by feasibility substitution does not itself satisfy the gate.
Estimate return energy honestly
The RTL calculation is a flat-earth, constant-power heuristic. It adds climb energy below h_rtl, horizontal cruise energy with a fixed wind multiplier, and descent energy from the greater of current altitude and RTL altitude. Dividing joules by 3600 gives watt-hours.
At fixed altitude and defaults, required energy is nondecreasing with distance. Altitude is piecewise around h_rtl: below it, climbing less reduces cost; above it, descending farther increases cost. A global claim that energy always increases with altitude would be wrong.
The worked example is executable in crates/failsafe-core/tests/energy.rs:
crates/failsafe-core/tests/energy.rs:
let e = rtl_energy(&cfg, pos, None);
assert!((e.rtl_wh - 21.5625).abs() < 1e-3);
assert!((e.land_wh - 3.3333).abs() < 1e-3);
let marginal_cutoff = e.rtl_wh + cfg.reserve_wh + cfg.energy_marginal_wh;
assert!((marginal_cutoff - 34.5625).abs() < 1e-3);
let with_reserve = e.rtl_wh + cfg.reserve_wh;
assert!((with_reserve - 29.5625).abs() < 1e-3);
Its position is 1500 metres from home at 60 metres altitude. Cruise takes 65,625 joules and descent 12,000, giving 21.5625 Wh for RTL. The default 8 Wh reserve raises that to 29.5625 Wh; the marginal cutoff is 34.5625 Wh.
Remaining energy prefers a fresh watt-hour sample, then falls back to fresh percentage times configured capacity. The margin subtracts RTL energy and reserve. Negative margin can latch Land; a small nonnegative margin requests RTL after its timing condition.
Run scenarios before drawing them
The simulator reads a TOML fence, optional capacity, timed events and action expectations. For example, crates/failsafe-sim/scenarios/gps_loss.toml contains:
crates/failsafe-sim/scenarios/gps_loss.toml:
[[events]]
at_ms = 1000
kind = "kill_gps"
[[expect]]
at_ms = 3500
action = "Land"
Each tick applies due events, builds telemetry, steps the supervisor, advances physics and records a row. The point-mass 2.5D model stores horizontal position and altitude. RTL changes northward acceleration toward home; Land sets a descent velocity. This is deliberately small and does not model attitude or motors.
There are ten named scenarios: nominal, GPS loss, low battery far away, fence breach, lost landing evidence, stale status, simultaneous triggers, clock wrap/reboot, marginal hysteresis and recovery with invalid position. Their names exceed their current coverage. Several only assert Nominal at 500 ms; the clock scenario never injects a counter wrap.
Adapt packets and reuse the simulation
The MAVLink adapter is receive-only. It filters sender IDs, decodes supported messages and caches fields with host receive timestamps. Heartbeats supply armed or GCS evidence; other mappings supply battery, local position, GPS and landing status. AHRS health serves as an EKF proxy.
Fixture replay reads a little-endian host timestamp, frame length and frame bytes. The checked-in tests exercise a synthetic heartbeat and sender selection. The fixtures are partial and synthetic. Although a feature enables the dependency's UDP transport, the binary itself reads a fixture file; it is not a live UDP monitor.
The browser calls the Rust simulation through this wrapper in crates/failsafe-wasm/src/lib.rs:
crates/failsafe-wasm/src/lib.rs:
pub fn tick(&mut self) -> Result<String, JsError> {
let (row, decision) = self.inner.tick();
The wrapper serializes each frame for JavaScript. web/app.js calls it with a 50 ms interval, matching the simulation's 50 ms step: nominally 20 Hz. Browser scheduling can delay callbacks; simulated time still advances one fixed step per call. JavaScript draws the fence and trail and forwards controls.
Check the boundaries
Tests cover geometry, GPS loss, invalid-position feasibility, clock wrap and energy arithmetic. Proptest configures 10,000 cases for three properties. The no-panic property varies clock sequences with otherwise missing telemetry; it does not generate arbitrary populated telemetry. The altitude property checks finite nonnegative output, but does not establish both monotonic branches or continuity.
The golden scenario harness checks actions at listed times, without comparing complete CSV traces. CI includes formatting, Clippy, workspace tests, dependency checks and a nostd Cortex-M4F cross-build. Its WASM job builds bindings and runs a Playwright smoke test that loads the page, clicks controls, resets and checks GPS kill eventually displays Land without browser errors.
Limits
This is not flight-tested or certified. Telemetry is synthetic, MAVLink fixtures are partial and synthetic, and the simulator is point-mass. Scenario tests assert only smoke-level expectations. Proptest case counts provide partial, sampled evidence, not proof. Energy margins are tunable heuristics without measured aircraft calibration.
What I'd do next
I would strengthen timing and recovery sequence tests, repair clock re-anchoring and add populated invalid telemetry generators. Then I would make each named scenario exercise its advertised fault and compare transitions and diagnostics. Broader packet fixtures and measured power data would make the adapter and energy model easier to assess.
Try it
The repository contains the core, scenarios and checks. Open the browser demo to see the same supervisor respond to synthetic faults.