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
21 changes: 13 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,35 @@ Beam402 is an open source drag racing timing system: beam sensors, Christmas
tree, ET / 60ft / trap speed measurement, and race control software, built
from industrial off-the-shelf parts.

**The repository currently contains no code.** It is design documentation at
the pre-validation stage: architecture, a decision log, a software design, and a
prototype BOM. There is nothing to build, lint, or test — do not invent build
commands or claim that any subsystem works.
**No hardware exists.** Nothing here has run against a beam, a node or a tree,
and no number any of it prints was measured — every one came from a scenario
file that stated it. Do not claim a subsystem works because its tests pass.

The stack is decided but unwritten: **C on ESP-IDF** for node and tree firmware
Race control, however, **is written and does run**: `software/` is a Rust
workspace — `cargo test` in it, `cargo run -p beam402 -- <subcommand>` for the
CLI, `cargo clippy --all-targets`. Firmware is still unwritten.

The firmware stack is decided but unwritten: **C on ESP-IDF** for node and tree
(`D22`, status *revisit* — chosen so the gating `T3` measurement carries one
fewer unknown, **not** because Rust cannot do it: the `esp32s3` PAC exposes the
capture and sync registers, and a Rust node becomes admissible the moment it
reproduces the T3 number on the same rig), **Rust** for race control as a single
binary that also serves the scoreboard (`D23`), **Python** for bench tooling
only, and KiCad for hardware. `.gitignore` reserves space accordingly.

Until bench validation passes, **the design documents *are* the project** — so
edits to them are the substantive work, not paperwork around it.
Until bench validation passes, the design documents carry as much of the project
as the code does — so edits to them are substantive work, not paperwork around
it.

| File | Role |
|---|---|
| `docs/architecture.md` | Full system design, §11 = ranked list of unverified assumptions, §12 = deployment stages |
| `docs/decisions.md` | ADRs `D01`–`D36`: context → decision → why → what would change it |
| `docs/decisions.md` | ADRs `D01`–`D37`: context → decision → why → what would change it |
| `docs/bench-validation.md` | The current stage: rig construction, tests `T1`–`T5`, pass/fail criteria |
| `docs/software.md` | Software architecture: program boundaries, poll strategy, build order, §8 = software-side open questions |
| `docs/protocol.md` | Modbus register map and mapping file format — the contract between firmware and race control |
| `hardware/BOM.md` | v0 prototype BOM (bench + parking-lot demo), organized by supplier basket |
| `software/` | The Rust workspace: protocol, mapping, simulator, poller, race logic, the event layer, the HTTP server and the `beam402` CLI |
| `events/` | An entry sheet, a season skeleton and a registration CSV — the format a club fills in (**D34**) |
| `deploy/` | Reference way to run a results receiver: reverse proxy for TLS, unit file, loopback binding (**D33**) |
| `README.md` / `README.ru.md` | English canonical, Russian overview |
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,20 @@ already a complete working system).
competition has something to count and a mirror can say why somebody lost.
An official calls the fouls the beams cannot see, voids a pass and takes a
car out of a class from the panel, in their own rulebook's words
- [x] A day in qualifying has a **board**, derived by the draw's own arithmetic
so it cannot disagree with the ladder people end up in: where each car
stands, its passes, where the cut falls, and no provisional place for one
that has not run. A practice day used to produce no output at all
- [x] A finished class publishes **every round it ran**, not the last one, so a
results page can show how somebody reached a final — plus who missed the
cut and who withdrew, which a facade with a field and a count cannot work
out for itself
- [x] A meeting over several days off **one** rulebook — `beam402 sheet … --date
2026-08-07 --id practice-day`, because hand-edited copies of a class list
drift and then the class runs two ways in one weekend
- [x] The record button waits for the **numbers**, not the finish beam: a car
is over the line about a second before its ET is off the node (**D25**),
and a press inside that beat wrote the pass down with no time in it
- [ ] Tree-hosted deployment (**D31**): a tree, two nodes and a phone — arm and
read every run with no computer at the track
- [x] A reference receiver actually deployed, so the chain runs end to end —
Expand Down
14 changes: 14 additions & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ Beam402 — ответ на это: открытая, воспроизводим
круги, есть что считать, а зеркалу есть что сказать о причине поражения.
Фолы, которых лучи не видят, аннулирование проезда и снятие из класса
судья ставит с панели — словами своего регламента
- [x] У дня в квалификации есть **таблица**, и считается она той же
арифметикой, что и жеребьёвка, — поэтому не может разойтись с сеткой, в
которую машины попадут: место каждой, число попыток, где проходит отсечка.
У машины, которая ещё не проехала, места нет. Раньше тренировочный день не
давал вообще никакого вывода
- [x] Закончившийся класс публикует **все свои раунды**, а не последний, — чтобы
страница результатов показывала, как человек дошёл до финала; плюс кто не
прошёл отсечку и кто снялся, а это фасад по полю и счётчику не выведет
- [x] Этап на несколько дней с **одного** регламента — `beam402 sheet … --date
2026-08-07 --id practice-day`: копии списка классов, поправленные руками,
расходятся, и тогда класс едет в один уикенд по двум правилам
- [x] Кнопка записи ждёт **цифры**, а не створ финиша: машина за створом
примерно на секунду раньше, чем её ET уходит с узла (**D25**), и нажатие
внутри этой секунды записывало проезд вообще без времени
- [ ] Конфигурация с ёлкой-мастером (**D31**): ёлка, два узла и телефон —
запуск и результат каждого заезда без компьютера на трассе
- [x] Референсный получатель, реально задеплоенный, чтобы цепочка шла целиком —
Expand Down
10 changes: 8 additions & 2 deletions events/season.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@
#
# beam402 sheet entries.csv --event events/season.toml -o today.toml
#
# Change the id and the date for each event, or generate them from whatever the
# club already uses to schedule its calendar.
# The [event] block below is the one thing here that is not a rule, so a meeting
# running over several days names each day on the command line rather than in a
# copy of this file — copies of a rulebook drift:
#
# beam402 sheet entries.csv --event events/season.toml \
# --id club-2026-08-07 --name Practice --date 2026-08-07 -o friday.toml
#
# What is left here is the fallback for a club running one day at a time.

