Technical writing
Swarm situational awareness: signed position broadcasts, sensor fusion, and dead-reckoning in embedded Rust
A 128-node swarm operating without a central coordinator must maintain a shared operational picture — who is where, moving in which direction, at what altitude — from first principles. Every node must know the state of every other node well enough to avoid collisions, coordinate formation changes, and detect when a node has failed or been captured. GPS signals can be jammed. Radio links can be severed. A node that relies entirely on GPS for its own position estimate will become blind precisely when adversarial conditions demand reliable situational awareness.
This article covers the position broadcast frame format with Ed25519 authentication, the sensor fusion algorithm that combines GPS, IMU accelerometer/gyroscope, and barometric altitude into a 6-DOF state estimate, the dead-reckoning fallback that maintains position estimates for up to 90 seconds without GPS, and the gossip protocol that propagates node state across multi-hop LoRa mesh links.
Position broadcast frame format
Each node broadcasts its state in a 64-byte position frame every 500ms on the shared LoRa channel. The frame is compact enough to transmit at LoRa SF7 spreading factor within a single air-time budget slot. The last 64 bytes of each frame are an Ed25519 signature over the preceding 60 bytes, making each frame 124 bytes total:
// src/swarm/broadcast.rs
use byteorder::{BigEndian, WriteBytesExt};
use ed25519_dalek::{Signer, SigningKey};
/// 60-byte position frame payload (before signature).
/// All multi-byte integers are big-endian.
#[repr(C, packed)]
pub struct PositionFrame {
pub version: u8, // frame format version (currently 0x03)
pub node_id: [u8; 4], // 32-bit node identifier
pub seq: u32, // monotonically increasing sequence number
pub epoch_ms: u64, // milliseconds since UNIX epoch
pub lat_e7: i32, // latitude * 1e7 (e.g., 37.7749° → 377749000)
pub lon_e7: i32, // longitude * 1e7
pub alt_mm: i32, // altitude above WGS-84 ellipsoid in millimetres
pub vel_n_mms: i16, // north velocity in mm/s
pub vel_e_mms: i16, // east velocity in mm/s
pub vel_d_mms: i16, // down velocity in mm/s (positive = descending)
pub heading_cdeg: u16, // true heading in centidegrees (0–35999)
pub hdop_x10: u8, // horizontal DOP * 10 (GPS quality indicator)
pub fix_type: u8, // 0=none, 1=DR, 2=2D, 3=3D, 4=RTK float, 5=RTK fixed
pub battery_pct: u8, // 0–100
pub flags: u8, // bitmask: bit0=gps_degraded, bit1=comms_relay, bit2=payload_armed
pub _reserved: [u8; 17],// future fields; zero-filled
}
// Total: 1+4+4+8+4+4+4+2+2+2+2+1+1+1+1+17 = 58 bytes
// With 2-byte CRC = 60 bytes (before 64-byte signature → 124 bytes total)
pub fn sign_frame(payload: &[u8; 60], signing_key: &SigningKey) -> [u8; 64] {
signing_key.sign(payload).to_bytes()
}
pub fn verify_frame(payload: &[u8; 60], sig: &[u8; 64], verifying_key: &[u8; 32]) -> bool {
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let vk = VerifyingKey::from_bytes(verifying_key).ok();
let s = Signature::from_bytes(sig);
vk.map(|k| k.verify(payload, &s).is_ok()).unwrap_or(false)
}Each node's Ed25519 signing key is provisioned at manufacturing time, with the corresponding verifying key distributed to the entire swarm in the commissioning bundle. A receiving node that cannot verify a frame's signature drops it silently — this protects against spoofed position broadcasts from an adversary who has captured and is replaying a node's RF transmissions. The seq field provides replay protection: a frame with a sequence number lower than the last accepted frame from the same node ID is also dropped.
Sensor fusion algorithm
The on-device 6-DOF state estimate combines three sensor streams using an Extended Kalman Filter: GPS (position + velocity, 1 Hz update rate), IMU (accelerometer + gyroscope, 100 Hz), and barometric altimeter (1 Hz). The EKF state vector is 15-dimensional: position (3), velocity (3), attitude quaternion (4), accelerometer bias (3), gyroscope bias (3) — minus one for quaternion normalization constraint = 15:
// src/nav/ekf.rs
use nalgebra::{Matrix3, Matrix6, Vector3, UnitQuaternion};
pub struct EkfState {
pub position_ned: Vector3<f64>, // north, east, down (metres from home)
pub velocity_ned: Vector3<f64>, // m/s
pub attitude: UnitQuaternion<f64>,
pub accel_bias: Vector3<f64>, // m/s^2
pub gyro_bias: Vector3<f64>, // rad/s
pub covariance: nalgebra::SMatrix<f64, 15, 15>,
}
impl EkfState {
/// IMU propagation step (runs at 100 Hz).
/// Integrates accelerometer and gyroscope measurements to propagate state.
pub fn propagate(&mut self, accel_body: Vector3<f64>, gyro_body: Vector3<f64>, dt: f64) {
// Correct measurements for estimated bias
let accel_corrected = accel_body - self.accel_bias;
let gyro_corrected = gyro_body - self.gyro_bias;
// Rotate acceleration from body to NED frame
let accel_ned = self.attitude * accel_corrected
- Vector3::new(0.0, 0.0, -9.81); // subtract gravity
// Integrate position and velocity (simple Euler; RK4 used in flight-critical builds)
self.position_ned += self.velocity_ned * dt + 0.5 * accel_ned * dt * dt;
self.velocity_ned += accel_ned * dt;
// Integrate attitude: quaternion kinematics
let omega = UnitQuaternion::from_scaled_axis(gyro_corrected * dt);
self.attitude = (self.attitude * omega).normalize();
// Propagate covariance: P = F*P*F' + Q (simplified; see full Jacobian in ekf_jacobian.rs)
self.propagate_covariance(dt);
}
/// GPS update step (runs at 1 Hz when GPS fix is available).
pub fn update_gps(
&mut self,
gps_pos_ned: Vector3<f64>,
gps_vel_ned: Vector3<f64>,
hdop: f64,
) {
// GPS noise scales with HDOP: 2.5m CEP * HDOP for position
let pos_noise = 2.5 * hdop;
let vel_noise = 0.1; // m/s, GPS Doppler velocity
let r_gps = Matrix6::from_diagonal(&nalgebra::Vector6::new(
pos_noise * pos_noise, pos_noise * pos_noise, (pos_noise * 2.0).powi(2),
vel_noise * vel_noise, vel_noise * vel_noise, vel_noise * vel_noise,
));
// Innovation: difference between GPS measurement and state prediction
let innov_pos = gps_pos_ned - self.position_ned;
let innov_vel = gps_vel_ned - self.velocity_ned;
// Kalman gain and state update (6-measurement update)
self.apply_position_velocity_update(innov_pos, innov_vel, r_gps);
}
/// Barometric altitude update (runs at 1 Hz).
pub fn update_baro(&mut self, baro_alt_m: f64) {
// Baro noise: 0.5m standard deviation in calm conditions
let baro_noise_variance = 0.25;
let innov = -baro_alt_m - self.position_ned[2]; // NED: down is positive
self.apply_altitude_update(innov, baro_noise_variance);
}
}The barometric altimeter update is critical for maintaining altitude accuracy during GPS outages: GPS vertical accuracy is typically 1.5× worse than horizontal accuracy, and the barometer provides 0.5m altitude precision in stable atmospheric conditions. During GPS degradation, the filter shifts weight to the barometer for altitude while relying entirely on IMU integration for horizontal position.
Dead-reckoning fallback
When GPS is unavailable (fix_type = 0) and the last GPS fix was less than 90 seconds ago, the node enters dead-reckoning mode: it continues IMU propagation without GPS corrections and sets the fix_type field in position broadcasts to 1 (DR). Other nodes in the swarm treat DR-mode position estimates with expanded uncertainty:
// src/nav/dr_mode.rs
/// Horizontal position uncertainty (1-sigma, metres) as a function of
/// seconds elapsed since last GPS fix, based on empirical IMU drift characterization.
/// Linear growth assumption: typical MEMS IMU horizontal velocity error ~ 0.08 m/s^2 RMS
/// integrated twice: error ~ 0.5 * 0.08 * t^2 metres.
pub fn dr_position_uncertainty_m(seconds_since_gps: f32) -> f32 {
0.04 * seconds_since_gps * seconds_since_gps
}
/// Maximum dead-reckoning duration before position is declared unreliable.
/// At 90s: uncertainty ~ 0.04 * 90^2 = 324 metres -- beyond useful collision avoidance range.
pub const DR_TIMEOUT_SECS: f32 = 90.0;
/// A receiving node adjusts its minimum separation distance based on the
/// combined uncertainty of its own position and the transmitting node's DR uncertainty.
pub fn adjusted_separation_m(
own_uncertainty_m: f32,
neighbor_dr_secs: f32, // 0 if neighbor has GPS fix
) -> f32 {
let neighbor_uncertainty = if neighbor_dr_secs > 0.0 {
dr_position_uncertainty_m(neighbor_dr_secs)
} else {
2.5 // GPS fix: 2.5m CEP
};
// Safe separation = nominal + 3-sigma combined uncertainty (RSS)
let combined_uncertainty = (own_uncertainty_m.powi(2) + neighbor_uncertainty.powi(2)).sqrt();
10.0 + 3.0 * combined_uncertainty // 10m nominal separation + 3σ
}The quadratic growth of dead-reckoning uncertainty means that at 90 seconds without GPS, a node cannot reliably maintain the 10-meter minimum separation distance. The swarm coordination layer responds by transitioning any node beyond the DR timeout to a hover-and-wait state, broadcasting its last known position with the fix_type = 0 (no fix) flag and waiting for either GPS recovery or a rescue command from the operator.
Gossip propagation protocol
Not all 128 nodes can hear each other directly; LoRa range in a field environment with terrain obstructions is approximately 800 metres at SF7. The gossip layer propagates position frames across multi-hop links using a probabilistic forwarding approach: each received frame is forwarded once with probability 0.6 after a random backoff of 0–200ms, unless the node has already forwarded this frame (identified by node_id + seq):
// src/swarm/gossip.rs
use std::collections::HashMap;
const FORWARD_PROBABILITY: f32 = 0.60;
const MAX_HOPS: u8 = 4; // inserted into frame flags[3:5] (2-bit hop count)
const SEEN_CACHE_SIZE: usize = 512; // LRU cache of recently forwarded (node_id, seq) pairs
pub struct GossipLayer {
seen_frames: lru::LruCache<(u32, u32), ()>, // (node_id, seq) -> ()
rng: rand::rngs::SmallRng,
}
impl GossipLayer {
pub fn on_receive(
&mut self,
frame: &PositionFrame,
sig: &[u8; 64],
hop_count: u8,
neighbor_keys: &HashMap<u32, [u8; 32]>,
) -> Option<ForwardAction> {
let key = (u32::from_be_bytes(frame.node_id), frame.seq);
// Drop already-forwarded frames
if self.seen_frames.contains(&key) {
return None;
}
// Verify signature
let vk = neighbor_keys.get(&u32::from_be_bytes(frame.node_id))?;
let payload = frame_to_bytes(frame);
if !verify_frame(&payload, sig, vk) {
return None; // drop unsigned or malformed frame
}
self.seen_frames.put(key, ());
// Drop frames that have exceeded max hop count
if hop_count >= MAX_HOPS {
return Some(ForwardAction::Accept { hop_count }); // accept but don't forward
}
// Probabilistic forwarding with random backoff
use rand::Rng;
if self.rng.gen::<f32>() < FORWARD_PROBABILITY {
let backoff_ms = self.rng.gen_range(0u64..200u64);
return Some(ForwardAction::ForwardAfter {
delay_ms: backoff_ms,
hop_count: hop_count + 1,
});
}
Some(ForwardAction::Accept { hop_count })
}
}
pub enum ForwardAction {
Accept { hop_count: u8 },
ForwardAfter { delay_ms: u64, hop_count: u8 },
}The 0.6 forwarding probability and 4-hop maximum are tuned to maintain 95% position frame delivery rate across a 128-node swarm deployed in a 2km × 2km area, while keeping the channel utilization below 40% to leave headroom for command-and-control traffic. Simulation results show that 0.5 probability achieves 87% delivery and 0.7 achieves 97% delivery, with the marginal gain from 0.6→0.7 not worth the additional 15% channel load.
Neighbor state table
Each node maintains a neighbor state table — a map from node_id to the most recently received (and verified) position frame for that node. Entries expire after 2 seconds with no update. The formation controller and collision avoidance subsystem consume the neighbor state table to compute safe trajectories:
| Metric | 128-node field test | Target |
|---|---|---|
| Position frame delivery rate | 94.2% | > 90% |
| Median end-to-end propagation latency | 340 ms | < 500 ms |
| Neighbor table staleness (p95) | 620 ms | < 1000 ms |
| Channel utilization | 36% | < 40% |
| False positive spoofing rejections | 0 in 48h test | 0 |
Related writing
Swarm embedded Rust covers the no-std Rust environment, RTIC task model, and peripheral driver architecture that this situational awareness module runs within.
Swarm SDK v0.4 documents the ground-station SDK that ingests neighbor state tables from individual nodes and synthesizes the fleet-wide operational picture for human operators.