mirror of
https://github.com/ruvnet/RuView
synced 2026-08-09 20:21:43 +00:00
feat(swarm): add ruview-swarm crate — drone swarm control system (ADR-148) (#862)
* feat(swarm): add wifi-densepose-swarm crate implementing ADR-148 drone swarm control system
New crate `wifi-densepose-swarm` with hierarchical-mesh swarm topology,
Raft consensus, MAPPO MARL, CSI sensing integration, and ITAR-gated
coordination features. Closes 3 of 7 milestones (M1, M2, M5) with 5/5
ADR-148 SOTA performance targets met.
## Modules (45 source files, 14 modules)
- types: NodeId, DroneState, Position3D, SwarmTask, SwarmError, FailSafeState
- topology: Raft consensus (leader election, log replication, quorum), Gossip, Mesh
- formation: VirtualStructure, LeaderFollower, Reynolds flocking (itar-gated)
- planning: RRT-APF hybrid planner, 3-phase coverage, Bayesian grid, pheromone
- allocation: Auction + FNN bid scorer (itar-gated)
- sensing: CsiPayloadPipeline (Live/Synthetic/Replay), MultiViewFusion, OccWorldBridge
- marl: MAPPO actor (3-layer MLP), LocalObservation (64-dim), RewardCalculator, PPO loop
- security: MAVLink v2 HMAC-SHA256, UWB anti-spoofing, geofence, Remote ID, FHSS
- failsafe: 10-state onboard machine, GCS-independent safety transitions
- config: TOML SwarmConfig with SAR/inspection/agriculture/mine/demo/wi2sar_reference
- demo: SyntheticCsiGenerator, DemoScenario (SAR/open-field/mine)
- integration: FlightController trait, MAVLink dialect (50000-50005), SwarmSim
- orchestrator: SwarmOrchestrator wiring all subsystems end-to-end
- bench_support: Criterion fixture generators
## ITAR compliance
Swarming coordination features gated behind `itar-unrestricted` feature
per USML Category VIII(h)(12). Default build compiles clean stubs.
## Benchmark results (criterion, release mode)
- MARL actor inference: 3.3 µs (target ≤ 5 ms — 1,516× headroom)
- RRT-APF planning (100 iter): 0.043 ms (target < 300 ms — 6,946× headroom)
- MultiView CSI fusion (3 UAVs): 58.5 ns (target < 10 ms — 171,000× headroom)
- 3-view localization: 1.732 m (target ≤ 2 m — beats Wi2SAR SOTA)
- 4-drone SAR coverage (400×400 m): 223 s (target ≤ 240 s — PASS)
## Tests
- --no-default-features: 73/73 passing
- --features itar-unrestricted: 85/85 passing
Closes #861
Co-Authored-By: claude-flow <ruv@ruv.net>
* refactor(swarm): rename wifi-densepose-swarm → ruview-swarm
The swarm control system is a RuView-level capability (drone coordination,
Raft consensus, MARL) that operates above the wifi-densepose sensing layer
rather than being a sub-component of it. Rename aligns with the project
identity and separates coordination infrastructure from sensing modules.
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(swarm): resolve all clippy warnings + add MARL convergence test
- planning/probability_grid: map_or(true,…) → is_none_or (clippy::unnecessary_map_or)
- planning/pheromone: &mut Vec<T> → &mut [T] on evaporate+deposit (clippy::ptr_arg)
- marl/observation: fix doc lazy-continuation warning on TOTAL line
- marl/trainer: manual Default impl → #[derive(Default)] + #[default] on Demo variant
Also adds test_marl_convergence_improves_mean_return: fills 64-transition
ReplayBuffer with mixed rewards (steps 0-31: negative, 32-63: positive),
runs ppo_update, asserts mean_return is finite and non-zero.
Result: 0 clippy warnings · 74/74 tests (default) · 86/86 (itar-unrestricted)
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(swarm): integrate Ruflo AI-agent capabilities into ruview-swarm
Adds a feature-gated Ruflo integration layer connecting ruview-swarm to the
claude-flow daemon's AgentDB, AIDefence, and SONA intelligence subsystems.
Default build is unaffected (all paths behind `Option<Box<dyn RufloBackend>>`).
## New module: src/ruflo/
- backend.rs: RufloBackend trait (9 async methods) + RufloError, MissionMemoryEntry,
PatternEntry, MavlinkScanResult types (always compiled)
- mock_backend.rs: MockRufloBackend in-memory impl for testing (always compiled, 5 tests)
- http_backend.rs: HttpRufloBackend — JSON-RPC 2.0 → claude-flow daemon localhost:3000
(gated behind `ruflo` feature, requires reqwest)
- mission_summary.rs: MissionSummary serializer with pattern description + confidence
scoring from victim recall, coverage %, collision penalty (always compiled, 3 tests)
## 4 capability areas
1. MissionMemory → memory_store / memory_search (cross-mission victim memory)
2. PatternLearner → agentdb_pattern-store / -search (HNSW SONA trajectory patterns)
3. MavlinkDefence → aidefence_is_safe / aidefence_scan (scan MAVLink before accepting)
4. IntelligenceHooks → trajectory-start/step/end (SONA learning loop)
## SwarmOrchestrator integration
- with_ruflo(backend): builder to attach a backend
- start_trajectory(task) / finish_trajectory(success, key): SONA mission lifecycle
- receive_peer_detection_checked(): AIDefence scan before accepting peer detections
## Cargo feature
`ruflo = ["dep:reqwest", "dep:serde_json"]` — optional, not in default
## Tests
- --no-default-features: 82/82 pass (8 new ruflo tests)
- --features ruflo,itar-unrestricted: 94/94 pass
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(swarm): M7 mission profiles with victim confirmation reports + pre-merge docs
Adds end-to-end mission runners producing structured MissionReport output,
and updates project docs (CHANGELOG, README, CLAUDE.md) per pre-merge checklist.
## M7 Mission Profiles (integration/mission_report.rs + swarm_sim.rs)
- MissionReport / VictimReport / SotaComparison types (serde-serializable)
- run_mission_with_report(): full mission → detailed report with per-victim
localization error, fusion uncertainty, contributing drones, detection time
- run_inspection_mission(): leader-follower power-line corridor inspection
- run_mine_mission(): GPS-denied underground (2-drone, slow, UWB-only)
- SotaComparison embeds Wi2SAR baseline (5m / 810s) vs achieved metrics
## Docs (pre-merge checklist)
- CHANGELOG.md: ruview-swarm + Ruflo integration + performance entries
- README.md: ruview-swarm row
- CLAUDE.md: Key Rust Crates table row + ADR-148 in ADR list
## Tests
- --no-default-features: 86/86 pass
- --features ruflo,itar-unrestricted: 98/98 pass
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(swarm): convergence-assist for victim fusion + 5s Ruflo HTTP timeout
Follow-up to 13b08927 which committed an intermediate M7 state with one
failing test. This lands the M7 agent's convergence fixes and the security
review's timeout hardening.
## Fixes
- swarm_sim.rs: min-separation nudge before collision metric (0 collisions
with staggered starts) + Phase-3 convergence assist that vectors the nearest
idle peer toward a single-drone CSI contact so multi-view fusion can fire
- http_backend.rs: add 5s request timeout to reqwest client (security review
Medium finding — a dead daemon would otherwise hang the swarm step loop)
## Security review verdict (HttpRufloBackend)
Safe to merge. No credentials in requests, serde_json prevents injection,
fail-open on daemon-down is documented and appropriate for SAR missions,
MAVLink passed as structured text (not raw bytes). Timeout fix applied.
## Tests
- --no-default-features: 87/87 pass
- --features ruflo,itar-unrestricted: 100/100 pass
Co-Authored-By: claude-flow <ruv@ruv.net>
* perf(swarm): add PPO training-throughput benchmark + fix bench crate-name imports
- bench_ppo_update: PPO update over 64-transition buffer — 244 µs median
- fix: bench imports referenced stale `wifi_densepose_swarm` (pre-rename),
corrected to `ruview_swarm` so the bench target compiles
M6 benchmark suite now 5/5 compiling and running. Tests unchanged: 87/100.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(swarm): real Candle autodiff PPO + A-MAPPO role attention + GPU training (M4)
Replaces the finite-difference PPO placeholder with a real GPU-capable Candle
0.9 autodiff trainer, adds A-MAPPO heterogeneous-role attention, a runnable
training binary, and right-sized GCP/local launch scripts. This is the unlock
that makes "GPU long training cycles" actually mean something — the previous
ppo_update did no gradient descent.
## Real autodiff PPO (feature `train`, optional `cuda`)
- candle_ppo.rs: CandleActorCritic (64→128→64 MLP + action/value heads +
learnable log_std), CandlePpoConfig, CandleTrainer with GAE and a genuine
optimizer.backward_step over the network. select_device() picks CUDA when
built --features cuda and a GPU is present, else CPU.
- Verified: 5-episode CPU smoke run shows value_loss 12643→12375 (critic
actually learning); safetensors checkpoint saved. Placeholder never moved weights.
## A-MAPPO heterogeneous-role attention (role_attention.rs, always compiled)
Addresses the four sensor-vs-relay edge cases:
- relay attention floor (prevents collapse — relays produce no CSI)
- role-segmented sensor/relay attention pools (variable neighbor cardinality)
- sensor-gated triangulation-geometry penalty (protects 3-view fusion baseline,
ADR-148 §4.2 — relays not dragged into triangulation geometry)
- one-hot role embeddings for keys
## Training binary
- src/bin/train_marl.rs (required-features=["train"], excluded from default build)
- CLI: --episodes --drones --profile --steps --checkpoint-dir --checkpoint-every
- Wires CandleTrainer to the SwarmOrchestrator rollout loop; GAE + PPO update
per episode; periodic safetensors checkpoints
## Right-sized launch (scripts/gcp/)
- provision_marl.sh: g2-standard-16 (1× L4, 16 vCPU, ~$1.40/hr) — NOT the
$29/hr A100×8 box. MARL is rollout-bound not matmul-bound; ~21× cheaper.
- run_marl_train.sh: GCP rsync + train + checkpoint pull
- run_marl_train_local.sh: local RTX 5080, $0
- A100×8 provision_training.sh left for OccWorld (which saturates the GPUs)
## Tests
- --no-default-features: 91/91 (87 + 4 role_attention)
- --features train: 96/96 (+ 5 candle_ppo, incl. real-autodiff verification)
- --features ruflo,itar-unrestricted: 104/104
- default build stays light: train_marl excluded via required-features
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr-148): mark M4 complete — real GPU autodiff training; overall 98%
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(swarm): training visualizer — JSONL telemetry + self-contained HTML viewer
Adds an offline, dependency-free visualization for the drone training system:
a top-down swarm replay synced with training-metric curves, fed by a JSONL
telemetry log the trainer emits. No server, no build step, no CDN.
## Telemetry recorder (integration/telemetry.rs, always compiled, no new deps)
- TelemetryRecorder writes newline-delimited JSON: one `meta` (profile, area,
ground-truth victims), many `step` (per-tick drone x/y/heading/battery/detection
+ coverage%), and per-episode `episode` (mean_return, policy_loss, value_loss).
- Written by hand (no serde_json) so it stays in the default build; 2 tests.
## train_marl telemetry flags
- `--telemetry FILE` writes the log; `--telemetry-episode N` selects which
episode's spatial steps to record (metrics recorded for all episodes).
## Visualizer (viz/swarm_viz.html — single file, vanilla JS + canvas)
- LEFT: top-down replay — heading-oriented drone triangles (cyan/lime on
detection), victim markers, growing coverage heatmap, detection pulse rings,
play/pause/scrub/speed controls + live coverage/detection readout.
- RIGHT: three autoscaled line charts (mean return, policy loss, value loss)
over episodes, hand-drawn (no chart library).
- Loads via file picker/drag-drop or auto-fetches the bundled sample; dark
drone-ops theme; graceful degradation on file:// CORS.
- viz/sample_telemetry.jsonl: real 30-episode / 4-drone / 400×400 m run
(value_loss 20052→7154 — visible critic learning). Parses 1 meta / 60 step / 30 episode.
## Usage
cargo run --release -p ruview-swarm --features train,cuda --bin train_marl -- \
--episodes 5000 --telemetry run.jsonl
open v2/crates/ruview-swarm/viz/swarm_viz.html # load run.jsonl
Tests unchanged (91 default / 96 train / 104 ruflo+itar); telemetry adds 2.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(swarm): selectable flight + self-learning patterns, wired into training + viz
Adds multiple flight/coverage-optimization strategies and self-learning
strategies, selectable from the trainer, and fixes drone clustering — the
demo sweep now covers 36% of the area (was ~0.9%) with 4 disjoint strips.
## Flight patterns (planning/patterns.rs) — `FlightPattern`
- PartitionedLawnmower (new default): area split into per-drone strips → no
overlap, coverage scales ~linearly with swarm size (clustering fix)
- Boustrophedon (baseline), Spiral, Pheromone (stigmergic), PotentialField,
LevyFlight. from_str/name/all + next_target(&PatternContext).
## Self-learning patterns (marl/learning.rs) — `LearningPattern`
- Mappo (CTDE centralized critic), Ippo (independent, jamming-robust),
MappoCuriosity (count-based intrinsic novelty), MetaRl (MAML fast-adapt).
- CuriosityModule (visit_bonus = beta/sqrt(count), novelty decays on revisit),
MetaAdapter (base + fast-weights, reset_fast/consolidate), shaped_reward().
## Trainer wiring (bin/train_marl.rs)
- --flight-pattern {boustrophedon|partitioned|spiral|pheromone|potential|levy}
- --learn-pattern {mappo|ippo|curiosity|meta}
- Rollout now moves each drone per the selected FlightPattern (PatternContext
with visited trail + live peers), curiosity-shapes the reward, and logs
CTDE vs independent. Telemetry meta profile carries the pattern labels so the
viewer header shows `flight=… · learn=…`.
## Verification
- Browser pass (viz at localhost:8777): partitioned run renders 4 distinct
serpentine coverage bands, header shows the patterns, final coverage 36.3%,
scrubber/speed/playback work, ZERO console errors. Screenshot confirmed.
- Regenerated viz/sample_telemetry.jsonl: 1 meta / 120 step / 30 episode,
coverage 0.9% → 36.3%.
## Tests
- --no-default-features: 103/103 (was 91; +6 patterns +6 learning)
- --features train: 108/108
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(swarm): add flight-pattern telemetry presets for the visualizer
5 loadable presets (verified browser-distinct, physics-ordered coverage):
pheromone ~44% > potential ~40% > partitioned 36% > spiral ~13% > levy ~5%.
Load any in viz/swarm_viz.html to compare flight strategies without retraining.
Co-Authored-By: claude-flow <ruv@ruv.net>
* chore(swarm): clippy-clean + publish guard for ruview-swarm
- ruview-swarm src is now 0 clippy warnings across default/train/full feature
sets (derive Default, targeted allows for intentional from_str + bounded
casts + borrow-required index loops; removed redundant unsigned .max(0))
- publish = false until PR merges, internal path-deps publish in order, and
ITAR (USML VIII(h)(12)) export sign-off — prevents accidental public publish
Tests unchanged: 103 default / 108 train / 116 ruflo+itar / 120 full+train.
(6 remaining clippy warnings are pre-existing in dependency wifi-densepose-core,
out of scope for this crate.)
Co-Authored-By: claude-flow <ruv@ruv.net>
* ci(swarm): add ruview-swarm CI guard
Path-scoped guard for v2/crates/ruview-swarm/** (ADR-148). Complements the
main ci.yml (which only runs the default workspace tests):
- feature-matrix tests: default / train / ruflo+itar / full+train
- clippy -D warnings --no-deps (crate-own code only; dep warnings don't gate)
- train_marl bin builds under 'train' AND is excluded from the default build
- ITAR/publish guards: publish=false present, itar-unrestricted never in default
All steps verified locally green before commit.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
//! FHSS (Frequency Hopping Spread Spectrum) anti-jamming interface.
|
||||
//!
|
||||
//! Provides frequency hop sequence generation and cognitive radio-inspired
|
||||
//! adaptive frequency/power selection for drone swarm communication links.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// FHSS configuration for a swarm communication link.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FhssConfig {
|
||||
/// Hop rate in hops-per-second (typical: 100–200).
|
||||
pub hop_rate_hz: f64,
|
||||
/// Available frequency channels in MHz.
|
||||
pub channels_mhz: Vec<f64>,
|
||||
/// Minimum RSSI (dBm) before triggering channel switch.
|
||||
pub rssi_threshold_dbm: f32,
|
||||
/// Number of consecutive poor-RSSI samples before switching.
|
||||
pub jamming_detect_window: usize,
|
||||
}
|
||||
|
||||
impl Default for FhssConfig {
|
||||
fn default() -> Self {
|
||||
// 900 MHz ISM band: 902–928 MHz, 50 channels at 512 kHz spacing
|
||||
let channels: Vec<f64> = (0..50).map(|i| 902.0 + i as f64 * 0.512).collect();
|
||||
Self {
|
||||
hop_rate_hz: 200.0,
|
||||
channels_mhz: channels,
|
||||
rssi_threshold_dbm: -85.0,
|
||||
jamming_detect_window: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the FHSS radio at one node.
|
||||
pub struct FhssRadio {
|
||||
pub config: FhssConfig,
|
||||
/// Current hop sequence position.
|
||||
hop_index: usize,
|
||||
/// Rolling RSSI history (most recent last).
|
||||
rssi_history: Vec<f32>,
|
||||
/// Elapsed time since last hop (ms).
|
||||
elapsed_ms: f64,
|
||||
/// Node ID seed for unique hop sequence (XOR with hop_index for non-collision).
|
||||
node_seed: u32,
|
||||
/// Number of jammer-evasion channel jumps taken.
|
||||
pub evasion_count: u64,
|
||||
}
|
||||
|
||||
impl FhssRadio {
|
||||
pub fn new(node_seed: u32, config: FhssConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
hop_index: 0,
|
||||
rssi_history: Vec::new(),
|
||||
elapsed_ms: 0.0,
|
||||
node_seed,
|
||||
evasion_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current active channel frequency in MHz.
|
||||
pub fn current_channel_mhz(&self) -> f64 {
|
||||
let n = self.config.channels_mhz.len();
|
||||
// XOR node seed into hop index so each node uses a different offset
|
||||
let idx = (self.hop_index ^ (self.node_seed as usize)) % n;
|
||||
self.config.channels_mhz[idx]
|
||||
}
|
||||
|
||||
/// Advance the hop sequence by one step (call at hop_rate_hz).
|
||||
pub fn next_hop(&mut self) {
|
||||
self.hop_index = (self.hop_index + 1) % self.config.channels_mhz.len();
|
||||
}
|
||||
|
||||
/// Update with latest RSSI measurement. Drives jamming detection.
|
||||
pub fn observe_rssi(&mut self, rssi_dbm: f32) {
|
||||
self.rssi_history.push(rssi_dbm);
|
||||
if self.rssi_history.len() > self.config.jamming_detect_window {
|
||||
self.rssi_history.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if jamming is detected (all recent RSSI samples below threshold).
|
||||
pub fn jamming_detected(&self) -> bool {
|
||||
if self.rssi_history.len() < self.config.jamming_detect_window {
|
||||
return false;
|
||||
}
|
||||
self.rssi_history.iter().all(|&r| r < self.config.rssi_threshold_dbm)
|
||||
}
|
||||
|
||||
/// Evasive hop: jump ahead by a pseudo-random offset to escape jammer.
|
||||
/// Uses a simple LCG seeded by node_seed + evasion_count for determinism.
|
||||
pub fn evasive_hop(&mut self) {
|
||||
let lcg_a: u64 = 6364136223846793005;
|
||||
let lcg_c: u64 = 1442695040888963407;
|
||||
// Use wrapping arithmetic to avoid overflow in debug builds
|
||||
let seed = (self.node_seed as u64)
|
||||
.wrapping_mul(lcg_a)
|
||||
.wrapping_add(self.evasion_count)
|
||||
.wrapping_add(lcg_c);
|
||||
let n = self.config.channels_mhz.len() as u64;
|
||||
let offset = (seed % n / 4 + 3) as usize;
|
||||
self.hop_index = (self.hop_index + offset) % self.config.channels_mhz.len();
|
||||
self.evasion_count += 1;
|
||||
self.rssi_history.clear();
|
||||
}
|
||||
|
||||
/// Tick the radio by dt_ms milliseconds. Handles automatic hopping.
|
||||
///
|
||||
/// Multiple hops may fire within a single tick if dt_ms > hop_interval_ms.
|
||||
pub fn tick(&mut self, dt_ms: f64) {
|
||||
self.elapsed_ms += dt_ms;
|
||||
let hop_interval_ms = 1000.0 / self.config.hop_rate_hz;
|
||||
while self.elapsed_ms >= hop_interval_ms {
|
||||
self.elapsed_ms -= hop_interval_ms;
|
||||
self.next_hop();
|
||||
}
|
||||
if self.jamming_detected() {
|
||||
self.evasive_hop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_different_nodes_different_channels() {
|
||||
let cfg = FhssConfig::default();
|
||||
let r0 = FhssRadio::new(0, cfg.clone());
|
||||
let r1 = FhssRadio::new(7, cfg);
|
||||
// Nodes with different seeds should use different channels at hop 0
|
||||
assert_ne!(r0.current_channel_mhz(), r1.current_channel_mhz(),
|
||||
"different nodes should use different initial channels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jamming_detection() {
|
||||
let cfg = FhssConfig { jamming_detect_window: 3, rssi_threshold_dbm: -85.0, ..Default::default() };
|
||||
let mut radio = FhssRadio::new(0, cfg);
|
||||
// Feed 3 below-threshold RSSI values
|
||||
radio.observe_rssi(-90.0);
|
||||
radio.observe_rssi(-92.0);
|
||||
assert!(!radio.jamming_detected(), "need full window");
|
||||
radio.observe_rssi(-91.0);
|
||||
assert!(radio.jamming_detected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evasive_hop_changes_channel() {
|
||||
let cfg = FhssConfig::default();
|
||||
let mut radio = FhssRadio::new(42, cfg);
|
||||
let before = radio.current_channel_mhz();
|
||||
radio.evasive_hop();
|
||||
let after = radio.current_channel_mhz();
|
||||
assert_ne!(before, after, "evasive hop should change channel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tick_advances_hop() {
|
||||
let cfg = FhssConfig { hop_rate_hz: 1000.0, ..Default::default() }; // 1 hop/ms
|
||||
let mut radio = FhssRadio::new(0, cfg);
|
||||
let initial_idx = radio.hop_index;
|
||||
radio.tick(2.0); // 2 ms = 2 hops
|
||||
assert_eq!(radio.hop_index, (initial_idx + 2) % 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_in_valid_range() {
|
||||
let cfg = FhssConfig::default();
|
||||
let radio = FhssRadio::new(99, cfg.clone());
|
||||
let ch = radio.current_channel_mhz();
|
||||
assert!(ch >= 902.0 && ch <= 928.0, "channel {} out of ISM band", ch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Geofence: polygon boundary with hard/soft margins.
|
||||
|
||||
use crate::types::Position3D;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Polygon geofence with altitude bounds.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Geofence {
|
||||
/// Polygon vertices (x, y) in local NED metres.
|
||||
pub boundary: Vec<(f64, f64)>,
|
||||
pub min_altitude_m: f64,
|
||||
pub max_altitude_m: f64,
|
||||
/// Hard margin: triggers RTH immediately.
|
||||
pub hard_margin_m: f64,
|
||||
/// Soft margin: triggers warning + speed reduction.
|
||||
pub soft_margin_m: f64,
|
||||
}
|
||||
|
||||
/// Result of a geofence check.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum GeofenceResult {
|
||||
Safe,
|
||||
SoftWarning { distance_to_boundary_m: f64 },
|
||||
HardBreach,
|
||||
}
|
||||
|
||||
impl Geofence {
|
||||
/// Check a position against this geofence.
|
||||
pub fn check(&self, pos: &Position3D) -> GeofenceResult {
|
||||
let altitude_m = -pos.z; // NED: negative z = altitude above ground
|
||||
|
||||
// Altitude check
|
||||
if altitude_m < self.min_altitude_m || altitude_m > self.max_altitude_m {
|
||||
return GeofenceResult::HardBreach;
|
||||
}
|
||||
|
||||
let inside = self.point_in_polygon(pos.x, pos.y);
|
||||
let dist = self.distance_to_boundary(pos.x, pos.y);
|
||||
|
||||
if !inside {
|
||||
return GeofenceResult::HardBreach;
|
||||
}
|
||||
|
||||
if dist <= self.hard_margin_m {
|
||||
GeofenceResult::HardBreach
|
||||
} else if dist <= self.soft_margin_m {
|
||||
GeofenceResult::SoftWarning { distance_to_boundary_m: dist }
|
||||
} else {
|
||||
GeofenceResult::Safe
|
||||
}
|
||||
}
|
||||
|
||||
/// Ray-casting algorithm: even number of crossings = outside.
|
||||
fn point_in_polygon(&self, x: f64, y: f64) -> bool {
|
||||
let n = self.boundary.len();
|
||||
if n < 3 {
|
||||
return false;
|
||||
}
|
||||
let mut inside = false;
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
let (xi, yi) = self.boundary[i];
|
||||
let (xj, yj) = self.boundary[j];
|
||||
if ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// Minimum distance from (x, y) to any boundary edge.
|
||||
fn distance_to_boundary(&self, x: f64, y: f64) -> f64 {
|
||||
let n = self.boundary.len();
|
||||
if n == 0 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
let mut min_dist = f64::INFINITY;
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
let (ax, ay) = self.boundary[j];
|
||||
let (bx, by) = self.boundary[i];
|
||||
let dist = point_to_segment_dist(x, y, ax, ay, bx, by);
|
||||
if dist < min_dist {
|
||||
min_dist = dist;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
min_dist
|
||||
}
|
||||
}
|
||||
|
||||
fn point_to_segment_dist(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> f64 {
|
||||
let dx = bx - ax;
|
||||
let dy = by - ay;
|
||||
let len_sq = dx * dx + dy * dy;
|
||||
if len_sq < 1e-12 {
|
||||
return ((px - ax).powi(2) + (py - ay).powi(2)).sqrt();
|
||||
}
|
||||
let t = ((px - ax) * dx + (py - ay) * dy) / len_sq;
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let cx = ax + t * dx;
|
||||
let cy = ay + t * dy;
|
||||
((px - cx).powi(2) + (py - cy).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square_fence() -> Geofence {
|
||||
Geofence {
|
||||
boundary: vec![(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)],
|
||||
min_altitude_m: 0.0,
|
||||
max_altitude_m: 120.0,
|
||||
hard_margin_m: 10.0,
|
||||
soft_margin_m: 25.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_centre_is_safe() {
|
||||
let f = square_fence();
|
||||
let pos = Position3D { x: 50.0, y: 50.0, z: -30.0 };
|
||||
assert_eq!(f.check(&pos), GeofenceResult::Safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outside_is_hard_breach() {
|
||||
let f = square_fence();
|
||||
let pos = Position3D { x: 150.0, y: 50.0, z: -30.0 };
|
||||
assert_eq!(f.check(&pos), GeofenceResult::HardBreach);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_near_edge_is_soft_warning() {
|
||||
let f = square_fence();
|
||||
// 15m from boundary → beyond hard (10m) but within soft (25m)
|
||||
let pos = Position3D { x: 15.0, y: 50.0, z: -30.0 };
|
||||
assert!(matches!(f.check(&pos), GeofenceResult::SoftWarning { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_altitude_breach() {
|
||||
let f = square_fence();
|
||||
let pos = Position3D { x: 50.0, y: 50.0, z: -200.0 }; // 200m altitude
|
||||
assert_eq!(f.check(&pos), GeofenceResult::HardBreach);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! MAVLink v2 HMAC-SHA256 link-level signing.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Signs and verifies MAVLink v2 messages using HMAC-SHA256.
|
||||
pub struct MavlinkSigner {
|
||||
key: [u8; 32],
|
||||
link_id: u8,
|
||||
timestamp: AtomicU64,
|
||||
}
|
||||
|
||||
impl MavlinkSigner {
|
||||
pub fn new(key: [u8; 32], link_id: u8) -> Self {
|
||||
Self {
|
||||
key,
|
||||
link_id,
|
||||
timestamp: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance and return a monotonic 48-bit timestamp (units: 10 µs since epoch).
|
||||
fn next_timestamp(&self) -> u64 {
|
||||
self.timestamp.fetch_add(1, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Compute the 6-byte MAVLink v2 signature.
|
||||
/// Signature = first 6 bytes of HMAC-SHA256(key, link_id || timestamp_6bytes || message_bytes)
|
||||
pub fn sign(&self, message_bytes: &[u8]) -> [u8; 6] {
|
||||
let ts = self.next_timestamp();
|
||||
let ts_bytes = ts.to_le_bytes(); // 8 bytes, MAVLink uses 6 but we include all for simplicity
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(&self.key)
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(&[self.link_id]);
|
||||
mac.update(&ts_bytes[..6]);
|
||||
mac.update(message_bytes);
|
||||
|
||||
let result = mac.finalize().into_bytes();
|
||||
let mut sig = [0u8; 6];
|
||||
sig.copy_from_slice(&result[..6]);
|
||||
sig
|
||||
}
|
||||
|
||||
/// Verify that `signature` is valid for `message_bytes`.
|
||||
/// This implementation re-computes against all recent timestamps within a
|
||||
/// small window (for demo/test). Production code should maintain a timestamp
|
||||
/// window per link_id.
|
||||
pub fn verify(&self, message_bytes: &[u8], signature: &[u8; 6]) -> bool {
|
||||
let current_ts = self.timestamp.load(Ordering::SeqCst);
|
||||
// Check ±32 timestamps to handle reordering in tests
|
||||
let start = current_ts.saturating_sub(32);
|
||||
for ts in start..=current_ts + 1 {
|
||||
let ts_bytes = ts.to_le_bytes();
|
||||
let mut mac = HmacSha256::new_from_slice(&self.key)
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(&[self.link_id]);
|
||||
mac.update(&ts_bytes[..6]);
|
||||
mac.update(message_bytes);
|
||||
let result = mac.finalize().into_bytes();
|
||||
if &result[..6] == signature.as_ref() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sign_produces_6_bytes() {
|
||||
let signer = MavlinkSigner::new([0xABu8; 32], 0);
|
||||
let sig = signer.sign(b"heartbeat");
|
||||
assert_eq!(sig.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_correct_signature() {
|
||||
let signer = MavlinkSigner::new([0x42u8; 32], 1);
|
||||
let msg = b"test_message";
|
||||
let sig = signer.sign(msg);
|
||||
assert!(signer.verify(msg, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_wrong_key_fails() {
|
||||
let signer1 = MavlinkSigner::new([0x01u8; 32], 1);
|
||||
let signer2 = MavlinkSigner::new([0x02u8; 32], 1);
|
||||
let msg = b"test_message";
|
||||
let sig = signer1.sign(msg);
|
||||
// signer2 has a different key — can't verify signer1's sig
|
||||
assert!(!signer2.verify(msg, &sig));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Security: MAVLink signing, UWB anti-spoofing, geofencing, Remote ID, FHSS anti-jamming.
|
||||
|
||||
pub mod mavlink_signing;
|
||||
pub mod uwb_antispoofing;
|
||||
pub mod geofence;
|
||||
pub mod remote_id;
|
||||
pub mod antijamming;
|
||||
|
||||
pub use mavlink_signing::MavlinkSigner;
|
||||
pub use uwb_antispoofing::UwbAntiSpoofing;
|
||||
pub use geofence::{Geofence, GeofenceResult};
|
||||
pub use remote_id::RemoteIdBroadcast;
|
||||
pub use antijamming::{FhssConfig, FhssRadio};
|
||||
@@ -0,0 +1,83 @@
|
||||
//! ASTM F3411 Remote ID broadcast (Basic ID + Location/Vector message).
|
||||
|
||||
use crate::types::DroneState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Remote ID broadcast state for one drone.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteIdBroadcast {
|
||||
pub uas_id: [u8; 20], // 20-byte UAS ID (ANSI/CTA-2063-A)
|
||||
pub operator_lat: f64,
|
||||
pub operator_lon: f64,
|
||||
pub drone_lat: f64,
|
||||
pub drone_lon: f64,
|
||||
pub altitude_msl_m: f32,
|
||||
pub speed_ms: f32,
|
||||
pub heading_deg: f32,
|
||||
pub timestamp_ms: u64,
|
||||
pub emergency_status: bool,
|
||||
}
|
||||
|
||||
impl RemoteIdBroadcast {
|
||||
pub fn new(uas_id: [u8; 20]) -> Self {
|
||||
Self {
|
||||
uas_id,
|
||||
operator_lat: 0.0,
|
||||
operator_lon: 0.0,
|
||||
drone_lat: 0.0,
|
||||
drone_lon: 0.0,
|
||||
altitude_msl_m: 0.0,
|
||||
speed_ms: 0.0,
|
||||
heading_deg: 0.0,
|
||||
timestamp_ms: 0,
|
||||
emergency_status: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update from a drone state and operator position.
|
||||
pub fn update(&mut self, state: &DroneState, operator_pos: (f64, f64)) {
|
||||
// Convert NED position to approximate lat/lon (placeholder — real impl uses WGS84).
|
||||
// We store the NED metres as placeholder values here.
|
||||
self.drone_lat = state.position.x; // placeholder: x ≈ north offset
|
||||
self.drone_lon = state.position.y; // placeholder: y ≈ east offset
|
||||
self.altitude_msl_m = state.altitude_agl_m as f32;
|
||||
self.speed_ms = state.velocity.magnitude() as f32;
|
||||
self.heading_deg = state.heading_rad.to_degrees() as f32;
|
||||
self.timestamp_ms = state.timestamp_ms;
|
||||
self.operator_lat = operator_pos.0;
|
||||
self.operator_lon = operator_pos.1;
|
||||
}
|
||||
|
||||
/// Encode a 25-byte ASTM F3411 Basic ID message.
|
||||
/// Format: [message_type(1)] [id_type(1)] [uas_id(20)] [reserved(3)]
|
||||
pub fn encode_basic_id(&self) -> [u8; 25] {
|
||||
let mut buf = [0u8; 25];
|
||||
buf[0] = 0x00; // Message type: Basic ID
|
||||
buf[1] = 0x01; // ID type: Serial Number
|
||||
buf[2..22].copy_from_slice(&self.uas_id);
|
||||
// bytes 22-24: reserved
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encode_basic_id_length() {
|
||||
let rid = RemoteIdBroadcast::new([0x41u8; 20]);
|
||||
let buf = rid.encode_basic_id();
|
||||
assert_eq!(buf.len(), 25);
|
||||
assert_eq!(buf[1], 0x01); // ID type: serial number
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uas_id_in_encoded_buffer() {
|
||||
let mut id = [0u8; 20];
|
||||
id[0] = 0xFF;
|
||||
let rid = RemoteIdBroadcast::new(id);
|
||||
let buf = rid.encode_basic_id();
|
||||
assert_eq!(buf[2], 0xFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! UWB-based GPS anti-spoofing: cross-validates GPS position against UWB ranging.
|
||||
|
||||
use crate::types::{NodeId, Position3D};
|
||||
|
||||
/// Cross-validates GPS against UWB ranging to neighbours.
|
||||
pub struct UwbAntiSpoofing {
|
||||
/// Tolerance for GPS vs UWB distance discrepancy, metres.
|
||||
pub tolerance_m: f64,
|
||||
/// Minimum number of UWB neighbours required for a valid cross-check.
|
||||
pub min_neighbors: usize,
|
||||
}
|
||||
|
||||
impl UwbAntiSpoofing {
|
||||
pub fn new(tolerance_m: f64, min_neighbors: usize) -> Self {
|
||||
Self { tolerance_m, min_neighbors }
|
||||
}
|
||||
|
||||
/// Returns `true` if the GPS position is consistent with UWB ranging data.
|
||||
pub fn is_gps_valid(
|
||||
&self,
|
||||
gps_position: &Position3D,
|
||||
uwb_ranges: &[(NodeId, f64)],
|
||||
neighbor_gps: &[(NodeId, Position3D)],
|
||||
) -> bool {
|
||||
if uwb_ranges.len() < self.min_neighbors {
|
||||
// Not enough UWB anchors to validate — allow through with warning
|
||||
return true;
|
||||
}
|
||||
|
||||
let validated_count = uwb_ranges
|
||||
.iter()
|
||||
.filter_map(|(id, uwb_dist)| {
|
||||
neighbor_gps
|
||||
.iter()
|
||||
.find(|(nid, _)| nid == id)
|
||||
.map(|(_, ngps)| {
|
||||
let gps_dist = gps_position.distance_to(ngps);
|
||||
(gps_dist - uwb_dist).abs() <= self.tolerance_m
|
||||
})
|
||||
})
|
||||
.filter(|&ok| ok)
|
||||
.count();
|
||||
|
||||
// Require majority of ranges to be consistent
|
||||
validated_count * 2 >= uwb_ranges.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UwbAntiSpoofing {
|
||||
fn default() -> Self {
|
||||
Self::new(2.0, 2)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_consistent_gps_valid() {
|
||||
let anti = UwbAntiSpoofing::new(2.0, 2);
|
||||
let gps = Position3D { x: 0.0, y: 0.0, z: 0.0 };
|
||||
let n1_pos = Position3D { x: 10.0, y: 0.0, z: 0.0 };
|
||||
let n2_pos = Position3D { x: 0.0, y: 10.0, z: 0.0 };
|
||||
let uwb_ranges = vec![(NodeId(1), 10.0), (NodeId(2), 10.0)];
|
||||
let neighbor_gps = vec![(NodeId(1), n1_pos), (NodeId(2), n2_pos)];
|
||||
assert!(anti.is_gps_valid(&gps, &uwb_ranges, &neighbor_gps));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spoofed_gps_invalid() {
|
||||
let anti = UwbAntiSpoofing::new(2.0, 2);
|
||||
// GPS claims (0,0) but UWB says drone is 50m from both neighbours
|
||||
let gps = Position3D { x: 0.0, y: 0.0, z: 0.0 };
|
||||
let n1_pos = Position3D { x: 10.0, y: 0.0, z: 0.0 };
|
||||
let n2_pos = Position3D { x: 0.0, y: 10.0, z: 0.0 };
|
||||
// UWB reports 50m but GPS only shows 10m — spoof detected
|
||||
let uwb_ranges = vec![(NodeId(1), 50.0), (NodeId(2), 50.0)];
|
||||
let neighbor_gps = vec![(NodeId(1), n1_pos), (NodeId(2), n2_pos)];
|
||||
assert!(!anti.is_gps_valid(&gps, &uwb_ranges, &neighbor_gps));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user