[event]
id = "club-day"
Expand Down
160 changes: 158 additions & 2 deletions software/crates/cli/src/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ use beam402_mapping::Mapping;
use beam402_poller::{Phase as BusPhase, Poller};
use beam402_protocol::Lane;
use beam402_race::staging::{Action, Config, Phase, Staging};
use beam402_race::{decide, Outcome, Pairing, RunBuilder};
use beam402_race::{decide, Outcome, Pairing, Round, RunBuilder};

use crate::meeting::Meeting;
use crate::round::{self, STEP_MS};
Expand Down Expand Up @@ -210,9 +210,52 @@ pub struct Runtime<'m, B> {
meeting: Option<Meeting>,
armed: bool,
cycles: u64,
/// How long the round has been [`Phase::Complete`], in loop time.
///
/// The staging machine calls a round complete when every car has been *seen*
/// to cross the finish beam, which is the right statement about the strip and
/// not yet a statement about the numbers: the ETs are latched in the nodes and
/// arrive on a later poll (**D25**). This is how long they have had to.
complete_ms: u64,
/// A car never reached the finish beam, so this round's numbers are not late —
/// they do not exist.
abandoned: bool,
note: String,
}

/// How long a finished round is given to produce its numbers before "not yet"
/// becomes "never".
///
/// Recording inside that window wrote a result with no time in it — a driver's
/// real pass logged as `-`, which seeds them at the back of a class they were
/// leading. Past it, a lane that was seen at the finish and still has nothing is a
/// record that is not coming, and refusing forever would strand the day.
///
/// Wide enough for several poll cycles at 19,200 bps with a node down, short
/// enough that nobody standing at the panel thinks the button is broken.
const SETTLE_MS: u64 = 3_000;

/// Whether a finished round is **whole**.
///
/// Whole means every lane that raced has its ET, or there is no longer any reason
/// to expect one: the round was abandoned, or the records have had [`SETTLE_MS`]
/// to arrive and did not. That last case is a node that stopped answering, and the
/// honest record for it is the one with `-` in it — written deliberately, rather
/// than by beating the poll loop to a button.
///
/// Only ever asked about a round the staging machine already calls complete. A
/// pairing sitting on the line has no times either, and that is not the same
/// question.
fn settled(round: &Round, pairing: &Pairing, abandoned: bool, complete_ms: u64) -> bool {
if abandoned || complete_ms >= SETTLE_MS {
return true;
}
pairing
.entries()
.iter()
.all(|e| round.lane(e.lane).is_some_and(|r| r.et_s.is_some()))
}

impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> {
pub fn new(
bus: B,
Expand All @@ -236,6 +279,8 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> {
meeting: None,
armed: false,
cycles: 0,
complete_ms: 0,
abandoned: false,
note: String::new(),
}
}
Expand Down Expand Up @@ -305,11 +350,22 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> {
}
}
Action::Abandon => {
// Nothing more is coming for a car that never reached the
// finish beam, so the round is as whole as it will get.
self.abandoned = true;
self.note = "abandoned: a car never reached the finish beam".into()
}
}
}

// How long the numbers have had to come back. Measured about this round
// rather than the day, so anything that is not a finished round clears it.
self.complete_ms = if self.staging.phase() == Phase::Complete {
self.complete_ms + STEP_MS
} else {
0
};

