Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion bindings/CreateTimerRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,10 @@ node_count?: number,
* The new timer's **available channels** in raw MHz (race redesign Slice 4a). Optional;
* defaults to empty (none configured).
*/
available_channels?: Array<number>, };
available_channels?: Array<number>,
/**
* The new timer's **gate-bounce window** in µs (#517) — see
* [`Timer::same_pass_window_micros`]. Optional; omit it (or send `0`) for no bounce rule.
* [`DEFAULT_SAME_PASS_WINDOW_MICROS`] is what the console offers, not what the server assumes.
*/
same_pass_window_micros?: number, };
4 changes: 2 additions & 2 deletions bindings/CrossingDisposition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
* So a disposition is a **position in the corrected pass chain**, or the removal record the fold
* already keeps — never a new logged fact.
*
* The two removal-side variants map 1:1 onto the only two [`VoidReason`]s that exist.
* Every removal-side variant maps 1:1 onto the [`VoidReason`] that produced it.
*/
export type CrossingDisposition = "Holeshot" | "Counted" | "RejectedTooShort" | "VoidedByMarshal" | "RejectedAfterRaceEnd";
export type CrossingDisposition = "Holeshot" | "Counted" | "RejectedTooShort" | "RejectedSamePass" | "VoidedByMarshal" | "RejectedAfterRaceEnd";
11 changes: 10 additions & 1 deletion bindings/Event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,16 @@ heat: HeatId,
* unbounded grace (the engine's `GraceWindow::UntilScored`): the heat then closes only
* on the all-crossed rule or the RD's `ForceEnd`.
*/
deadline?: number, } } | { "DetectionVoided": { target: LogRef, } } | { "LapInserted": { adapter: AdapterId, competitor: CompetitorRef, at: SourceTime,
deadline?: number, } } | { "HeatDetectionPinned": {
/**
* The heat this config was pinned for (it is entering `Armed`).
*/
heat: HeatId,
/**
* The **gate-bounce window** (µs) in force for this run: two lap-gate crossings by the
* same competitor closer together than this are one physical pass. `None` for no rule.
*/
same_pass_window_micros?: number, } } | { "DetectionVoided": { target: LogRef, } } | { "LapInserted": { adapter: AdapterId, competitor: CompetitorRef, at: SourceTime,
/**
* The heat the inserted lap belongs to. Unlike a raw [`Pass`] (an untagged wire
* observation attributed positionally), an insertion is an RD statement **about a
Expand Down
28 changes: 27 additions & 1 deletion bindings/Timer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,30 @@ calibration: Array<NodeCalibration>,
* an empty record is omitted entirely, so a pre-#413 `timers.json` restores with none and a
* console (or a test fixture) written before this existed still parses a `Timer`.
*/
node_channels?: Array<NodeChannel>, };
node_channels?: Array<NodeChannel>,
/**
* The **gate-bounce window** (µs): two lap-gate crossings by the same competitor closer
* together than this are **the same physical pass**, detected twice (#517).
*
* A quad in the gate's near field fires the detector several times per crossing — antenna
* reflections milliseconds apart. That is a property of *this gate*: its antenna, its RSSI
* thresholds, where it sits on the track. So it lives on the timer, not on the round.
*
* **Not the same thing as the min-lap floor.** `RoundDef::min_lap_secs` is a *competition
* rule* — "a lap shorter than this does not count" — and it is the round's to set. This is a
* *hardware fact* — "that was not a second crossing at all". Conflating them is what made a
* reflection burst indistinguishable from a genuinely short lap in the marshaling list.
*
* `None` (the default, and what an older `timers.json` restores with) means no bounce rule:
* every crossing stands on its own and only the round's floor judges it.
*
* Capped at [`MAX_SAME_PASS_WINDOW_MICROS`] and normalized so `0` reads as `None`. The cap is
* what makes the setting safe to change: no real lap can be that short, so the window can only
* ever collapse a multi-detection of one pass, never eat a lap somebody flew.
*
* **Read at the arm, not at the fold.** The value in force when a heat arms is stamped onto
* the log ([`Event::HeatDetectionPinned`](gridfpv_events::Event::HeatDetectionPinned)) and the
* corrected fold reads it from there — so editing this never re-judges a race that has already
* run. See #518 for the general rule.
*/
same_pass_window_micros?: number, };
8 changes: 7 additions & 1 deletion bindings/UpdateTimerRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,10 @@ node_count?: number,
* A new **available-channels** set in raw MHz (race redesign Slice 4a), or `None` to leave it
* unchanged.
*/
available_channels?: Array<number>, };
available_channels?: Array<number>,
/**
* A new **gate-bounce window** in µs (#517), or `None` to leave it unchanged. Send `0` to turn
* the bounce rule off — unlike the node-count override this needs no separate clear route,
* because zero already spells "no window" (the same idiom `RoundDef::min_lap_secs` uses).
*/
same_pass_window_micros?: number, };
2 changes: 1 addition & 1 deletion bindings/VoidReason.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
/**
* Why a pass sits on the removal record instead of the lap chain.
*/
export type VoidReason = "Marshal" | "UnderMinLap" | "AfterRaceEnd";
export type VoidReason = "Marshal" | "UnderMinLap" | "SamePassBounce" | "AfterRaceEnd";
1 change: 1 addition & 0 deletions crates/adapters/tests/rh_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ fn event_kind(e: &Event) -> &'static str {
Event::HeatStarting { .. } => "HeatStarting",
Event::HeatFinalizing { .. } => "HeatFinalizing",
Event::RaceExpired { .. } => "RaceExpired",
Event::HeatDetectionPinned { .. } => "HeatDetectionPinned",
Event::DetectionVoided { .. } => "DetectionVoided",
Event::LapInserted { .. } => "LapInserted",
Event::LapAdjusted { .. } => "LapAdjusted",
Expand Down
5 changes: 5 additions & 0 deletions crates/app/src/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2328,6 +2328,7 @@ mod tests {
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.unwrap();
// The timer reports four nodes; the RD has switched off the third one ("Node 3" = index 2).
Expand Down Expand Up @@ -2440,6 +2441,7 @@ mod tests {
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.unwrap();
// Select only the RotorHazard timer for Practice.
Expand Down Expand Up @@ -2493,6 +2495,7 @@ mod tests {
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.unwrap();
registry
Expand Down Expand Up @@ -3530,6 +3533,7 @@ mod tests {
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.unwrap()
.id
Expand Down Expand Up @@ -3619,6 +3623,7 @@ mod tests {
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.unwrap()
.id;
Expand Down
1 change: 1 addition & 0 deletions crates/app/src/source/failover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ mod tests {
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.unwrap();
let event = EventId(
Expand Down
2 changes: 2 additions & 0 deletions crates/app/src/source/rh_connections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,7 @@ mod tests {
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.expect("timer created")
.id
Expand Down Expand Up @@ -1150,6 +1151,7 @@ mod tests {
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.expect("timer created")
.id;
Expand Down
3 changes: 3 additions & 0 deletions crates/app/tests/rh_connect_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ async fn director_connects_rotorhazard_on_selection_and_keeps_it_connected_throu
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.expect("create RH timer");
// A freshly-configured RH timer rests at `Configured` until the Director connects it.
Expand Down Expand Up @@ -550,6 +551,7 @@ async fn director_fails_over_from_a_dropped_rh_primary_to_a_mock_alternate() {
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.expect("create RH timer");

Expand Down Expand Up @@ -718,6 +720,7 @@ async fn editing_a_live_timers_url_in_place_re_dials_without_a_restart() {
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.expect("create RH timer at the wrong URL");
registry
Expand Down
1 change: 1 addition & 0 deletions crates/app/tests/rh_no_plugin_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ async fn a_plugin_less_rotorhazard_connects_but_grid_refuses_to_race_it_and_says
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.expect("create the RH timer");

Expand Down
1 change: 1 addition & 0 deletions crates/app/tests/rh_restart_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ async fn restarting_a_timer_reexecutes_rotorhazard_and_the_director_reconnects_a
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.expect("create RH timer");
registry
Expand Down
1 change: 1 addition & 0 deletions crates/app/tests/rh_tune_write_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ async fn bench(port: u16) -> Bench {
channel_capability: None,
node_count: None,
available_channels: None,
same_pass_window_micros: None,
})
.expect("create RH timer")
.id;
Expand Down
31 changes: 31 additions & 0 deletions crates/events/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,37 @@ pub enum Event {
#[ts(optional, type = "number")]
deadline: Option<i64>,
},
/// The **detection config in force for this run**, pinned at the arm (#517).
///
/// The gate-bounce window (`Timer::same_pass_window_micros`) is *timer* config, and a timer is
/// editable at any time — unlike a round, which freezes the moment it races. Read live, the
/// corrected fold would therefore re-judge finished races every time an RD nudged a slider on
/// the Timers page. So the value in force is written here, **once, when the heat arms**, and
/// the fold reads it from the log instead: an edit changes the next run, never a past one.
///
/// Same shape as [`HeatStarting`](Event::HeatStarting) / [`HeatFinalizing`](Event::HeatFinalizing)
/// / [`RaceExpired`](Event::RaceExpired) — the runtime resolves a value once, at emission time,
/// and logs it as a fact so a replay reads the same answer instead of re-deriving it. Same idea
/// as [`HeatLayoutSet`](Event::HeatLayoutSet), too, which pins the resolved channel layout at
/// the *stage* for exactly this reason (#478). The general rule is #518.
///
/// **Why the arm and not the stage:** arming is the moment the gate opens to detections, and it
/// is the last point before a pass can arrive. `(Staged, Start) -> Armed` is the only arm in the
/// heat FSM (`heat::apply`), so every run passes through here exactly once — and a re-arm after
/// an Abort or Restart re-pins, which is correct: it is a new run.
///
/// **Absent means no bounce rule** — a heat armed before this existed, or one whose timer has
/// no window set. The fold then judges crossings on the round's min-lap floor alone, exactly as
/// it did before, so an older log folds bit-identically.
HeatDetectionPinned {
/// The heat this config was pinned for (it is entering `Armed`).
heat: HeatId,
/// The **gate-bounce window** (µs) in force for this run: two lap-gate crossings by the
/// same competitor closer together than this are one physical pass. `None` for no rule.
#[serde(default)]
#[ts(optional, type = "number")]
same_pass_window_micros: Option<i64>,
},
/// Marshaling: void a previously-detected pass, referenced by log offset. The
/// projection folds it out as if it never happened — the raw [`Pass`] stays in
/// the log untouched.
Expand Down
Loading