Defense-in-depth pass on companion safety - #1
Open
gasantiago16 wants to merge 3 commits into
Open
gasantiago16 wants to merge 3 commits into
gasantiago16 wants to merge 3 commits into
Conversation
Closes ten issues found by the v0.3 logic ultrareview. The keystone safety
invariant ("MSP_SET_RAW_RC sent only when state is_active(); silent companion
→ BF 500ms timeout → RX takeover") was already correct. This PR tightens the
second-tier checks the docs claimed but the code didn't fully enforce.
Critical bug fixes
- safety.evaluate now blocks on `analog is None`, mirroring the GPS pattern;
a loose VBAT pad would have silently passed the gate.
- state machine: HOLD now re-engages TRANSIT when drift exceeds
arrival_radius_m * 1.5. Wind in HOLD used to hold centered sticks while
drifting indefinitely.
- safety.max_distance_from_target_during_transit_m was a dead config field;
now wired as a `transit_runaway_*m` reason while in TRANSIT. Documented
protection that didn't exist.
- nav.compute_rc(climb_phase=True) zeros roll/pitch/yaw during CLIMB until
current alt >= 80% of target. Removes the downhill-takeoff treeline hazard.
New defense-in-depth checks
- HeadingDivergenceTracker — sliding-window watchdog catches mag-NONE yaw
drift before geofence does (geofence triggers only after lateral drift
exceeds the radius, by which time the drone is far off-bearing).
- stale_rc / no_rc_telemetry safety reasons — companion now reports MSP_RC
outage instead of silently defaulting aux_active to False with no signal.
- FlightModeReader (MSP_STATUS_EX + MSP_BOXNAMES) — companion subscribes,
decodes ANGLE/HORIZON box bits, refuses overrides if BF reports ACRO.
Fail-open until BOXNAMES received; safelock.lua remains primary defense.
Tests + tooling
- scripts/test_lua_logic.py — drives racestrt.lua's state machine and
safelock.lua's threshold logic via lupa with controlled time. Catches
transition bugs the parse-only check missed. 11 logic tests, wired into CI.
- MAVLink: STATUSTEXT byte-for-byte vs pymavlink 2.4.49, COMPANION_STATE
frozen reference vector, HEARTBEAT + STATUSTEXT pymavlink round-trip.
- sitl/start.sh — hard-fails if `msp_override_channels_mask = 15` or
`mag_hardware = NONE` are missing from the post-defaults dump. Was a
best-effort `nc | echo WARNING` before.
Test counts: 71 → 105 Python (102 always + 3 SITL-gated) + 11 Lua logic.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Six additions, each independently reviewed by a separate agent for bugs; agents found 11 real issues (one per finding listed below) which are also fixed in this commit. ## Items shipped 1. **Pre-flight config validator** (`scripts/preflight_validator.py`). Diff a per-drone manifest against an FC `dump`, exit 1 on mismatch. 18 tests. *Reviewer found:* `#`-stripping corrupted values containing `#` (`set craft_name = jacob#1`); blanket `value.upper()` made case-sensitive name fields silently match wrong-case values. Both fixed: comments only at line start; case-folding only for uniformly- cased single-token enum values. 2. **Telemetry recorder + replayer** (`racer_companion/recorder.py`, `tools/replay.py`). Tee MSP bytes to JSONL during a flight; replay through MspClient parser offline. 10 tests. *Reviewer found:* file order trusted (out-of-order events bypass pacing); non-monotonic `time_source` silently stalls; disk-full kills the FC link; malformed JSONL lines abort the load. All fixed: events sorted by timestamp on load, elapsed clamped to >=0 (with NaN→inf for drain mode), `_emit` wrapped in try/except OSError, malformed lines skipped with continue. 3. **FC adapter pattern** (`racer_companion/fc/`). FlightController Protocol + TelemetrySnapshot + BetaflightAdapter wrapping MspClient + FlightModeReader. main.py refactored to consume. 12 tests. *Reviewer found:* only MSP_STATUS_EX decoder was wrapped — a malformed GPS/ATTITUDE/etc frame would have killed the loop; BOXNAMES log line was lost in the refactor; snapshot returned by reference (callers retaining across ticks would see back-mutation). All fixed: every decoder branch wrapped, BOXNAMES log restored, `tick()` now returns `dataclasses.replace(self._snapshot)`. 4. **Property-based safety tests** (`tests/test_safety_properties.py`). hypothesis sampling for clamp invariants, evaluate() totality, and tracker invariants. *Reviewer found:* `test_ok_iff_no_reasons` was vacuous — `received_at` and `now` independent uniforms made stale telemetry universal, so the `ok=True` branch was unreachable; `test_no_exceptions_on_any_input` left geofence/transit/divergence defaulted; GPS strategy excluded poles/antimeridian; `test_never_trips _below_threshold` only sampled |err|<30 against a 60° threshold. Fixed: `FRESH_RECEIVED_AT` strategy anchored within staleness window, all evaluate() args randomized, lat/lon include ±90 / ±180, sub-threshold strategy widened to ±59. Hypothesis pinned >=6.100. 5. **Wind correction in HOLD** (`nav.py: hold_command_us`, `position_error_body_m`). Body-frame P controller for HOLD with small gains (8 us/m, ±60us cap) — drone now lazily opposes wind drift instead of holding centered sticks. 11 tests. *Reviewer* independently rederived the body-frame math and confirmed signs. *Reviewer found:* no NaN/Inf guard would surface as a `ValueError` from `int(round(NaN))`; sign-correctness untested for heading 180°/270°. Fixed: `math.isfinite` guard returns centered sticks on bad input; explicit tests added for the high-risk headings. 6. **Heading-threshold tuning harness** (`scripts/tune_heading _threshold.py`). Replay a JSONL flight log through the tracker across a grid of thresholds × windows; report two metrics. 12 tests. *Reviewer found:* degree-sign broke Windows console (cp1252); `load_log()` aborted on a single torn last line; `--log` and `--synthetic` not mutually exclusive; trip-count alone was misleading (a flapper at 40°/2s with 12 brief trips looks the same as a sustained trip). Fixed: ASCII-only output, per-line tolerance with stderr summary, mutually-exclusive argparse group, second metric `tripped_seconds` rendered as its own table. ## Test counts - companion: 105 → 154 always-run (+ 3 SITL-gated, unchanged) - scripts: 11 Lua logic + 18 preflight + 12 tune-heading ## CI `scripts-tests` job (renamed from `lua-syntax`) now runs Lua syntax, Lua logic, preflight tests, and tune-heading tests. `python-tests` installs `[dev]` extras (hypothesis, pymavlink) for property-based and round-trip MAVLink tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A holistic cross-cutting review (the 7th agent, after the six per-item reviewers) found one critical bug and several documentation gaps the per-item reviewers couldn't see. Fixes here. ## Critical fix **HeadingDivergenceTracker was silent dead code in default config.** main.py built `pitching_forward = (state==TRANSIT and |err| < 25°)`, combined with the tracker's 60° trip threshold meant the tracker was only fed samples whose mean was bounded by 25 — it could NEVER trip. The mag-NONE drift watchdog advertised in the v0.4 commit was silent. Fix: `transit_active = (state == TRANSIT)` only. The error-magnitude gate is removed; the tracker now sees every TRANSIT-state sample, which is what the threshold check is for. Same fix applied in `scripts/tune_heading_threshold.py` so the tuning grid reproduces what the live tracker actually sees. Sharp-edge entry added in MEMORY.md so future maintainers don't re-introduce the gate. A new end-to-end test (`test_recorder_fc_integration.py`) drives the same boolean main.py builds, confirming the tracker DOES trip on sustained 90° error in TRANSIT. ## Other fixes - **`tools/replay.py` invocation**: docstring said `python -m racer_companion.tools.replay`, which doesn't exist. Corrected to `python -m tools.replay` (matching the `tools/` package layout used by `msp_loopback_test.py` and `replay_synth.py`). - **`RecordingAdapter` is now wired into main.py via `--record <path>`.** Previously the recorder was implemented and tested but unreachable from the running binary. `BetaflightAdapter.open()` accepts an optional `record_path` and composes the chain `serial → RecordingAdapter → MspClient → adapter`. - **`is_acro_active()` docstring** said "armed and neither ANGLE nor HORIZON active." Code never consulted ARM. Docstring corrected to match (intentional fail-active behavior, paired with safelock.lua upstream). - **`bf_config/per_drone/example_manifest.txt`** added — the floor every drone must satisfy. Plus a "Pre-flight validation" section in the per-drone README so a new pilot has an on-ramp. - **`companion/README.md`** now lists `fc/`, `recorder.py`, `tools/replay.py`, and the `--record` flag on main.py. - **MEMORY.md** v0.4 → v0.5: snapshot table updated with the 6 new items + correct test count (105 → 159 unit + 9 hypothesis); 6 new sharp-edge entries (heading-tracker gate, is_acro arm-check absence, tools layout, snapshot copy contract, recorder error-swallow, validator normalize semantics); pymavlink "NOT done" row qualified as dev-dep only. ## New tests - `test_main_smoke.py` — imports `racer_companion.main`, `racer_companion.fc.betaflight`, `racer_companion.recorder` to catch typos / circular-import regressions that would only surface on the Pi at startup. - `test_recorder_fc_integration.py` — round-trips a recorded byte stream through `RecordingAdapter → MspClient → BetaflightAdapter`, then back through `ReplayAdapter`. Plus the heading-tracker end-to-end test described above. ## Test counts - companion: 154 → 159 always-run (+ 3 SITL-gated) - scripts: unchanged (11 Lua + 18 preflight + 12 tune) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
gasantiago16
pushed a commit
that referenced
this pull request
Apr 28, 2026
End-of-night doc pass after landing the two-stage ARM_SWITCH recovery in `pegasus-bridge`. Captures what changed and what's still open. - MEMORY.md: new v0.7 snapshot at top documenting the latch fix path (shim send-lock + two-stage recovery + failsafe_delay=200), the mission4 artifact (drone survives 6 latches over 220 s, completes X_LEG_1→X_LEG_2→X_LEG_3→TO_CENTER), and four new sharp edges. - HITL_SIM_HANDOFF.md: rewrote § 2 (ARM_SWITCH latch) marking it FIXED, with the full root-cause and recovery-flow explanation. - TODO.md (new): ranked open items. #1 is the recurring ~18 s RX-stall under sustained load — recovery handles it but it costs leg accuracy. Recommended diagnostic next step is per-tick wall delta logging in shim's _consume_requests. - mission{2,3,4}.png/.csv: in-repo artifacts of the flight progression (mission2/3 = pre-fix latch fatal, mission4 = post-fix recoverable).
gasantiago16
pushed a commit
that referenced
this pull request
Apr 28, 2026
TODO #1(a) from yesterday's wrap-up. mission_demo now sends the same RC values to both MSP TCP 5761 AND UDP 9004 every tick. BF's RX state machine treats the UDP path as "real RX", clearing the chronic arming-disable bit-2 set that drove the original 25-30 s ARM_SWITCH latch cycle. Empirical: mission7.png + mission7_md.log show 0 RX_FAILSAFE hits in 240 s of [rc] logs vs mission6's 42 hits. Single-source-of-truth design — both paths read the same `rc[]` from compute_rc, no precedence fight. The drone's leg-arrival behavior is still the same as mission6 because a SECOND failsafe trigger surfaced once we silenced bit-2: bit-1 FAILSAFE pulses on a ~10 s cycle without bit-2 ever appearing. Recovery flow handles each. Source unknown — queued as TODO.md item 2 with a concrete diagnostic recipe. Files: - integrations/tools/mission_demo.py: +socket, +struct, _UDP_RC_PACKET pin (<d16H, 40 B asserted), --udp-rc-host/--udp-rc-port/--no-udp-rc CLI args, connect()-pattern socket setup, dual-send block right after fc.send_overrides(rc), error counter with gated logging, socket close in finally. Default ON. Pre-existing duplicate `rel =` line cleaned up while we were there. - integrations/tests/test_mission_demo.py: docstring "(bit 7)"→ "(bit 3)" fix. Constant was already right; just a stale comment. - MEMORY.md: v0.8 snapshot at top with 0-vs-42 measurement, new sharp edge ("two bit-1 FAILSAFE triggers exist"), wire-format- pinned-in-three-places warning. - TODO.md: closed item 1 (chronic RX_FAILSAFE), opened item 2 (residual bit-1 FAILSAFE). Renumbered remaining items. Counter-agent reviewed twice — once mid-session on the patch alone, once on the consolidated diff. Findings applied or queued.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the ten issues from the v0.3 logic ultrareview. The keystone safety invariant — MSP_SET_RAW_RC sent only when
state.is_active(); silent companion → BF 500ms timeout → RX takeover — was already correct. This PR tightens the second-tier checks the docs implied but the code didn't fully enforce.Critical bug fixes
analog=Nonesilently passed safety (safety.py:66) — a loose VBAT pad would have flown a tired pack into a tree. Now mirrors the GPS pattern withno_analog_telemetry+stale_analog.state.py) — wind drift kept centered sticks while the drone drifted out of the hold disc. Now re-engages TRANSIT pastarrival_radius_m × 1.5with hysteresis to prevent ping-pong.max_distance_from_target_during_transit_mwas a dead config field — declared, never consumed. Now wired astransit_runaway_*mreason while in TRANSIT.nav.compute_rc(climb_phase=True)now zeros roll/pitch/yaw until current alt ≥ 80% of target. Removes the downhill-takeoff treeline hazard.New defense-in-depth checks
HeadingDivergenceTracker— sliding-window watchdog catches mag-NONE yaw drift before geofence does. Trips when forward-flight|heading_err|mean exceeds 60° for 3s.stale_rc/no_rc_telemetry— companion now publishes MSP_RC outage instead of silently defaultingaux_active=False.FlightModeReader— subscribes MSP_STATUS_EX + MSP_BOXNAMES, decodes ANGLE/HORIZON box bits, refuses overrides if BF reports ACRO. Fail-open until BOXNAMES received;safelock.luaremains primary defense.Tests + tooling
scripts/test_lua_logic.py— drivesracestrt.luastate machine andsafelock.luathreshold logic via lupa with controlled time. Catches transition bugs the parse-only check missed. 11 logic tests, wired into CI alongside the syntax check.sitl/start.shnow reads back the dump and exits 1 ifmsp_override_channels_mask = 15ormag_hardware = NONEare missing. Was best-effortnc | echo WARNINGbefore — would have let the smoke test "pass" with overrides on AUX channels unbounded.Test counts
Test plan
[sitl] OK: msp_override_channels_mask = 15)transit_runawaycap (default 200m) — verify no false trips on legit racing distances; tune if needed🤖 Generated with Claude Code