for intent in intents {
match intent {
Intent::Arm => self.do_arm(),
Expand Down Expand Up @@ -363,6 +419,7 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> {
self.staging.armed(handicap);
self.poller.set_phase(BusPhase::Quiet);
self.armed = true;
self.abandoned = false;
self.note.clear();
}
Err(e) => self.note = e,
Expand Down Expand Up @@ -391,6 +448,16 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> {
self.note = "the round is not over".into();
return;
}
// **Seen at the finish beam is not the same as measured.** The staging
// machine goes `Complete` on the beam edge; the ET is latched in the node
// and arrives on a later poll (**D25**). Recording in between wrote the
// pass down with no time in it — a real 11.85 logged as `-`, which seeds
// that driver at the back of a class they were leading, silently. So the
// gate is the round being whole rather than the car being over the line.
if !settled(&round, &pairing, self.abandoned, self.complete_ms) {
self.note = "the finish records have not come back yet".into();
return;
}
self.note = match meeting.record(&round, &pairing) {
Ok(line) => format!("recorded: {line}"),
Err(why) => why,
Expand Down Expand Up @@ -553,9 +620,21 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> {
self.note = "this round has a result that has not been recorded".into();
return;
}
// Nor by the button that brings up the next pair while the numbers are
// still on their way. Clearing the round here does not stop the records
// arriving — they are latched in the nodes and a poll is already asking
// (**D25**) — so what used to happen is that the previous car's ET landed
// in the next pair's round and showed on the panel before it had staged.
if self.staging.phase() == Phase::Complete
&& !settled(&round, &self.pairing, self.abandoned, self.complete_ms)
{
self.note = "the finish records have not come back yet".into();
return;
}

self.builder.clear_round();
self.staging.reset();
self.abandoned = false;
self.poller.set_phase(BusPhase::Live);
self.poller.release_tree(self.tree);
// Deliberately **no** refetch. The nodes still hold the last round's
Expand Down Expand Up @@ -644,12 +723,16 @@ impl<'m, B: Bus + Paced + CallUp> Runtime<'m, B> {
};

format!(
"{{\"phase\":\"{phase}\",\"ready\":{},\"armed\":{},\"held\":{},\"holder\":{},\
"{{\"phase\":\"{phase}\",\"ready\":{},\"armed\":{},\"settled\":{},\
\"held\":{},\"holder\":{},\
\"cycles\":{},\"bus_ms\":{:.0},\"note\":\"{}\",\"winner\":\"{verdict}\",\
\"board\":{{\"w\":{},\"h\":{},\"bits\":\"{}\"}},\"event\":{event},\
\"lanes\":[{lanes}],\"nodes\":[{nodes}],\"slip\":\"{}\"}}",
self.staging.is_ready(),
self.armed,
// Whether the round is whole, so the panel can stop offering `record`
// in the window where it would write a pass down with no time in it.
settled(&round, &self.pairing, self.abandoned, self.complete_ms),
holder.is_some(),
match holder {
Some(t) => t.to_string(),
Expand Down Expand Up @@ -696,6 +779,79 @@ pub fn pace() {
mod tests {
use super::*;

/// The defect a practice day found. The staging machine calls a round complete
/// on the **finish beam**; the ET is latched in the node and comes back on a
/// later poll (**D25**) — 1.3 s later on the reference venue. The panel offered
/// `record` for that whole beat, and a press inside it wrote
/// `Q Unlimited 17 - - -`: a real 11.85 logged as no time at all, which seeds
/// that driver at the back of a class they were leading. Silently, because a
/// pass with no time is a legitimate record — it is what a car that stops on
/// the track gets.
#[test]
fn a_finished_round_is_not_whole_until_its_numbers_arrive() {
use beam402_race::{Entry, Format, LaneRun};

let one_lane = Pairing::new(
Format::HeadsUp,
vec![Entry {
lane: Lane::L1,
dial_s: None,
}],
)
.unwrap();
let timed = |et: f64| {
let mut r = Round::default();
r.set_lane(
Lane::L1,
LaneRun {
reaction_s: Some(0.412),
et_s: Some(et),
..LaneRun::default()
},
);
r
};
// The window: over the line, nothing off the node yet.
let mut crossed = Round::default();
crossed.set_lane(
Lane::L1,
LaneRun {
reaction_s: Some(0.412),
..LaneRun::default()
},
);
assert!(!settled(&crossed, &one_lane, false, 0));
assert!(!settled(&crossed, &one_lane, false, SETTLE_MS - STEP_MS));

// The number arrives and the round is whole.
assert!(settled(&timed(11.85), &one_lane, false, 0));

// Two ways it is whole without one. A car that never reached the finish
// beam has no number coming...
assert!(settled(&crossed, &one_lane, true, 0));
// ...and neither has a node that stopped answering, which is the case that
// must not strand the day: past the grace, `-` is the honest record and it
// is written deliberately.
assert!(settled(&crossed, &one_lane, false, SETTLE_MS));

// Both lanes, because a pair is only as recordable as its slower node.
let two = Pairing::new(
Format::HeadsUp,
vec![
Entry {
lane: Lane::L1,
dial_s: None,
},
Entry {
lane: Lane::L2,
dial_s: None,
},
],
)
.unwrap();
assert!(!settled(&timed(11.85), &two, false, 0), "lane 2 has nothing");
}

#[test]
fn one_client_holds_control_and_the_others_are_told_so() {
// D30's rule, and the failure it prevents: two people arming is worse
Expand Down
Loading
Loading