From 5854170378d55f53c4b3dcaa31d6ebb491d5b08f Mon Sep 17 00:00:00 2001 From: wyattb Date: Sun, 26 Apr 2026 15:35:01 -0400 Subject: [PATCH 01/29] #305 - unified calypso-sim binary with streaming, keymap, and autonomous modes Replace `simulate` with a single `calypso-sim` binary organized as a folder of focused modules under `src/bin/calypso-sim/`. Modes (can run together): - Autonomous heartbeat (`--auto`, default when no other mode is chosen) - Interactive keymap (`--key-map`) - Scripted keymap replay (`--script` with `--key-map`) - JSON-RPC 2.0 over stdio (`--stream`, for an agent) Per-topic ownership (`auto` / `stream` / `silenced`) lets stream and keymap modes claim/release/silence topics; the autonomous loop checks ownership before each publish. Stream protocol: NDJSON over stdin/stdout, methods `publish`, `claim`, `release`, `silence`, `status`, `list_topics`, `ping`. Tracing on stderr so stdout stays clean for JSON-RPC. Other: - Startup warning lists CAN topics with no `sim_freq` - README documents all four modes, keymap formats, and the stream protocol - Keymap supports Random / Pinned / Increment / Sequence per key, with optional `desc` per entry --- Cargo.lock | 71 +++++ Cargo.toml | 2 + README.md | 106 ++++++- manual_sim_buttons.keymap.json | 30 ++ manual_sim_keymap.example.json | 6 + src/bin/calypso-sim/cli.rs | 82 +++++ src/bin/calypso-sim/keymap.rs | 370 +++++++++++++++++++++++ src/bin/calypso-sim/main.rs | 155 ++++++++++ src/bin/calypso-sim/modes/auto_script.rs | 79 +++++ src/bin/calypso-sim/modes/autonomous.rs | 103 +++++++ src/bin/calypso-sim/modes/interactive.rs | 146 +++++++++ src/bin/calypso-sim/modes/stream.rs | 194 ++++++++++++ src/bin/calypso-sim/publish.rs | 36 +++ src/bin/calypso-sim/raw_mode.rs | 36 +++ src/bin/calypso-sim/registry.rs | 69 +++++ src/bin/calypso-sim/warnings.rs | 45 +++ src/bin/simulate.rs | 301 ------------------ src/simulatable_message.rs | 6 +- 18 files changed, 1525 insertions(+), 312 deletions(-) create mode 100644 manual_sim_buttons.keymap.json create mode 100644 manual_sim_keymap.example.json create mode 100644 src/bin/calypso-sim/cli.rs create mode 100644 src/bin/calypso-sim/keymap.rs create mode 100644 src/bin/calypso-sim/main.rs create mode 100644 src/bin/calypso-sim/modes/auto_script.rs create mode 100644 src/bin/calypso-sim/modes/autonomous.rs create mode 100644 src/bin/calypso-sim/modes/interactive.rs create mode 100644 src/bin/calypso-sim/modes/stream.rs create mode 100644 src/bin/calypso-sim/publish.rs create mode 100644 src/bin/calypso-sim/raw_mode.rs create mode 100644 src/bin/calypso-sim/registry.rs create mode 100644 src/bin/calypso-sim/warnings.rs delete mode 100644 src/bin/simulate.rs diff --git a/Cargo.lock b/Cargo.lock index b92a984..da85290 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,7 @@ dependencies = [ "calypso-cangen", "can-dbc", "clap", + "crossterm", "daedalus", "futures-util", "num_enum", @@ -140,6 +141,7 @@ dependencies = [ "regex", "rumqttc", "schemars", + "serde", "serde_json", "socketcan", "tokio", @@ -276,6 +278,32 @@ dependencies = [ "memchr", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "daedalus" version = "0.1.0" @@ -1332,6 +1360,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1764,6 +1813,28 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7f362f6..09ead92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,9 @@ futures-util = "0.3.31" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["ansi", "env-filter"] } tokio-util = { version = "0.7.16", features = ["full"] } +crossterm = { version = "0.28", features = ["event-stream"] } can-dbc = "6.0.0" +serde.workspace = true serde_json.workspace = true schemars.workspace = true num_enum = "0.7.5" diff --git a/README.md b/README.md index f72ecf9..2a2c9de 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,103 @@ Ex. `cansend vcan0 702#01010101FFFFFFFF` Now view calypso interpret the can message and broadcast it on `mqttui` -### Simulation Mode -#### Run from build -- Same setup as above, then use the entry point `simulate` instead of `main` -- ```cargo run --bin simulate``` -- ```cargo run --bin simulate -- -u localhost:1883``` +### Simulation (`calypso-sim`) + +`calypso-sim` is a single binary that publishes simulated MQTT messages into the broker. It has four input modes that compose freely: + +| Mode | Flag | What it does | +|---|---|---| +| **Autonomous** | `--auto` | Heartbeat publishes for every CAN message with a `sim_freq` in the spec, at its configured frequency. Default when no other mode is chosen. | +| **Interactive** | `--key-map FILE` | Raw-mode terminal — each keypress fires a configured topic. Press `Ctrl+C` to exit. | +| **Script** | `--script FILE` (with `--key-map`) | Replay a sequence of keymap keys / `sleep N` lines from a text file, then exit. | +| **Stream** | `--stream` | JSON-RPC 2.0 over stdin/stdout — for agent-driven injection. `--auto` defaults OFF in this mode. | + +`--auto` can run alongside `--key-map` or `--stream` to provide a simulated background while you override specific topics. + +#### Quick reference + +``` +cargo run --bin calypso-sim -- --list-topics # enumerate topics, exit +cargo run --bin calypso-sim # autonomous heartbeat +cargo run --bin calypso-sim -- --key-map manual_sim_keymap.example.json +cargo run --bin calypso-sim -- --key-map keys.json --auto # background heartbeat + interactive +cargo run --bin calypso-sim -- --key-map keys.json --script play.txt +cargo run --bin calypso-sim -- --stream # JSON-RPC over stdio +cargo run --bin calypso-sim -- -u 10.0.0.5:1883 ... # remote broker +``` + +The `--enable-topic ` and `--disable-topic ` flags filter which topics the autonomous heartbeat publishes (whitelist / blacklist; mutually exclusive). Topics that lack a `sim_freq` in the CAN spec are listed at startup as a `Warning topics (not simulated): ...` line and can only be reached via `--key-map` or `--stream`. + +#### Topic-ownership model + +Every topic has an owner: `auto` (default — autonomous publishes), `stream` (claimed by stream/keymap), or `silenced` (nobody publishes). The autonomous loop checks ownership before each publish, so claims from `--stream` or keymap modes cleanly override the heartbeat without fighting it. Releasing a topic returns it to `auto`. + +#### Keymap format (`--key-map` and `--script`) + +A keymap is a JSON object mapping single-character keys to one of four entry shapes: + +```json +{ + "v": "BMS/Pack/Voltage", + "p": {"topic": "VCU/CarState/home_mode", "value": 1, "desc": "home pulse"}, + "n": {"topic": "VCU/CarState/nero_index", "value": 0, "step": 1, "max": 5, "desc": "cycle nero"}, + "w": { + "desc": "wrap menu", + "sequence": [ + {"topic": "VCU/CarState/nero_index", "value": 0}, + {"topic": "VCU/CarState/home_mode", "value": 1, "delay_ms": 10}, + {"topic": "VCU/CarState/home_mode", "value": 0, "delay_ms": 100} + ] + } +} +``` + +| Form | What it does | +|---|---| +| Bare topic string | **Random** — publishes a fresh randomized value within the topic's sim bounds. Topic must exist in the spec. | +| Object with `value` | **Pinned** — publishes that exact number every keypress. `unit` is required for unknown topics. | +| Object with `step` | **Increment** — emits the starting value first, then advances by `step` each keypress. `min`/`max` wrap independently when supplied. | +| Object with `sequence` | **Sequence** — publishes a scripted series of `(topic, value)` pairs with optional per-step `delay_ms`. | + +Every object form accepts an optional `desc` for the startup listing and inline log line. + +Script files (`--script`) contain one command per line: a single character (fires that key) or `sleep `. Blank lines and `#` comments are ignored. + +#### Stream mode protocol (`--stream`) + +JSON-RPC 2.0 over stdio — one request per line on stdin, one response per line on stdout, diagnostics on stderr. + +```jsonc +// stdin +{"jsonrpc":"2.0","id":1,"method":"publish","params":{"topic":"Wheel/Buttons/button_id","value":5}} +{"jsonrpc":"2.0","id":2,"method":"claim","params":{"topic":"VCU/CarState/home_mode"}} +{"jsonrpc":"2.0","id":3,"method":"publish","params":{"topic":"VCU/CarState/home_mode","value":1}} +{"jsonrpc":"2.0","id":4,"method":"release","params":{"topic":"VCU/CarState/home_mode"}} + +// stdout +{"jsonrpc":"2.0","id":1,"result":{"ts_us":1735347123456789}} +{"jsonrpc":"2.0","id":2,"result":{"topic":"...","previous_owner":"auto","owner":"stream"}} +... +``` + +| Method | Params | Result | +|---|---|---| +| `publish` | `{topic, value? \| values?, unit?}` | `{ts_us}` | +| `claim` | `{topic}` | `{topic, previous_owner, owner}` | +| `release` | `{topic}` | `{topic, previous_owner, owner: "auto"}` | +| `silence` | `{topic}` | `{topic, previous_owner, owner: "silenced"}` | +| `status` | `{}` | `{overrides: [{topic, owner}, ...]}` | +| `list_topics` | `{}` | `{topics: [{name, unit}, ...]}` | +| `ping` | `{}` | `{ok: true}` | + +Errors follow JSON-RPC 2.0 (`{error: {code, message}}` with codes -32600 through -32700 and -32603 for internal failures). #### Run from Docker -- ```docker pull ghcr.io/northeastern-electric-racing/calypso:Develop``` -- ```docker run -d --rm --network host ghcr.io/northeastern-electric-racing/calypso:Develop``` -- ```docker run -d --rm -e CALYPSO_SIREN_HOST_URL=127.0.0.1:1883 --network host ghcr.io/northeastern-electric-racing/calypso:Develop``` + +The default container entry point is the main `calypso` decoder. To run `calypso-sim` instead, override the entry point: + +``` +docker pull ghcr.io/northeastern-electric-racing/calypso:Develop +docker run -d --rm -e CALYPSO_SIREN_HOST_URL=127.0.0.1:1883 --network host \ + --entrypoint calypso-sim ghcr.io/northeastern-electric-racing/calypso:Develop +``` diff --git a/manual_sim_buttons.keymap.json b/manual_sim_buttons.keymap.json new file mode 100644 index 0000000..7a43b38 --- /dev/null +++ b/manual_sim_buttons.keymap.json @@ -0,0 +1,30 @@ +{ + "h": { + "desc": "home / esc (button_id=0 + Cerberus home_mode pulse)", + "sequence": [ + {"topic": "Wheel/Buttons/button_id", "value": 0}, + {"topic": "VCU/CarState/home_mode", "value": 1, "delay_ms": 10}, + {"topic": "VCU/CarState/home_mode", "value": 0, "delay_ms": 100} + ] + }, + "l": {"topic": "Wheel/Buttons/button_id", "value": 1, "desc": "left"}, + "2": {"topic": "Wheel/Buttons/button_id", "value": 2, "desc": "launch control toggle"}, + "j": {"topic": "Wheel/Buttons/button_id", "value": 3, "desc": "up (regen)"}, + "k": {"topic": "Wheel/Buttons/button_id", "value": 4, "desc": "down (regen)"}, + "e": {"topic": "Wheel/Buttons/button_id", "value": 5, "desc": "enter"}, + "r": {"topic": "Wheel/Buttons/button_id", "value": 6, "desc": "right"}, + "7": {"topic": "Wheel/Buttons/button_id", "value": 7, "desc": "traction control toggle"}, + "8": {"topic": "Wheel/Buttons/button_id", "value": 8, "desc": "up (torque)"}, + "9": {"topic": "Wheel/Buttons/button_id", "value": 9, "desc": "down (torque)"}, + "m": {"topic": "VCU/CarState/home_mode", "value": 0, "desc": "clear home_mode"}, + "n": {"topic": "VCU/CarState/nero_index", "value": 0, "desc": "reset nero_index"}, + "d": {"topic": "VCU/CarState/not_in_reverse", "value": 1, "desc": "forward drive"}, + "w": { + "desc": "force menu wrap (nero_index=0 + home_mode pulse)", + "sequence": [ + {"topic": "VCU/CarState/nero_index", "value": 0}, + {"topic": "VCU/CarState/home_mode", "value": 1, "delay_ms": 10}, + {"topic": "VCU/CarState/home_mode", "value": 0, "delay_ms": 50} + ] + } +} diff --git a/manual_sim_keymap.example.json b/manual_sim_keymap.example.json new file mode 100644 index 0000000..f7bcd2b --- /dev/null +++ b/manual_sim_keymap.example.json @@ -0,0 +1,6 @@ +{ + "v": "BMS/Pack/Voltage", + "c": "BMS/Pack/Current", + "s": "BMS/Pack/SOC", + "h": "BMS/Pack/Health" +} diff --git a/src/bin/calypso-sim/cli.rs b/src/bin/calypso-sim/cli.rs new file mode 100644 index 0000000..d0dcfef --- /dev/null +++ b/src/bin/calypso-sim/cli.rs @@ -0,0 +1,82 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +#[command( + name = "calypso-sim", + version, + about = "MQTT simulation tool for Calypso (autonomous, interactive, scripted, or streamed)" +)] +pub struct Cli { + /// MQTT broker host:port + #[arg( + short = 'u', + long, + env = "CALYPSO_SIREN_HOST_URL", + default_value = "127.0.0.1:1883" + )] + pub siren_host_url: String, + + /// List all simulatable topics and exit + #[arg(long)] + pub list_topics: bool, + + /// Run the autonomous heartbeat simulator. Default ON when no other mode + /// is chosen; explicit when paired with --key-map / --script / --stream. + #[arg(long)] + pub auto: bool, + + /// Disable the autonomous heartbeat for topics matching these regex + /// patterns (blacklist mode) + #[arg(long = "disable-topic", conflicts_with = "enable_topic")] + pub disable_topic: Vec, + + /// Run the autonomous heartbeat ONLY for topics matching these regex + /// patterns (whitelist mode) + #[arg(long = "enable-topic", conflicts_with = "disable_topic")] + pub enable_topic: Vec, + + /// Path to a JSON keymap file; runs the interactive raw-mode keypress + /// injector. Press Ctrl+C to exit. + #[arg(short = 'k', long, value_name = "FILE")] + pub key_map: Option, + + /// Run a scripted sequence of keymap keys then exit. Each line is either + /// a single character (fires that key) or `sleep ` (waits). Blank + /// lines and lines starting with `#` are ignored. Requires `--key-map`. + #[arg(long, value_name = "FILE", requires = "key_map")] + pub script: Option, + + /// Accept JSON-RPC 2.0 commands on stdin (one per line); replies on + /// stdout. Tracing/diagnostics go to stderr. `--auto` defaults OFF in + /// this mode unless explicitly set. + #[arg(long)] + pub stream: bool, +} + +impl Cli { + /// Whether the autonomous heartbeat should run. + /// True if `--auto` was set, OR no other input mode was chosen and + /// `--list-topics` wasn't requested. + pub fn run_autonomous(&self) -> bool { + if self.auto { + return true; + } + let any_other_mode = self.stream || self.key_map.is_some() || self.script.is_some(); + !any_other_mode && !self.list_topics + } + + /// Validate mutually-exclusive mode flags. + pub fn validate(&self) -> Result<(), String> { + let mut chosen = 0; + if self.stream { + chosen += 1; + } + if self.key_map.is_some() { + chosen += 1; + } + if chosen > 1 { + return Err("--stream and --key-map are mutually exclusive".into()); + } + Ok(()) + } +} diff --git a/src/bin/calypso-sim/keymap.rs b/src/bin/calypso-sim/keymap.rs new file mode 100644 index 0000000..c44d433 --- /dev/null +++ b/src/bin/calypso-sim/keymap.rs @@ -0,0 +1,370 @@ +use std::collections::HashMap; +use std::io::Write; +use std::time::Duration; + +use calypso::simulatable_message::{SimComponent, SimValue}; +use calypso::simulate_data::create_simulated_components; +use rand::prelude::*; +use rumqttc::v5::AsyncClient; +use serde::Deserialize; + +use crate::publish::publish_data; +use crate::raw_mode::line_end; +use crate::registry::{Owner, SharedRegistry}; + +/// A keymap entry. Four forms: +/// * Bare topic string — random value within sim bounds (requires the topic +/// to be in the auto-generated simulated-components list). +/// * Object with `value` — pins that exact number on every keypress. +/// `unit` is required for topics not in the generated simulated-components +/// list. +/// * Object with `step` — increment mode. Publishes `value` (or `min`, or 0) +/// on first press, then advances by `step` each press, wrapping +/// independently when each bound is supplied. `unit` falls back to the sim +/// component's unit if the topic is known. +/// * Object with `sequence` — publishes a scripted series of (topic, value) +/// pairs on each keypress, with optional per-step `delay_ms` before publish. +/// +/// Every object form also accepts an optional `desc` string that is shown in +/// the startup listing and inline with each publish log line. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum KeyEntry { + TopicOnly(String), + /// Sequence is distinguished by the `sequence` field — check first. + Sequence { + sequence: Vec, + #[serde(default)] + desc: Option, + }, + /// Checked before Pinned: if `step` is present, this is increment mode. + Increment { + topic: String, + value: Option, + step: f32, + min: Option, + max: Option, + unit: Option, + #[serde(default)] + desc: Option, + }, + Pinned { + topic: String, + value: f32, + unit: Option, + #[serde(default)] + desc: Option, + }, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct SequenceStep { + pub topic: String, + pub value: f32, + pub unit: Option, + /// Milliseconds to wait *before* publishing this step. Defaults to 0. + #[serde(default)] + pub delay_ms: u64, +} + +#[derive(Debug, Clone)] +pub enum KeyMode { + Random, + Pinned { + value: f32, + }, + Increment { + current: f32, + step: f32, + min: Option, + max: Option, + }, + Sequence { + steps: Vec, + }, +} + +#[derive(Debug, Clone)] +pub struct KeyState { + pub topic: String, + pub unit: String, + pub component: Option, + pub mode: KeyMode, + pub desc: Option, +} + +/// Format `" [unit]"` suffix, or empty string when the unit is empty/missing. +pub fn unit_suffix(unit: &str) -> String { + if unit.is_empty() { + String::new() + } else { + format!(" [{unit}]") + } +} + +/// Format `" — desc"` suffix, or empty string when desc is missing/empty. +pub fn desc_suffix(desc: Option<&str>) -> String { + desc.filter(|d| !d.is_empty()) + .map(|d| format!(" — {d}")) + .unwrap_or_default() +} + +pub fn parse_key_map(content: &str) -> Result, String> { + let raw: HashMap = + serde_json::from_str(content).map_err(|e| format!("Invalid key map JSON: {e}"))?; + raw.into_iter() + .map(|(key_str, entry)| { + let mut chars = key_str.chars(); + let (Some(ch), None) = (chars.next(), chars.next()) else { + return Err(format!( + "Key mapping keys must be single characters, got: '{key_str}'" + )); + }; + Ok((ch, entry)) + }) + .collect() +} + +pub fn load_key_map(path: &str) -> Result, String> { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read key map file '{path}': {e}"))?; + parse_key_map(&content) +} + +pub fn build_topic_states(key_map: HashMap) -> HashMap { + let components = create_simulated_components(); + let mut result = HashMap::new(); + for (key, entry) in key_map { + let (topic, mode, unit_override, desc) = match entry { + KeyEntry::TopicOnly(t) => (t, KeyMode::Random, None, None), + KeyEntry::Pinned { + topic, + value, + unit, + desc, + } => (topic, KeyMode::Pinned { value }, unit, desc), + KeyEntry::Increment { + topic, + value, + step, + min, + max, + unit, + desc, + } => { + let start = value.or(min).unwrap_or(0.0); + ( + topic, + KeyMode::Increment { + current: start, + step, + min, + max, + }, + unit, + desc, + ) + } + KeyEntry::Sequence { sequence, desc } => { + if sequence.is_empty() { + eprintln!("Warning: sequence for key '{key}' is empty — skipping"); + continue; + } + let summary_topic = format!("", sequence.len()); + ( + summary_topic, + KeyMode::Sequence { steps: sequence }, + None, + desc, + ) + } + }; + + let component = components.iter().find(|c| c.name == topic).cloned(); + + if matches!(mode, KeyMode::Random) && component.is_none() { + eprintln!( + "Warning: random-mode key '{key}' maps to topic '{topic}' which is \ + not in the generated sim components — skipping" + ); + continue; + } + + let unit = match component.as_ref().map(|c| c.unit.clone()).or(unit_override) { + Some(u) => u, + // Sequence mode publishes per-step topics with their own units; + // the top-level unit is unused, so missing it is fine. + None if matches!(mode, KeyMode::Sequence { .. }) => String::new(), + None => { + eprintln!( + "Warning: key '{key}' maps to unknown topic '{topic}' \ + with no `unit` provided — skipping" + ); + continue; + } + }; + + result.insert( + key, + KeyState { + topic, + unit, + component, + mode, + desc, + }, + ); + } + result +} + +/// Generate a fresh random value within each point's defined bounds. +pub fn randomize_component(component: &mut SimComponent) { + let mut rng = rand::rng(); + for point in &mut component.points { + match &mut point.value { + SimValue::Range { + min, + max, + inc_min, + round, + current, + .. + } => { + if (*max - *min).abs() < f32::EPSILON { + *current = *min; + } else { + *current = rng.random_range(*min..*max); + } + if *inc_min != 0.0 { + *current = (*current / *inc_min).round() * *inc_min; + } + if *round { + *current = current.round(); + } + *current = current.clamp(*min, *max); + } + SimValue::Discrete { + options, current, .. + } => { + if let Some(&(v, _)) = options.choose(&mut rng) { + *current = v; + } + } + } + } +} + +/// Advance an increment-mode state and return the value to publish *before* +/// the advance (so the first press emits the starting value). +pub fn advance_increment( + current: &mut f32, + step: f32, + min: Option, + max: Option, +) -> f32 { + let emitted = *current; + let mut next = *current + step; + if let Some(hi) = max + && next > hi { + next = min.unwrap_or(hi); + } + if let Some(lo) = min + && next < lo { + next = max.unwrap_or(lo); + } + *current = next; + emitted +} + +/// Resolve the (topic, unit, values) for single-shot modes. Sequence mode +/// returns `None` and is handled by the caller. +fn resolve_single_publish(state: &mut KeyState) -> Option<(String, String, Vec)> { + match &mut state.mode { + KeyMode::Random => { + let component = state.component.as_mut()?; + randomize_component(component); + let data = component.get_decode_data(); + Some((data.topic, data.unit, data.value)) + } + KeyMode::Pinned { value } => { + Some((state.topic.clone(), state.unit.clone(), vec![*value])) + } + KeyMode::Increment { + current, + step, + min, + max, + } => { + let v = advance_increment(current, *step, *min, *max); + Some((state.topic.clone(), state.unit.clone(), vec![v])) + } + KeyMode::Sequence { .. } => None, + } +} + +/// Resolve the value(s) for this keypress and publish to the broker. +/// Sequence mode walks the scripted steps with per-step delays; other modes +/// emit a single message. Logs each publish to stdout. +/// +/// Topics owned by `Silenced` in the registry are skipped silently. +pub async fn publish_injection( + ch: char, + state: &mut KeyState, + client: &AsyncClient, + registry: &SharedRegistry, +) { + if let KeyMode::Sequence { steps } = &state.mode { + if let Some(desc) = state.desc.as_deref() { + let nl = line_end(); + print!("[{ch}] {desc}{nl}"); + let _ = std::io::stdout().flush(); + } + let steps = steps.clone(); + for step in steps { + if registry.read().await.owner(&step.topic) == Owner::Silenced { + continue; + } + if step.delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(step.delay_ms)).await; + } + let unit = step.unit.clone().unwrap_or_default(); + log_and_publish(ch, &step.topic, &unit, &[step.value], None, client).await; + } + return; + } + + if registry.read().await.owner(&state.topic) == Owner::Silenced { + return; + } + + if let Some((topic, unit, values)) = resolve_single_publish(state) { + log_and_publish(ch, &topic, &unit, &values, state.desc.as_deref(), client).await; + } +} + +async fn log_and_publish( + ch: char, + topic: &str, + unit: &str, + values: &[f32], + desc: Option<&str>, + client: &AsyncClient, +) { + let nl = line_end(); + let desc_s = desc_suffix(desc); + + match publish_data(client, topic, unit, values).await { + Ok(_) => { + let values_str: Vec = values.iter().map(|v| format!("{v:.2}")).collect(); + print!( + "[{ch}] {topic} = [{}] {unit}{desc_s}{nl}", + values_str.join(", ") + ); + } + Err(e) => { + print!("[{ch}] error publishing {topic}: {e}{nl}"); + } + } + let _ = std::io::stdout().flush(); +} diff --git a/src/bin/calypso-sim/main.rs b/src/bin/calypso-sim/main.rs new file mode 100644 index 0000000..77ccc31 --- /dev/null +++ b/src/bin/calypso-sim/main.rs @@ -0,0 +1,155 @@ +mod cli; +mod keymap; +mod modes; +mod publish; +mod raw_mode; +mod registry; +mod warnings; + +use std::process::exit; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use calypso::simulate_data::create_simulated_components; +use clap::Parser; +use rumqttc::v5::{AsyncClient, EventLoop, MqttOptions}; +use tokio_util::sync::CancellationToken; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::{EnvFilter, fmt::format::FmtSpan}; + +use cli::Cli; +use registry::TopicRegistry; + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + init_tracing(); + + if cli.list_topics { + list_topics_and_exit(); + } + + if let Err(err) = cli.validate() { + eprintln!("Error: {err}"); + exit(2); + } + + warnings::print_unsimulated(); + + let (client, eventloop) = match connect_mqtt(&cli.siren_host_url) { + Ok(pair) => pair, + Err(err) => { + eprintln!("Error: {err}"); + exit(1); + } + }; + + let token = CancellationToken::new(); + let poll_handle = tokio::spawn(modes::poll_eventloop(token.clone(), eventloop)); + + let registry = TopicRegistry::shared(); + + let auto_handle = if cli.run_autonomous() { + Some(tokio::spawn(modes::autonomous::run( + token.clone(), + client.clone(), + registry.clone(), + cli.enable_topic.clone(), + cli.disable_topic.clone(), + ))) + } else { + None + }; + + let foreground = run_foreground(&cli, &token, &client, ®istry).await; + + token.cancel(); + if let Some(h) = auto_handle { + let _ = h.await; + } + let _ = poll_handle.await; + tokio::time::sleep(Duration::from_millis(50)).await; + + if let Err(err) = foreground { + eprintln!("Error: {err}"); + exit(1); + } +} + +async fn run_foreground( + cli: &Cli, + token: &CancellationToken, + client: &AsyncClient, + registry: ®istry::SharedRegistry, +) -> Result<(), String> { + if cli.stream { + modes::stream::run(token.clone(), client.clone(), registry.clone()).await + } else if let Some(script_path) = &cli.script { + let key_map_path = cli + .key_map + .as_deref() + .ok_or_else(|| "--script requires --key-map".to_string())?; + modes::auto_script::run(client.clone(), key_map_path, script_path, registry.clone()).await + } else if let Some(key_map_path) = &cli.key_map { + modes::interactive::run(token.clone(), client.clone(), key_map_path, registry.clone()).await + } else { + // Pure --auto: wait for SIGINT, then exit. + match tokio::signal::ctrl_c().await { + Ok(()) => Ok(()), + Err(e) => Err(format!("ctrl+c handler failed: {e}")), + } + } +} + +fn init_tracing() { + // Tracing always writes to stderr so stdout stays clean for stream mode + // and keymap-mode logs. + let subscriber = tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_thread_ids(false) + .with_ansi(true) + .with_span_events(FmtSpan::CLOSE) + .with_env_filter( + EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(), + ) + .finish(); + let _ = tracing::subscriber::set_global_default(subscriber); +} + +fn list_topics_and_exit() -> ! { + let components = create_simulated_components(); + println!("Available topics ({} total):", components.len()); + for c in &components { + println!(" {} [{}]", c.name, c.unit); + } + exit(0); +} + +fn connect_mqtt(host_url: &str) -> Result<(AsyncClient, EventLoop), String> { + let (host, port_str) = host_url + .split_once(':') + .ok_or_else(|| format!("Invalid broker URL '{host_url}', expected host:port"))?; + let port: u16 = port_str + .parse() + .map_err(|_| format!("Invalid port: {port_str}"))?; + + let client_id = format!( + "Calypso-Sim-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + ); + + let mut mqtt_opts = MqttOptions::new(client_id, host, port); + mqtt_opts + .set_keep_alive(Duration::from_secs(20)) + .set_clean_start(true) + .set_connection_timeout(3) + .set_session_expiry_interval(Some(u32::MAX)) + .set_topic_alias_max(Some(600)); + let (client, eventloop) = AsyncClient::new(mqtt_opts, 600); + Ok((client, eventloop)) +} diff --git a/src/bin/calypso-sim/modes/auto_script.rs b/src/bin/calypso-sim/modes/auto_script.rs new file mode 100644 index 0000000..dbcdf87 --- /dev/null +++ b/src/bin/calypso-sim/modes/auto_script.rs @@ -0,0 +1,79 @@ +use std::time::Duration; + +use rumqttc::v5::AsyncClient; + +use crate::keymap::{KeyMode, build_topic_states, load_key_map, publish_injection}; +use crate::registry::{Owner, SharedRegistry}; + +/// Run a scripted sequence from a text file, then return. Each line is +/// either a single character (fires that key from the keymap) or +/// `sleep `. Blank lines and lines starting with `#` are ignored. +pub async fn run( + client: AsyncClient, + key_map_path: &str, + script_path: &str, + registry: SharedRegistry, +) -> Result<(), String> { + let key_map = load_key_map(key_map_path)?; + if key_map.is_empty() { + return Err("Key map is empty".into()); + } + let mut states = build_topic_states(key_map); + if states.is_empty() { + return Err("No matching topics found for any key mapping".into()); + } + + { + let mut reg = registry.write().await; + for state in states.values() { + match &state.mode { + KeyMode::Sequence { steps } => { + for step in steps { + reg.set(&step.topic, Owner::Stream); + } + } + _ => { + reg.set(&state.topic, Owner::Stream); + } + } + } + } + + let script = std::fs::read_to_string(script_path) + .map_err(|e| format!("Failed to read script '{script_path}': {e}"))?; + + println!("Running scripted sequence from {script_path} ..."); + println!(); + + for (lineno, raw_line) in script.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(rest) = line.strip_prefix("sleep ") { + match rest.trim().parse::() { + Ok(ms) => tokio::time::sleep(Duration::from_millis(ms)).await, + Err(_) => eprintln!("script: line {}: bad sleep arg '{rest}'", lineno + 1), + } + continue; + } + let mut chars = line.chars(); + let Some(ch) = chars.next() else { continue }; + if chars.next().is_some() { + eprintln!( + "script: line {}: expected single char or 'sleep N', got '{line}'", + lineno + 1 + ); + continue; + } + if let Some(state) = states.get_mut(&ch) { + publish_injection(ch, state, &client, ®istry).await; + } else { + eprintln!("script: line {}: no binding for key '{ch}'", lineno + 1); + } + } + + // Allow broker time to flush. + tokio::time::sleep(Duration::from_millis(100)).await; + Ok(()) +} diff --git a/src/bin/calypso-sim/modes/autonomous.rs b/src/bin/calypso-sim/modes/autonomous.rs new file mode 100644 index 0000000..7c766fb --- /dev/null +++ b/src/bin/calypso-sim/modes/autonomous.rs @@ -0,0 +1,103 @@ +use std::time::Duration; + +use calypso::simulate_data::create_simulated_components; +use regex::Regex; +use rumqttc::v5::AsyncClient; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use crate::publish::publish_data; +use crate::registry::{Owner, SharedRegistry}; + +#[derive(Debug)] +enum FilterMode { + Disabled, + Blacklist(Vec), + Whitelist(Vec), +} + +impl FilterMode { + fn build(enable: &[String], disable: &[String]) -> Result { + if !disable.is_empty() { + Ok(Self::Blacklist(compile_patterns(disable)?)) + } else if !enable.is_empty() { + Ok(Self::Whitelist(compile_patterns(enable)?)) + } else { + Ok(Self::Disabled) + } + } + + fn allows(&self, topic: &str) -> bool { + match self { + FilterMode::Disabled => true, + FilterMode::Blacklist(p) => !p.iter().any(|re| re.is_match(topic)), + FilterMode::Whitelist(p) => p.iter().any(|re| re.is_match(topic)), + } + } +} + +fn compile_patterns(patterns: &[String]) -> Result, String> { + patterns + .iter() + .map(|p| Regex::new(p).map_err(|e| format!("Invalid regex '{p}': {e}"))) + .collect() +} + +/// Background task: every 5ms, walk the simulated components and publish any +/// that are due (per `sim_freq`) AND owned by `Owner::Auto` in the registry. +/// +/// Components owned by `Stream` or `Silenced` are skipped without advancing +/// internal state, so they pick up where they would have been on `release`. +pub async fn run( + token: CancellationToken, + client: AsyncClient, + registry: SharedRegistry, + enable: Vec, + disable: Vec, +) { + let filter = match FilterMode::build(&enable, &disable) { + Ok(f) => f, + Err(err) => { + eprintln!("Autonomous mode: {err}"); + return; + } + }; + + let mut components: Vec<_> = create_simulated_components() + .into_iter() + .filter(|c| filter.allows(&c.name)) + .collect(); + + if components.is_empty() { + info!("Autonomous: no components match the filter; nothing to simulate."); + } else { + info!("Autonomous: simulating {} components", components.len()); + } + + let mut interval = tokio::time::interval(Duration::from_millis(5)); + + loop { + tokio::select! { + () = token.cancelled() => { + debug!("Autonomous: shutting down."); + break; + } + _ = interval.tick() => { + for component in &mut components { + if !component.should_update() { + continue; + } + let owner = registry.read().await.owner(&component.name); + if owner != Owner::Auto { + continue; + } + component.update(); + let data = component.get_decode_data(); + if let Err(e) = publish_data(&client, &data.topic, &data.unit, &data.value).await { + warn!("Autonomous publish failed for {}: {e}", data.topic); + } + } + } + } + } +} diff --git a/src/bin/calypso-sim/modes/interactive.rs b/src/bin/calypso-sim/modes/interactive.rs new file mode 100644 index 0000000..b1eb35b --- /dev/null +++ b/src/bin/calypso-sim/modes/interactive.rs @@ -0,0 +1,146 @@ +use std::collections::HashMap; +use std::io::Write; + +use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use futures_util::StreamExt; +use rumqttc::v5::AsyncClient; +use tokio_util::sync::CancellationToken; + +use crate::keymap::{ + KeyMode, KeyState, build_topic_states, desc_suffix, load_key_map, publish_injection, + unit_suffix, +}; +use crate::raw_mode::{RawModeGuard, line_end}; +use crate::registry::{Owner, SharedRegistry}; + +/// Run the interactive raw-mode keypress loop. Claims every keymap topic in +/// the registry so the autonomous loop (if running) yields ownership. +pub async fn run( + token: CancellationToken, + client: AsyncClient, + key_map_path: &str, + registry: SharedRegistry, +) -> Result<(), String> { + let key_map = load_key_map(key_map_path)?; + if key_map.is_empty() { + return Err("Key map is empty".into()); + } + let mut states = build_topic_states(key_map); + if states.is_empty() { + return Err("No matching topics found for any key mapping".into()); + } + + claim_keymap_topics(&states, ®istry).await; + + print_listing(&states); + println!("Press mapped keys to inject. Ctrl+C to exit."); + println!(); + + let guard = RawModeGuard::new().map_err(|e| format!("Failed to enable raw mode: {e}"))?; + + let mut reader = EventStream::new(); + + loop { + tokio::select! { + () = token.cancelled() => break, + event = reader.next() => match event { + Some(Ok(Event::Key(KeyEvent { + code: KeyCode::Char('c'), + modifiers, + kind: KeyEventKind::Press, + .. + }))) if modifiers.contains(KeyModifiers::CONTROL) => break, + Some(Ok(Event::Key(KeyEvent { + code: KeyCode::Char(ch), + kind: KeyEventKind::Press, + .. + }))) => { + if let Some(state) = states.get_mut(&ch) { + publish_injection(ch, state, &client, ®istry).await; + } + } + Some(Err(e)) => { + print!("Terminal event error: {e}{}", line_end()); + let _ = std::io::stdout().flush(); + break; + } + None => break, + _ => {} + } + } + } + + drop(guard); + println!(); + println!("Shutting down..."); + Ok(()) +} + +async fn claim_keymap_topics(states: &HashMap, registry: &SharedRegistry) { + let mut reg = registry.write().await; + for state in states.values() { + match &state.mode { + KeyMode::Sequence { steps } => { + for step in steps { + reg.set(&step.topic, Owner::Stream); + } + } + _ => { + reg.set(&state.topic, Owner::Stream); + } + } + } +} + +fn print_listing(states: &HashMap) { + println!("Key Mappings:"); + let mut sorted_keys: Vec = states.keys().copied().collect(); + sorted_keys.sort_unstable(); + for key in &sorted_keys { + let state = &states[key]; + let unit_s = unit_suffix(&state.unit); + let desc_s = desc_suffix(state.desc.as_deref()); + match &state.mode { + KeyMode::Random => { + println!(" {key} → {} (random){unit_s}{desc_s}", state.topic); + } + KeyMode::Pinned { value } => { + println!(" {key} → {} = {value}{unit_s}{desc_s}", state.topic); + } + KeyMode::Increment { + current, + step, + min, + max, + } => { + let bounds = match (min, max) { + (Some(lo), Some(hi)) => format!(" in [{lo}, {hi}]"), + (Some(lo), None) => format!(" ≥ {lo}"), + (None, Some(hi)) => format!(" ≤ {hi}"), + (None, None) => String::new(), + }; + println!( + " {key} → {} starting {current} step {step}{bounds}{unit_s}{desc_s}", + state.topic + ); + } + KeyMode::Sequence { steps } => { + println!(" {key} → sequence ({} steps){desc_s}:", steps.len()); + for step in steps { + let delay = if step.delay_ms > 0 { + format!(" +{}ms", step.delay_ms) + } else { + String::new() + }; + let step_unit_s = unit_suffix(step.unit.as_deref().unwrap_or("")); + println!( + " {topic} = {value}{step_unit_s}{delay}", + topic = step.topic, + value = step.value + ); + } + } + } + } + println!(); +} diff --git a/src/bin/calypso-sim/modes/stream.rs b/src/bin/calypso-sim/modes/stream.rs new file mode 100644 index 0000000..3e5e7c1 --- /dev/null +++ b/src/bin/calypso-sim/modes/stream.rs @@ -0,0 +1,194 @@ +use std::io::{self, Write}; + +use calypso::simulate_data::create_simulated_components; +use rumqttc::v5::AsyncClient; +use serde::Deserialize; +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio_util::sync::CancellationToken; + +use crate::publish::publish_data; +use crate::registry::{Owner, SharedRegistry}; + +/// JSON-RPC 2.0 over stdio. Reads one request per line from stdin, writes +/// one response per line to stdout. Diagnostics go to stderr. +/// +/// Methods: +/// * `publish` — `{topic, value | values, unit?}` → `{ts_us}` +/// * `claim` — `{topic}` → `{previous_owner, owner}` +/// * `release` — `{topic}` → `{previous_owner, owner}` (sets owner=auto) +/// * `silence` — `{topic}` → `{previous_owner, owner}` +/// * `status` — `{}` → `{overrides: [{topic, owner}, ...]}` +/// * `list_topics` — `{}` → `{topics: [{name, unit}, ...]}` +/// * `ping` — `{}` → `{ok: true}` +pub async fn run( + token: CancellationToken, + client: AsyncClient, + registry: SharedRegistry, +) -> Result<(), String> { + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin).lines(); + + loop { + tokio::select! { + () = token.cancelled() => break, + line = reader.next_line() => match line { + Ok(Some(line)) => { + if line.trim().is_empty() { + continue; + } + let resp = handle_line(&line, &client, ®istry).await; + write_line(&resp); + } + Ok(None) => break, // stdin closed + Err(e) => { + eprintln!("stream: stdin read error: {e}"); + break; + } + } + } + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct Request { + #[serde(default)] + jsonrpc: Option, + #[serde(default)] + id: Option, + method: String, + #[serde(default)] + params: Value, +} + +const ERR_PARSE: i32 = -32700; +const ERR_INVALID_REQUEST: i32 = -32600; +const ERR_METHOD_NOT_FOUND: i32 = -32601; +const ERR_INVALID_PARAMS: i32 = -32602; +const ERR_INTERNAL: i32 = -32603; + +async fn handle_line(line: &str, client: &AsyncClient, registry: &SharedRegistry) -> Value { + let request: Request = match serde_json::from_str(line) { + Ok(r) => r, + Err(e) => return error(Value::Null, ERR_PARSE, &format!("Parse error: {e}")), + }; + + if let Some(ver) = &request.jsonrpc + && ver != "2.0" { + return error( + request.id.unwrap_or(Value::Null), + ERR_INVALID_REQUEST, + "jsonrpc version must be \"2.0\"", + ); + } + let id = request.id.unwrap_or(Value::Null); + + match request.method.as_str() { + "publish" => handle_publish(id, request.params, client, registry).await, + "claim" => handle_set(id, request.params, registry, Owner::Stream).await, + "release" => handle_set(id, request.params, registry, Owner::Auto).await, + "silence" => handle_set(id, request.params, registry, Owner::Silenced).await, + "status" => handle_status(id, registry).await, + "list_topics" => handle_list_topics(id), + "ping" => ok(id, json!({"ok": true})), + other => error(id, ERR_METHOD_NOT_FOUND, &format!("Unknown method: {other}")), + } +} + +#[derive(Deserialize)] +struct PublishParams { + topic: String, + #[serde(default)] + value: Option, + #[serde(default)] + values: Option>, + #[serde(default)] + unit: Option, +} + +async fn handle_publish( + id: Value, + params: Value, + client: &AsyncClient, + registry: &SharedRegistry, +) -> Value { + let p: PublishParams = match serde_json::from_value(params) { + Ok(v) => v, + Err(e) => return error(id, ERR_INVALID_PARAMS, &format!("Invalid params: {e}")), + }; + + let values = match (p.value, p.values) { + (Some(v), None) => vec![v], + (None, Some(vs)) if !vs.is_empty() => vs, + (Some(_), Some(_)) => { + return error(id, ERR_INVALID_PARAMS, "specify `value` or `values`, not both"); + } + (None, None | Some(_)) => { + return error(id, ERR_INVALID_PARAMS, "missing `value` or `values`"); + } + }; + + if registry.read().await.owner(&p.topic) == Owner::Silenced { + return ok(id, json!({"skipped": "silenced"})); + } + + let unit = p.unit.unwrap_or_default(); + match publish_data(client, &p.topic, &unit, &values).await { + Ok(ts_us) => ok(id, json!({"ts_us": ts_us})), + Err(e) => error(id, ERR_INTERNAL, &format!("publish failed: {e}")), + } +} + +#[derive(Deserialize)] +struct TopicParam { + topic: String, +} + +async fn handle_set(id: Value, params: Value, registry: &SharedRegistry, owner: Owner) -> Value { + let p: TopicParam = match serde_json::from_value(params) { + Ok(v) => v, + Err(e) => return error(id, ERR_INVALID_PARAMS, &format!("Invalid params: {e}")), + }; + + let prev = registry.write().await.set(&p.topic, owner); + ok( + id, + json!({"topic": p.topic, "previous_owner": prev.as_str(), "owner": owner.as_str()}), + ) +} + +async fn handle_status(id: Value, registry: &SharedRegistry) -> Value { + let snap = registry.read().await.snapshot(); + let entries: Vec<_> = snap + .into_iter() + .map(|(t, o)| json!({"topic": t, "owner": o.as_str()})) + .collect(); + ok(id, json!({"overrides": entries})) +} + +fn handle_list_topics(id: Value) -> Value { + let components = create_simulated_components(); + let topics: Vec<_> = components + .iter() + .map(|c| json!({"name": c.name, "unit": c.unit})) + .collect(); + ok(id, json!({"topics": topics})) +} + +#[allow(clippy::needless_pass_by_value)] +fn ok(id: Value, result: impl serde::Serialize) -> Value { + json!({"jsonrpc": "2.0", "id": id, "result": result}) +} + +#[allow(clippy::needless_pass_by_value)] +fn error(id: Value, code: i32, message: &str) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) +} + +fn write_line(value: &Value) { + let s = serde_json::to_string(value).unwrap_or_else(|_| "{}".into()); + let mut out = io::stdout().lock(); + let _ = writeln!(out, "{s}"); + let _ = out.flush(); +} diff --git a/src/bin/calypso-sim/publish.rs b/src/bin/calypso-sim/publish.rs new file mode 100644 index 0000000..48344d9 --- /dev/null +++ b/src/bin/calypso-sim/publish.rs @@ -0,0 +1,36 @@ +use std::time::UNIX_EPOCH; + +use calypso::proto::serverdata; +use protobuf::Message; +use rumqttc::v5::AsyncClient; +use rumqttc::v5::mqttbytes::QoS; + +/// Encode a `ServerData` payload and publish it to the broker. Returns the +/// timestamp (microseconds since UNIX epoch) embedded in the payload. +pub async fn publish_data( + client: &AsyncClient, + topic: &str, + unit: &str, + values: &[f32], +) -> Result { + let timestamp = UNIX_EPOCH + .elapsed() + .map(|d| d.as_micros() as u64) + .unwrap_or(0); + + let mut payload = serverdata::ServerData::new(); + payload.unit = unit.to_string(); + payload.values = values.to_vec(); + payload.time_us = timestamp; + + let bytes = payload + .write_to_bytes() + .map_err(|e| format!("serialize: {e}"))?; + + client + .publish(topic, QoS::AtMostOnce, false, bytes) + .await + .map_err(|e| format!("publish: {e}"))?; + + Ok(timestamp) +} diff --git a/src/bin/calypso-sim/raw_mode.rs b/src/bin/calypso-sim/raw_mode.rs new file mode 100644 index 0000000..bf13433 --- /dev/null +++ b/src/bin/calypso-sim/raw_mode.rs @@ -0,0 +1,36 @@ +use std::io::{self, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; + +/// In raw mode the tty driver does not translate `\n` to `\r\n`, so we must +/// emit `\r\n` ourselves. In cooked mode (script / autonomous / stream) a +/// literal `\r` renders as staircase output / `^M`, so we must emit `\n`. +static RAW_MODE_ACTIVE: AtomicBool = AtomicBool::new(false); + +pub fn line_end() -> &'static str { + if RAW_MODE_ACTIVE.load(Ordering::Relaxed) { + "\r\n" + } else { + "\n" + } +} + +/// RAII guard that enables raw mode on creation and restores on drop. +pub struct RawModeGuard; + +impl RawModeGuard { + pub fn new() -> io::Result { + enable_raw_mode()?; + RAW_MODE_ACTIVE.store(true, Ordering::Relaxed); + Ok(Self) + } +} + +impl Drop for RawModeGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + RAW_MODE_ACTIVE.store(false, Ordering::Relaxed); + let _ = io::stdout().flush(); + } +} diff --git a/src/bin/calypso-sim/registry.rs b/src/bin/calypso-sim/registry.rs new file mode 100644 index 0000000..ca63526 --- /dev/null +++ b/src/bin/calypso-sim/registry.rs @@ -0,0 +1,69 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::RwLock; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Owner { + /// Default state — autonomous heartbeat publishes this topic. + Auto, + /// Claimed by a streaming/keymap client; autonomous skips. + Stream, + /// Nobody publishes; both autonomous and stream skip. + Silenced, +} + +impl Owner { + pub fn as_str(self) -> &'static str { + match self { + Owner::Auto => "auto", + Owner::Stream => "stream", + Owner::Silenced => "silenced", + } + } +} + +pub type SharedRegistry = Arc>; + +/// Per-topic ownership. Topics not in the map default to `Owner::Auto`. +#[derive(Debug, Default)] +pub struct TopicRegistry { + overrides: HashMap, +} + +impl TopicRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn shared() -> SharedRegistry { + Arc::new(RwLock::new(Self::new())) + } + + pub fn owner(&self, topic: &str) -> Owner { + self.overrides.get(topic).copied().unwrap_or(Owner::Auto) + } + + /// Set ownership; returns the previous owner. Setting back to `Auto` + /// removes the override entirely. + pub fn set(&mut self, topic: &str, owner: Owner) -> Owner { + let prev = self.owner(topic); + if owner == Owner::Auto { + self.overrides.remove(topic); + } else { + self.overrides.insert(topic.to_string(), owner); + } + prev + } + + /// Snapshot of all non-`Auto` topic overrides, sorted by topic name. + pub fn snapshot(&self) -> Vec<(String, Owner)> { + let mut entries: Vec<_> = self + .overrides + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + entries + } +} diff --git a/src/bin/calypso-sim/warnings.rs b/src/bin/calypso-sim/warnings.rs new file mode 100644 index 0000000..86a2524 --- /dev/null +++ b/src/bin/calypso-sim/warnings.rs @@ -0,0 +1,45 @@ +use calypso_cangen::CANGEN_SPEC_PATH; +use calypso_cangen::can_types::OdysseyMsg; + +/// Print a comma-separated list of CAN message topics that have no +/// `sim_freq` in the spec — these are invisible to the autonomous simulator +/// and can only be published via `--key-map` / `--script` / `--stream`. +/// +/// Resolves the spec path relative to the current working directory; emits +/// nothing if the spec dir is missing. +pub fn print_unsimulated() { + let topics = collect_unsimulated_topics(); + if topics.is_empty() { + return; + } + eprintln!("Warning topics (not simulated): {}", topics.join(", ")); +} + +fn collect_unsimulated_topics() -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(CANGEN_SPEC_PATH) else { + return out; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() || path.extension().is_none_or(|ext| ext != "json") { + continue; + } + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(msgs): Result, _> = serde_json::from_str(&contents) else { + continue; + }; + for msg in msgs { + if let OdysseyMsg::Can(canmsg) = msg + && canmsg.sim_freq.is_none() { + for field in canmsg.fields { + out.push(field.name); + } + } + } + } + out.sort(); + out +} diff --git a/src/bin/simulate.rs b/src/bin/simulate.rs deleted file mode 100644 index 34b6ea4..0000000 --- a/src/bin/simulate.rs +++ /dev/null @@ -1,301 +0,0 @@ -use std::process::exit; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use calypso::{ - proto::serverdata::{self, ServerData}, - simulatable_message::SimComponent, - simulate_data::create_simulated_components, -}; -use clap::Parser; -use protobuf::Message; -use regex::Regex; -use rumqttc::v5::{AsyncClient, EventLoop, MqttOptions}; -use tokio::{signal, sync::mpsc}; -use tokio_util::{sync::CancellationToken, task::TaskTracker}; -use tracing::{debug, info, level_filters::LevelFilter, warn}; -use tracing_subscriber::{EnvFilter, fmt::format::FmtSpan}; - -/** -* The command line arguments for the simulator. -*/ -#[derive(Parser, Debug)] -#[command(version)] -struct CalypsoArgs { - /// The host url of the siren, including port and excluding protocol prefix - #[arg( - short = 'u', - long, - env = "CALYPSO_SIREN_HOST_URL", - default_value = "localhost:1883" - )] - siren_host_url: String, - - /// Disable topics matching regex patterns (blacklist mode) - #[arg(long = "disable-topic", conflicts_with = "enabled_topics")] - disabled_topics: Vec, - - /// Enable ONLY topics matching regex patterns (whitelist mode) - #[arg(long = "enable-topic", conflicts_with = "disabled_topics")] - enabled_topics: Vec, -} - -/** - * Filter mode for topic filtering - */ -#[derive(Debug, Clone)] -enum FilterMode { - /// Publish all topics except those matching patterns (blacklist) - Blacklist(Vec), - /// Publish only topics matching patterns (whitelist) - Whitelist(Vec), - /// No filtering, publish all topics - Disabled, -} - -/** - * Build `FilterMode` from CLI arguments, validating regex patterns - * Returns Err(String) if any regex pattern is invalid - */ -fn build_filter_mode(args: &CalypsoArgs) -> Result { - if !args.disabled_topics.is_empty() { - let mut regexes = Vec::new(); - for pattern in &args.disabled_topics { - match Regex::new(pattern) { - Ok(re) => regexes.push(re), - Err(e) => return Err(format!("Invalid regex pattern '{pattern}': {e}")), - } - } - Ok(FilterMode::Blacklist(regexes)) - } else if !args.enabled_topics.is_empty() { - let mut regexes = Vec::new(); - for pattern in &args.enabled_topics { - match Regex::new(pattern) { - Ok(re) => regexes.push(re), - Err(e) => return Err(format!("Invalid regex pattern '{pattern}': {e}")), - } - } - Ok(FilterMode::Whitelist(regexes)) - } else { - Ok(FilterMode::Disabled) - } -} - -/** - * Check if a topic should be published based on the filter mode - */ -fn should_publish(topic: &str, filter: &FilterMode) -> bool { - match filter { - FilterMode::Disabled => true, - FilterMode::Blacklist(patterns) => { - // Publish if topic does NOT match any blacklist pattern - !patterns.iter().any(|re| re.is_match(topic)) - } - FilterMode::Whitelist(patterns) => { - // Publish if topic matches at least one whitelist pattern - patterns.iter().any(|re| re.is_match(topic)) - } - } -} - -async fn simulate_out( - token: CancellationToken, - pub_channel: mpsc::Sender<(String, ServerData)>, - filter_mode: FilterMode, -) { - // todo: a way to turn individual components on and off - // note: components are pre-initialized within the function - let all_components = create_simulated_components(); - - // Filter components based on filter mode - let mut simulated_components: Vec = all_components - .into_iter() - .filter(|component| should_publish(&component.name, &filter_mode)) - .collect(); - - if simulated_components.is_empty() { - info!("No components to simulate after filtering. All topics filtered out."); - } else { - info!("Simulating {} components", simulated_components.len()); - } - - let mut interval = tokio::time::interval(Duration::from_millis(5)); - - loop { - tokio::select! { - () = token.cancelled() => { - debug!("Shutting down sim gen!"); - break; - }, - _ = interval.tick() => { - for component in &mut simulated_components { - if component.should_update() { - component.update(); - let timestamp = UNIX_EPOCH.elapsed().unwrap().as_micros() as u64; - let data: calypso::data::DecodeData = component.get_decode_data(); - let mut payload = serverdata::ServerData::new(); - payload.unit.clone_from(&data.unit); - payload.values = data.value; - payload.time_us = timestamp; - - pub_channel - .send(( - data.topic.clone(), - payload - )) - .await - .expect("Could not publish!"); - } - } - } - } - } -} - -/** - * A thread to publish messages to a MQTT client - * client: The client to publish to - * `recv_messages`: The channel to get the messages to publish - */ -async fn publish_stub( - token: CancellationToken, - client: AsyncClient, - mut recv_messages: mpsc::Receiver<(String, ServerData)>, -) { - loop { - tokio::select! { - () = token.cancelled() => { - debug!("Shutting down PUB stub!"); - break; - }, - Some(new_msg) = recv_messages.recv() => { - pub_msg(new_msg.0, new_msg.1, &client).await; - } - } - } -} - -/** - * A thread to poll MQTT broker status, and relay incoming subscribed messages - * eventloop: the eventloop to poll - * `send_to_manager`: the channel to send recieved MQTT messages from (optional) - */ -async fn poll_stub(token: CancellationToken, mut eventloop: EventLoop) { - loop { - tokio::select! { - () = token.cancelled() => { - debug!("Shutting down SIREN manager!"); - break; - }, - _ = eventloop.poll() => {} - } - } -} - -/** - * Helper function to generate bytes and publish a MQTT message - * topic: the topic to send - * data: the data protobuf to send - * client: the client to send data to - */ -async fn pub_msg(topic: String, data: ServerData, client: &AsyncClient) { - let Ok(bytes) = data.write_to_bytes() else { - warn!("Could not generate protobuf!"); - return; - }; - let Ok(()) = client - .publish(topic, rumqttc::v5::mqttbytes::QoS::AtMostOnce, false, bytes) - .await - else { - warn!("Could not publish message"); - return; - }; -} - -/** - * Main Function - * Calls the `simulate_out` function with the siren host URL from the command line arguments. - */ -#[tokio::main] -async fn main() { - let cli = CalypsoArgs::parse(); - - println!("Initializing fmt subscriber"); - // construct a subscriber that prints formatted traces to stdout - // if RUST_LOG is not set, defaults to loglevel INFO - let subscriber = tracing_subscriber::fmt() - .with_thread_ids(true) - .with_ansi(true) - .with_thread_names(true) - .with_span_events(FmtSpan::CLOSE) - .with_env_filter( - EnvFilter::builder() - .with_default_directive(LevelFilter::INFO.into()) - .from_env_lossy(), - ) - .finish(); - // use that subscriber to process traces emitted after this point - tracing::subscriber::set_global_default(subscriber).expect("Could not init tracing"); - - // the below two threads need to cancel cleanly to ensure all queued messages are sent. therefore they are part of the a task tracker group. - // create a task tracker and cancellation token - let task_tracker = TaskTracker::new(); - let token = CancellationToken::new(); - - // Build filter mode and validate regex patterns - let filter_mode = match build_filter_mode(&cli) { - Ok(mode) => mode, - Err(err) => { - eprintln!("Error: {err}"); - exit(1); - } - }; - - // a channel to give protobuf messages to be sent out over MQTT - let (decoder_send, decoder_recv) = mpsc::channel::<(String, ServerData)>(500); - - let mut mqtt_opts_main = MqttOptions::new( - format!( - "Calypso-Simulator-{}", - SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .expect("Time went backwards") - .as_millis() - ), - cli.siren_host_url - .split_once(':') - .expect("Invalid Siren URL") - .0, - cli.siren_host_url - .split_once(':') - .unwrap() - .1 - .parse::() - .expect("Invalid Siren port"), - ); - mqtt_opts_main - .set_keep_alive(Duration::from_secs(20)) - .set_clean_start(true) - .set_connection_timeout(3) - .set_session_expiry_interval(Some(u32::MAX)) - .set_topic_alias_max(Some(600)); - let (client, eventloop) = rumqttc::v5::AsyncClient::new(mqtt_opts_main, 600); - - task_tracker.spawn(poll_stub(token.clone(), eventloop)); - - task_tracker.spawn(publish_stub(token.clone(), client, decoder_recv)); - - task_tracker.spawn(simulate_out(token.clone(), decoder_send, filter_mode)); - - task_tracker.close(); - - info!("Initialization complete, ready..."); - info!("Use Ctrl+C or SIGINT to exit cleanly!"); - - signal::ctrl_c() - .await - .expect("Could not read cancellation trigger (ctr+c)"); - info!("Received exit signal, shutting down!"); - token.cancel(); - - task_tracker.wait().await; -} diff --git a/src/simulatable_message.rs b/src/simulatable_message.rs index 87bb00e..c08ac90 100644 --- a/src/simulatable_message.rs +++ b/src/simulatable_message.rs @@ -8,7 +8,7 @@ use std::time::Instant; /** * A `SimComponent` roughly corresponds to a `NetField` with properties inherited from `CANMsg` */ -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SimComponent { pub id: String, pub points: Vec, @@ -23,7 +23,7 @@ pub struct SimComponent { /** * Corresponds to `CANPoint` of a `NetField` */ -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SimPoint { pub size: usize, pub parse: Option, @@ -37,7 +37,7 @@ pub struct SimPoint { /** * The mode of simulation and the real-time value of the `CANPoint` */ -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum SimValue { /// Ranged mode where the value is within a min/max range and can include increment parameters. Range { From 7ac8de7be15d472c73f4eccc641291b13ef1f4d0 Mon Sep 17 00:00:00 2001 From: wyattb Date: Mon, 27 Apr 2026 06:24:44 -0400 Subject: [PATCH 02/29] #305 - extracted calypso-sim into standalone crate with Embedded-Base submodule --- .gitignore | 4 + Embedded-Base | 1 + Odyssey-Definitions | 2 +- README.md | 101 +- calypso-sim/Cargo.lock | 1705 +++++++++++++++++ calypso-sim/Cargo.toml | 41 + calypso-sim/Odyssey-Definitions | 1 + calypso-sim/README.md | 101 + calypso-sim/build.rs | 10 + .../manual_sim_buttons.keymap.json | 0 .../manual_sim_keymap.example.json | 0 .../calypso-sim => calypso-sim/src}/cli.rs | 24 +- calypso-sim/src/data.rs | 131 ++ .../calypso-sim => calypso-sim/src}/keymap.rs | 61 +- .../calypso-sim => calypso-sim/src}/main.rs | 53 +- .../src}/modes/auto_script.rs | 30 +- .../src}/modes/autonomous.rs | 2 +- .../src}/modes/interactive.rs | 30 +- calypso-sim/src/modes/mod.rs | 31 + .../src}/modes/stream.rs | 38 +- calypso-sim/src/proto/serverdata.proto | 13 + .../src}/publish.rs | 2 +- .../src}/raw_mode.rs | 0 .../src}/registry.rs | 0 calypso-sim/src/simulatable_message.rs | 274 +++ calypso-sim/src/simulate_data.rs | 3 + .../src}/warnings.rs | 9 +- 27 files changed, 2430 insertions(+), 237 deletions(-) create mode 160000 Embedded-Base create mode 100644 calypso-sim/Cargo.lock create mode 100644 calypso-sim/Cargo.toml create mode 120000 calypso-sim/Odyssey-Definitions create mode 100644 calypso-sim/README.md create mode 100644 calypso-sim/build.rs rename manual_sim_buttons.keymap.json => calypso-sim/manual_sim_buttons.keymap.json (100%) rename manual_sim_keymap.example.json => calypso-sim/manual_sim_keymap.example.json (100%) rename {src/bin/calypso-sim => calypso-sim/src}/cli.rs (79%) create mode 100644 calypso-sim/src/data.rs rename {src/bin/calypso-sim => calypso-sim/src}/keymap.rs (87%) rename {src/bin/calypso-sim => calypso-sim/src}/main.rs (81%) rename {src/bin/calypso-sim => calypso-sim/src}/modes/auto_script.rs (67%) rename {src/bin/calypso-sim => calypso-sim/src}/modes/autonomous.rs (98%) rename {src/bin/calypso-sim => calypso-sim/src}/modes/interactive.rs (82%) create mode 100644 calypso-sim/src/modes/mod.rs rename {src/bin/calypso-sim => calypso-sim/src}/modes/stream.rs (90%) create mode 100644 calypso-sim/src/proto/serverdata.proto rename {src/bin/calypso-sim => calypso-sim/src}/publish.rs (96%) rename {src/bin/calypso-sim => calypso-sim/src}/raw_mode.rs (100%) rename {src/bin/calypso-sim => calypso-sim/src}/registry.rs (100%) create mode 100644 calypso-sim/src/simulatable_message.rs create mode 100644 calypso-sim/src/simulate_data.rs rename {src/bin/calypso-sim => calypso-sim/src}/warnings.rs (89%) diff --git a/.gitignore b/.gitignore index abcf183..9668d40 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ serverdata.rs # private testing /privatetest/ + +# AI things +.claude +CLAUDE.md diff --git a/Embedded-Base b/Embedded-Base new file mode 160000 index 0000000..1b99c1c --- /dev/null +++ b/Embedded-Base @@ -0,0 +1 @@ +Subproject commit 1b99c1cbc44e3a52f21845b21f7a65a2b14fe27e diff --git a/Odyssey-Definitions b/Odyssey-Definitions index 5805bd2..8e17f6e 160000 --- a/Odyssey-Definitions +++ b/Odyssey-Definitions @@ -1 +1 @@ -Subproject commit 5805bd259c11d1913a8b1d711237d2a35cec95b1 +Subproject commit 8e17f6e4659101cb7e1d9fb793ab63ad7dda3ddd diff --git a/README.md b/README.md index 2a2c9de..413a604 100644 --- a/README.md +++ b/README.md @@ -39,103 +39,6 @@ Ex. `cansend vcan0 702#01010101FFFFFFFF` Now view calypso interpret the can message and broadcast it on `mqttui` -### Simulation (`calypso-sim`) +### Simulation -`calypso-sim` is a single binary that publishes simulated MQTT messages into the broker. It has four input modes that compose freely: - -| Mode | Flag | What it does | -|---|---|---| -| **Autonomous** | `--auto` | Heartbeat publishes for every CAN message with a `sim_freq` in the spec, at its configured frequency. Default when no other mode is chosen. | -| **Interactive** | `--key-map FILE` | Raw-mode terminal — each keypress fires a configured topic. Press `Ctrl+C` to exit. | -| **Script** | `--script FILE` (with `--key-map`) | Replay a sequence of keymap keys / `sleep N` lines from a text file, then exit. | -| **Stream** | `--stream` | JSON-RPC 2.0 over stdin/stdout — for agent-driven injection. `--auto` defaults OFF in this mode. | - -`--auto` can run alongside `--key-map` or `--stream` to provide a simulated background while you override specific topics. - -#### Quick reference - -``` -cargo run --bin calypso-sim -- --list-topics # enumerate topics, exit -cargo run --bin calypso-sim # autonomous heartbeat -cargo run --bin calypso-sim -- --key-map manual_sim_keymap.example.json -cargo run --bin calypso-sim -- --key-map keys.json --auto # background heartbeat + interactive -cargo run --bin calypso-sim -- --key-map keys.json --script play.txt -cargo run --bin calypso-sim -- --stream # JSON-RPC over stdio -cargo run --bin calypso-sim -- -u 10.0.0.5:1883 ... # remote broker -``` - -The `--enable-topic ` and `--disable-topic ` flags filter which topics the autonomous heartbeat publishes (whitelist / blacklist; mutually exclusive). Topics that lack a `sim_freq` in the CAN spec are listed at startup as a `Warning topics (not simulated): ...` line and can only be reached via `--key-map` or `--stream`. - -#### Topic-ownership model - -Every topic has an owner: `auto` (default — autonomous publishes), `stream` (claimed by stream/keymap), or `silenced` (nobody publishes). The autonomous loop checks ownership before each publish, so claims from `--stream` or keymap modes cleanly override the heartbeat without fighting it. Releasing a topic returns it to `auto`. - -#### Keymap format (`--key-map` and `--script`) - -A keymap is a JSON object mapping single-character keys to one of four entry shapes: - -```json -{ - "v": "BMS/Pack/Voltage", - "p": {"topic": "VCU/CarState/home_mode", "value": 1, "desc": "home pulse"}, - "n": {"topic": "VCU/CarState/nero_index", "value": 0, "step": 1, "max": 5, "desc": "cycle nero"}, - "w": { - "desc": "wrap menu", - "sequence": [ - {"topic": "VCU/CarState/nero_index", "value": 0}, - {"topic": "VCU/CarState/home_mode", "value": 1, "delay_ms": 10}, - {"topic": "VCU/CarState/home_mode", "value": 0, "delay_ms": 100} - ] - } -} -``` - -| Form | What it does | -|---|---| -| Bare topic string | **Random** — publishes a fresh randomized value within the topic's sim bounds. Topic must exist in the spec. | -| Object with `value` | **Pinned** — publishes that exact number every keypress. `unit` is required for unknown topics. | -| Object with `step` | **Increment** — emits the starting value first, then advances by `step` each keypress. `min`/`max` wrap independently when supplied. | -| Object with `sequence` | **Sequence** — publishes a scripted series of `(topic, value)` pairs with optional per-step `delay_ms`. | - -Every object form accepts an optional `desc` for the startup listing and inline log line. - -Script files (`--script`) contain one command per line: a single character (fires that key) or `sleep `. Blank lines and `#` comments are ignored. - -#### Stream mode protocol (`--stream`) - -JSON-RPC 2.0 over stdio — one request per line on stdin, one response per line on stdout, diagnostics on stderr. - -```jsonc -// stdin -{"jsonrpc":"2.0","id":1,"method":"publish","params":{"topic":"Wheel/Buttons/button_id","value":5}} -{"jsonrpc":"2.0","id":2,"method":"claim","params":{"topic":"VCU/CarState/home_mode"}} -{"jsonrpc":"2.0","id":3,"method":"publish","params":{"topic":"VCU/CarState/home_mode","value":1}} -{"jsonrpc":"2.0","id":4,"method":"release","params":{"topic":"VCU/CarState/home_mode"}} - -// stdout -{"jsonrpc":"2.0","id":1,"result":{"ts_us":1735347123456789}} -{"jsonrpc":"2.0","id":2,"result":{"topic":"...","previous_owner":"auto","owner":"stream"}} -... -``` - -| Method | Params | Result | -|---|---|---| -| `publish` | `{topic, value? \| values?, unit?}` | `{ts_us}` | -| `claim` | `{topic}` | `{topic, previous_owner, owner}` | -| `release` | `{topic}` | `{topic, previous_owner, owner: "auto"}` | -| `silence` | `{topic}` | `{topic, previous_owner, owner: "silenced"}` | -| `status` | `{}` | `{overrides: [{topic, owner}, ...]}` | -| `list_topics` | `{}` | `{topics: [{name, unit}, ...]}` | -| `ping` | `{}` | `{ok: true}` | - -Errors follow JSON-RPC 2.0 (`{error: {code, message}}` with codes -32600 through -32700 and -32603 for internal failures). - -#### Run from Docker - -The default container entry point is the main `calypso` decoder. To run `calypso-sim` instead, override the entry point: - -``` -docker pull ghcr.io/northeastern-electric-racing/calypso:Develop -docker run -d --rm -e CALYPSO_SIREN_HOST_URL=127.0.0.1:1883 --network host \ - --entrypoint calypso-sim ghcr.io/northeastern-electric-racing/calypso:Develop -``` +The MQTT simulator lives in [`calypso-sim/`](calypso-sim/) as its own standalone crate. See [calypso-sim/README.md](calypso-sim/README.md) for usage. diff --git a/calypso-sim/Cargo.lock b/calypso-sim/Cargo.lock new file mode 100644 index 0000000..1381f9b --- /dev/null +++ b/calypso-sim/Cargo.lock @@ -0,0 +1,1705 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calypso-cangen" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "schemars", + "serde", + "serde_json", + "serde_path_to_error", + "thiserror 2.0.18", +] + +[[package]] +name = "calypso-sim" +version = "0.1.0" +dependencies = [ + "calypso-cangen", + "clap", + "crossterm", + "daedalus", + "futures-util", + "protobuf", + "protobuf-codegen", + "rand", + "regex", + "rumqttc", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "daedalus" +version = "0.1.0" +dependencies = [ + "calypso-cangen", + "proc-macro2", + "quote", + "serde_json", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-codegen" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-parse" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" +dependencies = [ + "anyhow", + "indexmap", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror 1.0.69", + "which", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rumqttc" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0feff8d882bff0b2fddaf99355a10336d43dd3ed44204f85ece28cf9626ab519" +dependencies = [ + "bytes", + "fixedbitset", + "flume", + "futures-util", + "log", + "rustls-native-certs", + "rustls-pemfile", + "rustls-webpki 0.102.8", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "hashbrown 0.15.5", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/calypso-sim/Cargo.toml b/calypso-sim/Cargo.toml new file mode 100644 index 0000000..04c1c33 --- /dev/null +++ b/calypso-sim/Cargo.toml @@ -0,0 +1,41 @@ +# Empty [workspace] makes this its own workspace root, independent of the +# parent calypso workspace at /Cargo.toml. +[workspace] + +[package] +name = "calypso-sim" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +cast_possible_truncation = "allow" +too_many_lines = "allow" +cast_precision_loss = "allow" +cast_sign_loss = "allow" + +[dependencies] +rumqttc = "0.25.0" +protobuf = "3.7.2" +clap = { version = "4.5.43", features = ["derive", "env"] } +rand = "0.9.2" +regex = "1.12.3" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +tokio = { version = "1.47.1", features = ["full"] } +tokio-util = { version = "0.7.16", features = ["full"] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["ansi", "env-filter"] } +crossterm = { version = "0.28", features = ["event-stream"] } +futures-util = "0.3.31" +daedalus = { path = "../libs/daedalus" } +calypso-cangen = { path = "../libs/calypso-cangen" } + +[build-dependencies] +protobuf-codegen = "3.7.2" + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" diff --git a/calypso-sim/Odyssey-Definitions b/calypso-sim/Odyssey-Definitions new file mode 120000 index 0000000..d4b134a --- /dev/null +++ b/calypso-sim/Odyssey-Definitions @@ -0,0 +1 @@ +../Odyssey-Definitions \ No newline at end of file diff --git a/calypso-sim/README.md b/calypso-sim/README.md new file mode 100644 index 0000000..c5b928e --- /dev/null +++ b/calypso-sim/README.md @@ -0,0 +1,101 @@ +# calypso-sim + +Standalone MQTT simulation tool. Publishes simulated messages into the same broker the main `calypso` decoder uses, for testing UIs and dependent services without a live CAN bus. + +`calypso-sim` is its own crate (separate from `calypso`); build and run it from this directory. + +## Build + +``` +cd calypso-sim +cargo build --release +``` + +## Input modes + +| Mode | Flag | What it does | +|---|---|---| +| **Autonomous** | `--auto` | Heartbeat publishes for every CAN message with a `sim_freq` in the spec, at its configured frequency. Default when no other mode is chosen; defaults OFF when paired with `--key-map`, `--script`, or `--stream`. | +| **Interactive** | `--key-map FILE` | Raw-mode terminal — each keypress fires a configured topic. Press `Ctrl+C` to exit. | +| **Script** | `--script FILE` (with `--key-map`) | Replay a sequence of keymap keys / `sleep N` lines from a text file, then exit. | +| **Stream** | `--stream` | JSON-RPC 2.0 over stdin/stdout — for agent-driven injection. | + +Pick at most one foreground mode: `--key-map` (with optional `--script`) or `--stream`. `--auto` may run alongside either as a background heartbeat — set it explicitly to override the default-off behavior in those modes. + +## Quick reference + +``` +cargo run -- --list-topics # enumerate topics, exit +cargo run # autonomous heartbeat +cargo run -- --key-map manual_sim_keymap.example.json +cargo run -- --key-map keys.json --auto # background heartbeat + interactive +cargo run -- --key-map keys.json --script play.txt +cargo run -- --stream # JSON-RPC over stdio +cargo run -- -u 10.0.0.5:1883 ... # remote broker +``` + +The `--enable-topic ` and `--disable-topic ` flags filter which topics the autonomous heartbeat publishes (whitelist / blacklist; mutually exclusive). Topics that lack a `sim_freq` in the CAN spec are listed at startup as a `Warning topics (not simulated): ...` line and can only be reached via `--key-map` or `--stream`. + +## Topic-ownership model + +Every topic has an owner: `auto` (default — autonomous publishes), `stream` (claimed by stream/keymap), or `silenced` (nobody publishes). The autonomous loop checks ownership before each publish, so claims from `--stream` or keymap modes cleanly override the heartbeat without fighting it. Releasing a topic returns it to `auto`. + +## Keymap format (`--key-map` and `--script`) + +A keymap is a JSON object mapping single-character keys to one of four entry shapes: + +```json +{ + "v": "BMS/Pack/Voltage", + "p": {"topic": "VCU/CarState/home_mode", "value": 1, "desc": "home pulse"}, + "n": {"topic": "VCU/CarState/nero_index", "value": 0, "step": 1, "max": 5, "desc": "cycle nero"}, + "w": { + "desc": "wrap menu", + "sequence": [ + {"topic": "VCU/CarState/nero_index", "value": 0}, + {"topic": "VCU/CarState/home_mode", "value": 1, "delay_ms": 10}, + {"topic": "VCU/CarState/home_mode", "value": 0, "delay_ms": 100} + ] + } +} +``` + +| Form | What it does | +|---|---| +| Bare topic string | **Random** — publishes a fresh randomized value within the topic's sim bounds. Entries whose topic isn't in the spec are warned about on stderr and skipped at load. | +| Object with `value` | **Pinned** — publishes that exact number every keypress. `unit` is required for unknown topics. | +| Object with `step` | **Increment** — emits the starting value first, then advances by `step` each keypress. Start is `value` if set, else `min`, else `0`. `min`/`max` wrap independently when supplied. | +| Object with `sequence` | **Sequence** — publishes a scripted series of `(topic, value)` pairs with optional per-step `delay_ms`. | + +Every object form accepts an optional `desc` for the startup listing and inline log line. + +Script files (`--script`) contain one command per line: a single character (fires that key) or `sleep `. Blank lines and `#` comments are ignored. + +## Stream mode protocol (`--stream`) + +JSON-RPC 2.0 over stdio — one request per line on stdin, one response per line on stdout, diagnostics on stderr. + +```jsonc +// stdin +{"jsonrpc":"2.0","id":1,"method":"publish","params":{"topic":"Wheel/Buttons/button_id","value":5}} +{"jsonrpc":"2.0","id":2,"method":"claim","params":{"topic":"VCU/CarState/home_mode"}} +{"jsonrpc":"2.0","id":3,"method":"publish","params":{"topic":"VCU/CarState/home_mode","value":1}} +{"jsonrpc":"2.0","id":4,"method":"release","params":{"topic":"VCU/CarState/home_mode"}} + +// stdout +{"jsonrpc":"2.0","id":1,"result":{"ts_us":1735347123456789}} +{"jsonrpc":"2.0","id":2,"result":{"topic":"...","previous_owner":"auto","owner":"stream"}} +... +``` + +| Method | Params | Result | +|---|---|---| +| `publish` | `{topic, value? \| values?, unit?}` | `{ts_us}` | +| `claim` | `{topic}` | `{topic, previous_owner, owner}` | +| `release` | `{topic}` | `{topic, previous_owner, owner: "auto"}` | +| `silence` | `{topic}` | `{topic, previous_owner, owner: "silenced"}` | +| `status` | `{}` | `{overrides: [{topic, owner}, ...]}` | +| `list_topics` | `{}` | `{topics: [{name, unit}, ...]}` | +| `ping` | `{}` | `{ok: true}` | + +Errors follow JSON-RPC 2.0 (`{error: {code, message}}`) with the standard codes: `-32700` (parse), `-32600` (invalid request), `-32601` (method not found), `-32602` (invalid params), and `-32603` (internal). diff --git a/calypso-sim/build.rs b/calypso-sim/build.rs new file mode 100644 index 0000000..d38960a --- /dev/null +++ b/calypso-sim/build.rs @@ -0,0 +1,10 @@ +fn main() { + println!("cargo:rerun-if-changed=src/proto"); + + protobuf_codegen::Codegen::new() + .pure() + .includes(["src/proto"]) + .input("src/proto/serverdata.proto") + .out_dir("src/proto") + .run_from_script(); +} diff --git a/manual_sim_buttons.keymap.json b/calypso-sim/manual_sim_buttons.keymap.json similarity index 100% rename from manual_sim_buttons.keymap.json rename to calypso-sim/manual_sim_buttons.keymap.json diff --git a/manual_sim_keymap.example.json b/calypso-sim/manual_sim_keymap.example.json similarity index 100% rename from manual_sim_keymap.example.json rename to calypso-sim/manual_sim_keymap.example.json diff --git a/src/bin/calypso-sim/cli.rs b/calypso-sim/src/cli.rs similarity index 79% rename from src/bin/calypso-sim/cli.rs rename to calypso-sim/src/cli.rs index d0dcfef..ec8a324 100644 --- a/src/bin/calypso-sim/cli.rs +++ b/calypso-sim/src/cli.rs @@ -12,7 +12,7 @@ pub struct Cli { short = 'u', long, env = "CALYPSO_SIREN_HOST_URL", - default_value = "127.0.0.1:1883" + default_value = "localhost:1883" )] pub siren_host_url: String, @@ -49,7 +49,7 @@ pub struct Cli { /// Accept JSON-RPC 2.0 commands on stdin (one per line); replies on /// stdout. Tracing/diagnostics go to stderr. `--auto` defaults OFF in /// this mode unless explicitly set. - #[arg(long)] + #[arg(long, conflicts_with = "key_map")] pub stream: bool, } @@ -58,25 +58,7 @@ impl Cli { /// True if `--auto` was set, OR no other input mode was chosen and /// `--list-topics` wasn't requested. pub fn run_autonomous(&self) -> bool { - if self.auto { - return true; - } let any_other_mode = self.stream || self.key_map.is_some() || self.script.is_some(); - !any_other_mode && !self.list_topics - } - - /// Validate mutually-exclusive mode flags. - pub fn validate(&self) -> Result<(), String> { - let mut chosen = 0; - if self.stream { - chosen += 1; - } - if self.key_map.is_some() { - chosen += 1; - } - if chosen > 1 { - return Err("--stream and --key-map are mutually exclusive".into()); - } - Ok(()) + self.auto || (!any_other_mode && !self.list_topics) } } diff --git a/calypso-sim/src/data.rs b/calypso-sim/src/data.rs new file mode 100644 index 0000000..0dd54bf --- /dev/null +++ b/calypso-sim/src/data.rs @@ -0,0 +1,131 @@ +#![allow(dead_code)] +// Vendored from main calypso (src/data.rs). Some helpers are unused here; +// keep the file in sync with the upstream copy rather than trimming. + +use std::fmt; + +/** + * Wrapper Class for Data coming off the car + */ +pub struct DecodeData { + pub value: Vec, + pub topic: String, + pub unit: String, + pub clients: Option>, +} + +/** + * Implementation for the format of the data for debugging purposes + */ +impl fmt::Display for DecodeData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Overrides the string representation of the class. + + write!( + f, + "topic: {}, value: {:#?}, unit: {}, clients: {:#?}", + self.topic, self.value, self.unit, self.clients + ) + } +} + +/** + * Implementation fo the `DecodeData` methods + */ +impl DecodeData { + /** + * Constructor + * @param id: the id of the data + * @param value: the value of the data + * @param topic: the topic of the data + * @param clients: additional MQTT clients + */ + #[must_use] + pub fn new(value: Vec, topic: &str, unit: &str, clients: Option>) -> Self { + Self { + value, + topic: topic.to_string(), + unit: unit.to_string(), + clients, + } + } +} + +/** + * Wrapper Class for data going into the car + */ +pub struct EncodeData { + pub value: Vec, + pub id: u32, + pub is_ext: bool, +} + +/** + * Implementation for the format of the data for debugging purposes + */ +impl fmt::Display for EncodeData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Overrides the string representation of the class. + + write!( + f, + "{}#{:?} (extended: {})", + self.id, self.value, self.is_ext + ) + } +} + +/** + * Implementation fo the `DecodeData` methods + */ +impl EncodeData { + /** + * Constructor + * @param id: the id of the can message + * @param value: the can message payload + * @param `is_ext`: whether the can message is extended format ID + */ + #[must_use] + pub fn new(id: u32, value: Vec, is_ext: bool) -> Self { + Self { value, id, is_ext } + } +} + +/** + * Class to contain the data formatting functions + * _d = a func to decode a value + * _e = its counterpart to encode a value for sending on CAN line + */ +pub struct FormatData {} + +impl FormatData { + /* General divide function */ + #[must_use] + pub fn divide_d(value: f32, divisor: f32) -> f32 { + value / divisor + } + #[must_use] + pub fn divide_e(value: f32, multiplicand: f32) -> f32 { + value * multiplicand + } + + /* Energy meter temperature is (degC = raw * 0.5) according to datasheet */ + #[must_use] + pub fn temperature_d(value: f32, _divisor: f32) -> f32 { + value * 0.5 + } + #[must_use] + pub fn temperature_e(value: f32, _multiplicand: f32) -> f32 { + value * 2.0 + } + + /* Energy meter temperature indices are determined by multiplexor signal */ + #[must_use] + pub fn multiply_d(value: f32, multiplicand: f32) -> f32 { + value * multiplicand + } + #[must_use] + pub fn multiply_e(value: f32, divisor: f32) -> f32 { + value / divisor + } +} diff --git a/src/bin/calypso-sim/keymap.rs b/calypso-sim/src/keymap.rs similarity index 87% rename from src/bin/calypso-sim/keymap.rs rename to calypso-sim/src/keymap.rs index c44d433..588fabb 100644 --- a/src/bin/calypso-sim/keymap.rs +++ b/calypso-sim/src/keymap.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::io::Write; use std::time::Duration; -use calypso::simulatable_message::{SimComponent, SimValue}; -use calypso::simulate_data::create_simulated_components; +use crate::simulatable_message::{SimComponent, SimValue}; +use crate::simulate_data::create_simulated_components; use rand::prelude::*; use rumqttc::v5::AsyncClient; use serde::Deserialize; @@ -12,6 +12,38 @@ use crate::publish::publish_data; use crate::raw_mode::line_end; use crate::registry::{Owner, SharedRegistry}; +/// Build the topic states from a keymap file, erroring if the resulting set +/// is empty. +pub fn load_states(key_map_path: &str) -> Result, String> { + let key_map = load_key_map(key_map_path)?; + if key_map.is_empty() { + return Err("Key map is empty".into()); + } + let states = build_topic_states(key_map); + if states.is_empty() { + return Err("No matching topics found for any key mapping".into()); + } + Ok(states) +} + +/// Claim every topic referenced by `states` for `Owner::Stream` so the +/// autonomous heartbeat (if running) yields ownership. +pub async fn claim_keymap_topics(states: &HashMap, registry: &SharedRegistry) { + let mut reg = registry.write().await; + for state in states.values() { + match &state.mode { + KeyMode::Sequence { steps } => { + for step in steps { + reg.set(&step.topic, Owner::Stream); + } + } + _ => { + reg.set(&state.topic, Owner::Stream); + } + } + } +} + /// A keymap entry. Four forms: /// * Bare topic string — random value within sim bounds (requires the topic /// to be in the auto-generated simulated-components list). @@ -257,22 +289,19 @@ pub fn randomize_component(component: &mut SimComponent) { /// Advance an increment-mode state and return the value to publish *before* /// the advance (so the first press emits the starting value). -pub fn advance_increment( - current: &mut f32, - step: f32, - min: Option, - max: Option, -) -> f32 { +pub fn advance_increment(current: &mut f32, step: f32, min: Option, max: Option) -> f32 { let emitted = *current; let mut next = *current + step; if let Some(hi) = max - && next > hi { - next = min.unwrap_or(hi); - } + && next > hi + { + next = min.unwrap_or(hi); + } if let Some(lo) = min - && next < lo { - next = max.unwrap_or(lo); - } + && next < lo + { + next = max.unwrap_or(lo); + } *current = next; emitted } @@ -287,9 +316,7 @@ fn resolve_single_publish(state: &mut KeyState) -> Option<(String, String, Vec { - Some((state.topic.clone(), state.unit.clone(), vec![*value])) - } + KeyMode::Pinned { value } => Some((state.topic.clone(), state.unit.clone(), vec![*value])), KeyMode::Increment { current, step, diff --git a/src/bin/calypso-sim/main.rs b/calypso-sim/src/main.rs similarity index 81% rename from src/bin/calypso-sim/main.rs rename to calypso-sim/src/main.rs index 77ccc31..1a02452 100644 --- a/src/bin/calypso-sim/main.rs +++ b/calypso-sim/src/main.rs @@ -1,15 +1,20 @@ mod cli; +mod data; mod keymap; mod modes; +#[allow(clippy::all, clippy::pedantic)] +mod proto; mod publish; mod raw_mode; mod registry; +mod simulatable_message; +mod simulate_data; mod warnings; use std::process::exit; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use calypso::simulate_data::create_simulated_components; +use crate::simulate_data::create_simulated_components; use clap::Parser; use rumqttc::v5::{AsyncClient, EventLoop, MqttOptions}; use tokio_util::sync::CancellationToken; @@ -29,20 +34,12 @@ async fn main() { list_topics_and_exit(); } - if let Err(err) = cli.validate() { - eprintln!("Error: {err}"); - exit(2); - } - warnings::print_unsimulated(); - let (client, eventloop) = match connect_mqtt(&cli.siren_host_url) { - Ok(pair) => pair, - Err(err) => { - eprintln!("Error: {err}"); - exit(1); - } - }; + let (client, eventloop) = connect_mqtt(&cli.siren_host_url).unwrap_or_else(|err| { + eprintln!("Error: {err}"); + exit(1); + }); let token = CancellationToken::new(); let poll_handle = tokio::spawn(modes::poll_eventloop(token.clone(), eventloop)); @@ -64,10 +61,14 @@ async fn main() { let foreground = run_foreground(&cli, &token, &client, ®istry).await; token.cancel(); - if let Some(h) = auto_handle { - let _ = h.await; + if let Some(h) = auto_handle + && let Err(e) = h.await + { + tracing::error!("autonomous task panicked: {e}"); + } + if let Err(e) = poll_handle.await { + tracing::error!("MQTT eventloop task panicked: {e}"); } - let _ = poll_handle.await; tokio::time::sleep(Duration::from_millis(50)).await; if let Err(err) = foreground { @@ -91,13 +92,18 @@ async fn run_foreground( .ok_or_else(|| "--script requires --key-map".to_string())?; modes::auto_script::run(client.clone(), key_map_path, script_path, registry.clone()).await } else if let Some(key_map_path) = &cli.key_map { - modes::interactive::run(token.clone(), client.clone(), key_map_path, registry.clone()).await + modes::interactive::run( + token.clone(), + client.clone(), + key_map_path, + registry.clone(), + ) + .await } else { // Pure --auto: wait for SIGINT, then exit. - match tokio::signal::ctrl_c().await { - Ok(()) => Ok(()), - Err(e) => Err(format!("ctrl+c handler failed: {e}")), - } + tokio::signal::ctrl_c() + .await + .map_err(|e| format!("ctrl+c handler failed: {e}")) } } @@ -106,8 +112,6 @@ fn init_tracing() { // and keymap-mode logs. let subscriber = tracing_subscriber::fmt() .with_writer(std::io::stderr) - .with_thread_ids(false) - .with_ansi(true) .with_span_events(FmtSpan::CLOSE) .with_env_filter( EnvFilter::builder() @@ -150,6 +154,5 @@ fn connect_mqtt(host_url: &str) -> Result<(AsyncClient, EventLoop), String> { .set_connection_timeout(3) .set_session_expiry_interval(Some(u32::MAX)) .set_topic_alias_max(Some(600)); - let (client, eventloop) = AsyncClient::new(mqtt_opts, 600); - Ok((client, eventloop)) + Ok(AsyncClient::new(mqtt_opts, 600)) } diff --git a/src/bin/calypso-sim/modes/auto_script.rs b/calypso-sim/src/modes/auto_script.rs similarity index 67% rename from src/bin/calypso-sim/modes/auto_script.rs rename to calypso-sim/src/modes/auto_script.rs index dbcdf87..b58ed7c 100644 --- a/src/bin/calypso-sim/modes/auto_script.rs +++ b/calypso-sim/src/modes/auto_script.rs @@ -2,8 +2,8 @@ use std::time::Duration; use rumqttc::v5::AsyncClient; -use crate::keymap::{KeyMode, build_topic_states, load_key_map, publish_injection}; -use crate::registry::{Owner, SharedRegistry}; +use crate::keymap::{claim_keymap_topics, load_states, publish_injection}; +use crate::registry::SharedRegistry; /// Run a scripted sequence from a text file, then return. Each line is /// either a single character (fires that key from the keymap) or @@ -14,30 +14,8 @@ pub async fn run( script_path: &str, registry: SharedRegistry, ) -> Result<(), String> { - let key_map = load_key_map(key_map_path)?; - if key_map.is_empty() { - return Err("Key map is empty".into()); - } - let mut states = build_topic_states(key_map); - if states.is_empty() { - return Err("No matching topics found for any key mapping".into()); - } - - { - let mut reg = registry.write().await; - for state in states.values() { - match &state.mode { - KeyMode::Sequence { steps } => { - for step in steps { - reg.set(&step.topic, Owner::Stream); - } - } - _ => { - reg.set(&state.topic, Owner::Stream); - } - } - } - } + let mut states = load_states(key_map_path)?; + claim_keymap_topics(&states, ®istry).await; let script = std::fs::read_to_string(script_path) .map_err(|e| format!("Failed to read script '{script_path}': {e}"))?; diff --git a/src/bin/calypso-sim/modes/autonomous.rs b/calypso-sim/src/modes/autonomous.rs similarity index 98% rename from src/bin/calypso-sim/modes/autonomous.rs rename to calypso-sim/src/modes/autonomous.rs index 7c766fb..faf2f15 100644 --- a/src/bin/calypso-sim/modes/autonomous.rs +++ b/calypso-sim/src/modes/autonomous.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use calypso::simulate_data::create_simulated_components; +use crate::simulate_data::create_simulated_components; use regex::Regex; use rumqttc::v5::AsyncClient; use tokio_util::sync::CancellationToken; diff --git a/src/bin/calypso-sim/modes/interactive.rs b/calypso-sim/src/modes/interactive.rs similarity index 82% rename from src/bin/calypso-sim/modes/interactive.rs rename to calypso-sim/src/modes/interactive.rs index b1eb35b..afb7377 100644 --- a/src/bin/calypso-sim/modes/interactive.rs +++ b/calypso-sim/src/modes/interactive.rs @@ -7,11 +7,11 @@ use rumqttc::v5::AsyncClient; use tokio_util::sync::CancellationToken; use crate::keymap::{ - KeyMode, KeyState, build_topic_states, desc_suffix, load_key_map, publish_injection, + KeyMode, KeyState, claim_keymap_topics, desc_suffix, load_states, publish_injection, unit_suffix, }; use crate::raw_mode::{RawModeGuard, line_end}; -use crate::registry::{Owner, SharedRegistry}; +use crate::registry::SharedRegistry; /// Run the interactive raw-mode keypress loop. Claims every keymap topic in /// the registry so the autonomous loop (if running) yields ownership. @@ -21,15 +21,7 @@ pub async fn run( key_map_path: &str, registry: SharedRegistry, ) -> Result<(), String> { - let key_map = load_key_map(key_map_path)?; - if key_map.is_empty() { - return Err("Key map is empty".into()); - } - let mut states = build_topic_states(key_map); - if states.is_empty() { - return Err("No matching topics found for any key mapping".into()); - } - + let mut states = load_states(key_map_path)?; claim_keymap_topics(&states, ®istry).await; print_listing(&states); @@ -76,22 +68,6 @@ pub async fn run( Ok(()) } -async fn claim_keymap_topics(states: &HashMap, registry: &SharedRegistry) { - let mut reg = registry.write().await; - for state in states.values() { - match &state.mode { - KeyMode::Sequence { steps } => { - for step in steps { - reg.set(&step.topic, Owner::Stream); - } - } - _ => { - reg.set(&state.topic, Owner::Stream); - } - } - } -} - fn print_listing(states: &HashMap) { println!("Key Mappings:"); let mut sorted_keys: Vec = states.keys().copied().collect(); diff --git a/calypso-sim/src/modes/mod.rs b/calypso-sim/src/modes/mod.rs new file mode 100644 index 0000000..563ceda --- /dev/null +++ b/calypso-sim/src/modes/mod.rs @@ -0,0 +1,31 @@ +pub mod auto_script; +pub mod autonomous; +pub mod interactive; +pub mod stream; + +use std::time::Duration; + +use rumqttc::v5::EventLoop; +use tokio_util::sync::CancellationToken; + +/// Background task that drives the MQTT eventloop. Required for any publish +/// to actually go through. rumqttc's `poll()` calls `clean()` internally on +/// error and reconnects on the next call, so we just keep looping. +pub async fn poll_eventloop(token: CancellationToken, mut eventloop: EventLoop) { + loop { + tokio::select! { + () = token.cancelled() => break, + result = eventloop.poll() => { + if let Err(e) = result { + tracing::error!("MQTT eventloop error: {e}"); + // Avoid tight-looping if poll() returns immediately on a + // local error; cooperate with cancellation during backoff. + tokio::select! { + () = token.cancelled() => break, + () = tokio::time::sleep(Duration::from_millis(500)) => {} + } + } + } + } + } +} diff --git a/src/bin/calypso-sim/modes/stream.rs b/calypso-sim/src/modes/stream.rs similarity index 90% rename from src/bin/calypso-sim/modes/stream.rs rename to calypso-sim/src/modes/stream.rs index 3e5e7c1..22c59d8 100644 --- a/src/bin/calypso-sim/modes/stream.rs +++ b/calypso-sim/src/modes/stream.rs @@ -1,6 +1,6 @@ use std::io::{self, Write}; -use calypso::simulate_data::create_simulated_components; +use crate::simulate_data::create_simulated_components; use rumqttc::v5::AsyncClient; use serde::Deserialize; use serde_json::{Value, json}; @@ -75,13 +75,14 @@ async fn handle_line(line: &str, client: &AsyncClient, registry: &SharedRegistry }; if let Some(ver) = &request.jsonrpc - && ver != "2.0" { - return error( - request.id.unwrap_or(Value::Null), - ERR_INVALID_REQUEST, - "jsonrpc version must be \"2.0\"", - ); - } + && ver != "2.0" + { + return error( + request.id.unwrap_or(Value::Null), + ERR_INVALID_REQUEST, + "jsonrpc version must be \"2.0\"", + ); + } let id = request.id.unwrap_or(Value::Null); match request.method.as_str() { @@ -92,7 +93,11 @@ async fn handle_line(line: &str, client: &AsyncClient, registry: &SharedRegistry "status" => handle_status(id, registry).await, "list_topics" => handle_list_topics(id), "ping" => ok(id, json!({"ok": true})), - other => error(id, ERR_METHOD_NOT_FOUND, &format!("Unknown method: {other}")), + other => error( + id, + ERR_METHOD_NOT_FOUND, + &format!("Unknown method: {other}"), + ), } } @@ -119,14 +124,17 @@ async fn handle_publish( }; let values = match (p.value, p.values) { - (Some(v), None) => vec![v], - (None, Some(vs)) if !vs.is_empty() => vs, (Some(_), Some(_)) => { - return error(id, ERR_INVALID_PARAMS, "specify `value` or `values`, not both"); - } - (None, None | Some(_)) => { - return error(id, ERR_INVALID_PARAMS, "missing `value` or `values`"); + return error( + id, + ERR_INVALID_PARAMS, + "specify `value` or `values`, not both", + ); } + (Some(v), None) => vec![v], + (None, Some(vs)) if !vs.is_empty() => vs, + // None/None or None/Some(empty) + _ => return error(id, ERR_INVALID_PARAMS, "missing `value` or `values`"), }; if registry.read().await.owner(&p.topic) == Owner::Silenced { diff --git a/calypso-sim/src/proto/serverdata.proto b/calypso-sim/src/proto/serverdata.proto new file mode 100644 index 0000000..e583c38 --- /dev/null +++ b/calypso-sim/src/proto/serverdata.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package serverdata.v2; + +message ServerData { + // ensure old type is reserved + reserved 1; + reserved "value"; + string unit = 2; + // time since unix epoch in MICROSECONDS + uint64 time_us = 3; + repeated float values = 4; +} diff --git a/src/bin/calypso-sim/publish.rs b/calypso-sim/src/publish.rs similarity index 96% rename from src/bin/calypso-sim/publish.rs rename to calypso-sim/src/publish.rs index 48344d9..ac10436 100644 --- a/src/bin/calypso-sim/publish.rs +++ b/calypso-sim/src/publish.rs @@ -1,6 +1,6 @@ use std::time::UNIX_EPOCH; -use calypso::proto::serverdata; +use crate::proto::serverdata; use protobuf::Message; use rumqttc::v5::AsyncClient; use rumqttc::v5::mqttbytes::QoS; diff --git a/src/bin/calypso-sim/raw_mode.rs b/calypso-sim/src/raw_mode.rs similarity index 100% rename from src/bin/calypso-sim/raw_mode.rs rename to calypso-sim/src/raw_mode.rs diff --git a/src/bin/calypso-sim/registry.rs b/calypso-sim/src/registry.rs similarity index 100% rename from src/bin/calypso-sim/registry.rs rename to calypso-sim/src/registry.rs diff --git a/calypso-sim/src/simulatable_message.rs b/calypso-sim/src/simulatable_message.rs new file mode 100644 index 0000000..b619c75 --- /dev/null +++ b/calypso-sim/src/simulatable_message.rs @@ -0,0 +1,274 @@ +#![allow(dead_code)] +// Vendored from main calypso (src/simulatable_message.rs). Some struct +// fields are read only by the codegen-expanded `create_simulated_components` +// initializer and not by sim code paths. + +use super::data::DecodeData; +use rand::prelude::*; +use regex::Regex; +use std::time::Instant; + +/********************* SIMULATE_MESSAGE.H *********************/ + +/** + * A `SimComponent` roughly corresponds to a `NetField` with properties inherited from `CANMsg` + */ +#[derive(Debug, Clone)] +pub struct SimComponent { + pub id: String, + pub points: Vec, + pub points_intopic: Option>, + pub unit: String, + pub name: String, + pub last_update: Instant, + pub desc: String, + pub sim_freq: f32, +} + +/** + * Corresponds to `CANPoint` of a `NetField` + */ +#[derive(Debug, Clone)] +pub struct SimPoint { + pub size: usize, + pub parse: Option, + pub signed: Option, + pub endianness: Option, + pub default: Option, + pub ieee754_f32: Option, + pub value: SimValue, +} + +/** + * The mode of simulation and the real-time value of the `CANPoint` + */ +#[derive(Debug, Clone)] +pub enum SimValue { + /// Ranged mode where the value is within a min/max range and can include increment parameters. + Range { + min: f32, + max: f32, + inc_min: f32, + inc_max: f32, + round: bool, + current: f32, // current value in range mode + }, + /// Options mode where the value is selected from a set of predefined options. + Discrete { + options: Vec<(f32, f32)>, // List of option pairs. + current: f32, // currently selected option + }, +} + +/********************* SIMULATE_MESSAGE.C *********************/ + +impl SimComponent { + pub fn initialize(&mut self) { + self.points.iter_mut().for_each(SimPoint::initialize); + if let Some(points_intopic) = &mut self.points_intopic { + points_intopic.iter_mut().for_each(SimPoint::initialize); + } + } + + #[must_use] + pub fn should_update(&self) -> bool { + self.last_update.elapsed().as_millis() > self.sim_freq as u128 + } + + #[must_use] + pub fn get_decode_data(&self) -> DecodeData { + let topic_name = topic_values_inject(self); + DecodeData::new( + self.points.iter().map(SimPoint::get_value).collect(), + &topic_name, + &self.unit, + None, + ) + } + + pub fn update(&mut self) { + self.last_update = Instant::now(); + self.points.iter_mut().for_each(SimPoint::update); + if let Some(points_intopic) = &mut self.points_intopic { + points_intopic.iter_mut().for_each(SimPoint::update); + } + } +} + +impl SimPoint { + fn initialize(&mut self) { + match self.default { + Some(default_val) => match &mut self.value { + SimValue::Range { current, .. } | SimValue::Discrete { current, .. } => { + *current = default_val; + } + }, + None => self.value.initialize(), + } + } + + #[must_use] + pub fn get_value(&self) -> f32 { + self.value.get_value() + } + + fn update(&mut self) { + self.value.update(); + } +} + +impl SimValue { + pub fn initialize(&mut self) { + let mut rng = rand::rng(); + match self { + SimValue::Range { + min, + max, + inc_min, + round, + current, + .. + } => { + *current = rng.random_range(*min..*max); + if *inc_min != 0.0 { + *current = (*current / *inc_min).round() * *inc_min; // Round to nearest inc_min + } + if *round { + *current = current.round(); // Round to nearest whole number + } + } + SimValue::Discrete { options, current } => { + let idx = rng.random_range(0..options.len()); + *current = options[idx].0; + } + } + } + + #[must_use] + pub fn get_value(&self) -> f32 { + match self { + SimValue::Range { current, .. } | SimValue::Discrete { current, .. } => *current, + } + } + + /** + * Get a random offset within the range of `sim_inc_min` and `sim_inc_max` with a random sign. + * Use `sim_inc_min` as the offset if `sim_inc_min` == `sim_inc_max`. + * Rounds the offset to the nearest `sim_inc_min` if `sim_inc_min` is not 0. + */ + fn get_rand_offset(inc_min: f32, inc_max: f32) -> f32 { + let mut rng = rand::rng(); + let sign = if rng.random_bool(0.5) { 1.0 } else { -1.0 }; + + let offset: f32 = if (inc_min - inc_max).abs() < 0.0001 { + inc_min + } else { + let rand_offset = rng.random_range(inc_min..inc_max); + if inc_min == 0.0 { + rand_offset + } else { + (rand_offset / inc_min).round() * inc_min + } + }; + offset * sign + } + + fn update(&mut self) { + match self { + SimValue::Range { + min, + max, + inc_min, + inc_max, + round, + current, + } => { + const MAX_ATTEMPTS: u8 = 10; + + let cur = *current; + let min_val = *min; + let max_val = *max; + let inc_min_val = *inc_min; + let inc_max_val = *inc_max; + + // First, call get_rand_offset without partially borrowing each field + // let mut new_value = cur + SimValue::get_rand_offset(inc_min_val, inc_max_val); + let mut new_value = cur + SimValue::get_rand_offset(inc_min_val, inc_max_val); + + let mut attempts = 0; + while (new_value < min_val || new_value > max_val) && attempts < MAX_ATTEMPTS { + new_value = cur + SimValue::get_rand_offset(inc_min_val, inc_max_val); + attempts += 1; + } + + if attempts >= MAX_ATTEMPTS { + return; + } + + if inc_min_val != 0.0 { + new_value = (new_value / inc_min_val).round() * inc_min_val; + } + + if *round { + new_value = new_value.round(); + } + + *current = new_value; + } + SimValue::Discrete { options, current } => { + let mut rng = rand::rng(); + let prob = rng.random_range(0f32..1f32); + let mut new_value = None; + + for i in 0..options.len() { + let prob_floor = if i == 0 { 0f32 } else { options[i - 1].1 }; + let prob_ceiling = options[i].1; + if prob >= prob_floor && prob <= prob_ceiling { + new_value = Some(options[i].0); + break; + } + } + + *current = new_value.unwrap_or(-1f32); + } + } + } +} + +/** + * This helper function takes a `SimComponent`, injects the associated `CANPoint` values into the topic string + * e.g. "Hello/{}/World/{}" -> "Hello/{4}/World{5}" + * + * # Panics + * Panics if Regex compilation fails. + * + */ +#[must_use] +pub fn topic_values_inject(component: &SimComponent) -> String { + if let Some(points_intopic) = &component.points_intopic { + let component_name = &component.name; + // check: placeholder count lines up with in point vector array length + let re = Regex::new(r"\{\}").unwrap(); + if points_intopic.len() != re.find_iter(component_name).count() { + eprintln!( + "[error] in-topic points vector length does not line up with placeholder count" + ); + return component_name.clone(); + } + let in_topic_values: Vec = points_intopic + .iter() + .map(|p| p.get_value() as u32) + .collect(); + + // Replace {} placeholders with values + let mut value_iter = in_topic_values.iter(); + re.replace_all(component_name, |_: ®ex::Captures| { + value_iter + .next() + .map_or("{}".to_string(), std::string::ToString::to_string) + }) + .into_owned() + } else { + component.name.clone() + } +} diff --git a/calypso-sim/src/simulate_data.rs b/calypso-sim/src/simulate_data.rs new file mode 100644 index 0000000..79114a2 --- /dev/null +++ b/calypso-sim/src/simulate_data.rs @@ -0,0 +1,3 @@ +#![allow(clippy::all, clippy::pedantic)] +use daedalus::gen_simulate_data; +gen_simulate_data!(); diff --git a/src/bin/calypso-sim/warnings.rs b/calypso-sim/src/warnings.rs similarity index 89% rename from src/bin/calypso-sim/warnings.rs rename to calypso-sim/src/warnings.rs index 86a2524..d5f1e58 100644 --- a/src/bin/calypso-sim/warnings.rs +++ b/calypso-sim/src/warnings.rs @@ -33,11 +33,12 @@ fn collect_unsimulated_topics() -> Vec { }; for msg in msgs { if let OdysseyMsg::Can(canmsg) = msg - && canmsg.sim_freq.is_none() { - for field in canmsg.fields { - out.push(field.name); - } + && canmsg.sim_freq.is_none() + { + for field in canmsg.fields { + out.push(field.name); } + } } } out.sort(); From fdab310638354e71175a48a437562f2659d97a33 Mon Sep 17 00:00:00 2001 From: wyattb Date: Mon, 27 Apr 2026 09:29:48 -0400 Subject: [PATCH 03/29] #305 - added per-repo Claude Code slash commands and skills --- .claude/commands/commit.md | 25 +++++ .claude/commands/open-pr.md | 52 +++++++++ .claude/commands/update-pr.md | 72 ++++++++++++ .claude/skills/create-ticket/SKILL.md | 62 +++++++++++ .claude/skills/review/SKILL.md | 151 ++++++++++++++++++++++++++ .claude/skills/start-ticket/SKILL.md | 47 ++++++++ 6 files changed, 409 insertions(+) create mode 100644 .claude/commands/commit.md create mode 100644 .claude/commands/open-pr.md create mode 100644 .claude/commands/update-pr.md create mode 100644 .claude/skills/create-ticket/SKILL.md create mode 100644 .claude/skills/review/SKILL.md create mode 100644 .claude/skills/start-ticket/SKILL.md diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 0000000..d69e1d0 --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,25 @@ +--- +allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*), Bash(git diff:*) +description: Stage and commit using this repo's commit message convention +--- + +# Context + +Current branch: +!`git branch --show-current` + +Git status: +!`git status` + +Staged + unstaged diff: +!`git diff HEAD` + +Recent commits: +!`git log --oneline -5` + +# Task + +1. Extract the ticket number from the current branch name using pattern: leading digits before the first `-` (e.g. `289-sim-custom-topic-injection-mode` -> `#289`) +2. Stage all relevant changes (prefer naming specific files over `git add -A`) +3. Write a commit message in the format: `#NUMBER - description` — example: `#225 - added simulation black/whitelist based on topic name, using regex` +4. Commit in a single operation using a HEREDOC for the message diff --git a/.claude/commands/open-pr.md b/.claude/commands/open-pr.md new file mode 100644 index 0000000..7bd9053 --- /dev/null +++ b/.claude/commands/open-pr.md @@ -0,0 +1,52 @@ +--- +allowed-tools: Bash(git:*), Bash(gh pr create:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo build:*) +description: Run pre-PR checks, push the branch, and open a pull request +--- + +## Your task + +Run each step in order. Stop and report if any step fails. + +### 1. Verify commit format + +!`git log --oneline Develop..HEAD` + +Confirm all commits match `#TICKET - description` (ticket number extracted from branch prefix digits before first `-`). Flag any that don't. + +### 2. Lint and test +```bash +cargo fmt --all --check +cargo clippy --verbose --all -- -D warnings +cargo test --verbose +``` + +### 3. Conflict check +```bash +git fetch origin Develop +git merge --no-commit --no-ff origin/Develop +git merge --abort +``` +If conflicts exist, stop and ask the user to resolve. + +### 4. Write PR body + +Use `.github/pull_request_template.md` as the base. Read the full diff with `git diff Develop..HEAD`. + +**PR style for this repo:** +- **Changes**: 1-2 concise sentences. What was added/changed and why, not a line-by-line summary. +- **Notes**: Brief, informal context — design decisions, tradeoffs, things the reviewer should know. Remove this section if nothing to say. +- **Test Cases**: Practical CLI examples showing how to verify (e.g. `simulate --topic "BMS/Pack/Voltage=30000"`). Include expected behavior for each. +- **To Do**: Remove this section unless there are actual remaining items. +- **Checklist**: Check off all applicable items. Always check "Remove any non-applicable sections." +- **Tone**: Casual and direct. No formal language or over-explanation. +- End with `Closes #TICKET` (ticket number from branch prefix). + +Write the filled-in body to `/tmp/pr-body.md`. + +### 5. Push and open PR +```bash +git push -u origin $(git branch --show-current) +gh pr create --draft --base Develop --title "#TICKET - brief title" --body-file /tmp/pr-body.md +``` + +Use the ticket number extracted from the branch name. Keep the title under 70 characters. Report the PR URL when done. diff --git a/.claude/commands/update-pr.md b/.claude/commands/update-pr.md new file mode 100644 index 0000000..baf9c79 --- /dev/null +++ b/.claude/commands/update-pr.md @@ -0,0 +1,72 @@ +--- +allowed-tools: Bash(git:*), Bash(gh pr view:*), Bash(gh pr edit:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo build:*) +description: Update the current branch's PR title, body, and checks after new commits +--- + +## Context + +Current branch: +!`git branch --show-current` + +Recent commits since Develop: +!`git log --oneline Develop..HEAD` + +## Task + +Run each step in order. Stop and report if any step fails. + +### 1. Get the current PR + +```bash +gh pr view --json number,title,body,commits,reviews +``` + +If no PR exists for this branch, stop and tell the user to run `/open-pr` first. + +### 2. Run lint and tests + +```bash +cargo fmt --all --check +cargo clippy --verbose --all -- -D warnings +cargo test --verbose +``` + +If any check fails, stop and report the failure. Do not push broken code. + +### 3. Push latest changes + +```bash +git push +``` + +### 4. Update PR title and body + +Extract the ticket number from the branch name using the leading digits before the first `-` (e.g. `289-sim-custom-topic-injection-mode` -> `#289`). + +Read the full diff with `git diff Develop..HEAD` and the full commit log with `git log Develop..HEAD --oneline`. + +Use `.github/pull_request_template.md` as the base for the updated body. Follow the same style rules as `/open-pr`: + +- **Changes**: 1-2 concise sentences. What was added/changed and why, not a line-by-line summary. Reflect ALL commits on the branch, not just the latest. +- **Notes**: Brief, informal context — design decisions, tradeoffs, things the reviewer should know. Remove this section if nothing to say. +- **Test Cases**: Practical CLI examples showing how to verify. Include expected behavior for each. +- **To Do**: Remove this section unless there are actual remaining items. +- **Checklist**: Check off all applicable items. Always check "Remove any non-applicable sections." +- **Tone**: Casual and direct. No formal language or over-explanation. +- End with `Closes #TICKET` (ticket number from branch prefix). + +Write the filled-in body to `/tmp/pr-body.md`. + +Update the PR: +```bash +gh pr edit --title "#TICKET - brief title" --body-file /tmp/pr-body.md +``` + +Keep the title under 70 characters. The title should summarize the full scope of the branch, not just the latest commit. + +### 5. Report + +Output the updated PR URL: +```bash +gh pr view --json url --jq '.url' +``` diff --git a/.claude/skills/create-ticket/SKILL.md b/.claude/skills/create-ticket/SKILL.md new file mode 100644 index 0000000..dd76772 --- /dev/null +++ b/.claude/skills/create-ticket/SKILL.md @@ -0,0 +1,62 @@ +--- +name: create-ticket +description: Create a new GitHub Issue from a description +user-invocable: true +allowed-tools: Bash, Read, Grep, Glob +--- + +# Create Ticket + +Create a new GitHub Issue with proper formatting and labels. + +## Steps + +### 1. Get the description + +If the user provided a description as an argument, use that. Otherwise, ask what the ticket should cover (this is the ONLY time to ask). + +### 2. Classify and pick a template + +Based on the description, classify the work as one of the repo's issue types: +- **bug** — something broken +- **feature-request** — new capability +- **task** — general work item +- **epic** — large multi-issue effort +- **spike** — research / investigation +- **other** — anything else + +### 3. Generate the issue + +- **Title**: Use the pattern `[Area] - Short Description` (e.g. `[SIM] - Add custom topic injection mode`) +- **Body**: Include a clear description, acceptance criteria, and any relevant context. Follow the structure from the matching `.github/ISSUE_TEMPLATE/` template. +- **Labels**: Apply the most relevant label(s) from the repo's actual label list (run `gh label list` if unsure). Common ones: `bug`, `good first issue`, `epic`, `dependencies`, `submodules`, `rust`, `github_actions`. Don't invent labels — pick from what exists. + +### 3a. Write like a teammate, not like an AI + +Use plain English. Short sentences. The reader is another engineer skimming a backlog, not a marketing audience. + +**Avoid these tells:** +- "compose" / "compose freely" / "can't compose" → "run together", "run at the same time", "work alongside each other" +- "control plane" / "primitive" / "unlock" / "first-class" / "surface area" / "blast radius" → describe the thing in plain words +- "agent-driven way to drive X" / "X-driven Y" stacked → pick one phrasing +- "yields cleanly" / "honors the registry" / "mediates ownership" → "skips", "checks", "tracks" +- "elegant" / "clean" / "robust" / "powerful" / "seamless" → describe what makes it good instead, or drop the adjective +- Empty connective tissue: "the key insight is", "the unlock here is", "fundamentally" +- Stacking tables, nested headings, and bullet sub-lists when a short paragraph would do + +**Rewrite test:** if a sentence has a phrase you wouldn't say out loud to a coworker, rewrite it. + +**Length:** short sections. Three short paragraphs beat one long one. Acceptance criteria as a checklist beats prose. + +### 4. Create the issue + +```bash +gh issue create --title "" --label "<label>" --body "$(cat <<'EOF' +<body> +EOF +)" +``` + +### 5. Done + +Output the issue number and URL. diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md new file mode 100644 index 0000000..0ba9a3a --- /dev/null +++ b/.claude/skills/review/SKILL.md @@ -0,0 +1,151 @@ +--- +name: review +description: "Hierarchical code review: triage, review, score, fix, simplify. Run /review to review branch changes, /review improve to learn from this session." +argument-hint: "[improve]" +allowed-tools: Read, Write, Edit, Glob, Grep, Bash(git *), Bash(gh *), Agent +--- + +# Code Review + +Hierarchical review pipeline modeled after Anthropic's code-review plugin. Cheap triage, focused review, independent scoring, then fix and simplify. + +## Argument Handling + +If `` is "improve", skip to **Step 7: Improve** below. + +--- + +## Step 1: Eligibility Check + +Use a **Haiku agent** (`model: "haiku"`). Determine if there is anything to review: + +- Get the base branch from CLAUDE.md conventions (default: `develop` if found, otherwise `main`) +- Run `git diff --name-only $(git merge-base HEAD <base-branch>)...HEAD` +- If no changed files, tell the user and stop + +## Step 2: Gather Context + +Use a **Haiku agent** (`model: "haiku"`). Return: + +- List of all changed file paths, categorized (source, test, config, docs, types) +- Paths to relevant CLAUDE.md files (root + any in directories containing changed files) +- Contents of `.claude/review-rules.md` if it exists + +## Step 3: Summarize the Change + +Use a **Haiku agent** (`model: "haiku"`): + +- Run `git diff $(git merge-base HEAD <base-branch>)...HEAD` +- Return a brief summary of what this change does and why (intent, not implementation) + +Steps 1-3 can run in parallel. + +--- + +## Step 4: Review + +Read every changed file in full (not just the diff) for surrounding context. + +Launch **parallel agents** to independently review the change. Each agent returns a list of issues with the reason each was flagged (CLAUDE.md adherence, bug, historical context, etc.). + +### Agent dispatch: + +| # | Agent | Condition | What it does | +|---|---|---|---| +| A | `code-reviewer` (PR Review Toolkit) | Always | CLAUDE.md compliance, bug detection, general code quality. Scores 0-100, reports 80+. | +| B | Shallow bug scan | Always | Read just the diff. Scan for obvious bugs -- logic errors, null safety, async issues, wrong operators. Focus on big bugs, skip nitpicks. Ignore likely false positives. | +| C | Git history context | Always | Read `git blame` and history of modified code. Identify bugs in light of historical context. | +| D | `silent-failure-hunter` (PR Review Toolkit) | Error handling code present (catch blocks, .catch(), Result types, fallback logic) | Audit error handlers for silent failures, logging quality, catch specificity. | +| E | `comment-analyzer` (PR Review Toolkit) | Comments or docs added/modified | Verify comment accuracy against code, flag misleading docs. | + +Agents B and C are inline (not toolkit) -- use `model: "sonnet"` for these. + +### False positive examples (pass to agents B and C verbatim): + +Do NOT flag: +- Pre-existing issues (not introduced by this change) +- Pedantic nitpicks a senior engineer wouldn't call out +- Functionality changes clearly intentional given the broader change +- Real issues on lines the user did not modify + +--- + +## Step 5: Confidence Scoring + +For **each issue** found in Step 4, launch a parallel **Haiku agent** (`model: "haiku"`) that takes the issue, the PR diff, and the CLAUDE.md files, and returns a confidence score. + +For issues flagged due to CLAUDE.md, the agent must double-check that the CLAUDE.md actually calls out that issue specifically. + +### Scoring rubric (pass to each agent verbatim): + +- **0**: Not confident at all. False positive that doesn't stand up to light scrutiny, or is a pre-existing issue. +- **25**: Somewhat confident. Might be real, might be false positive. Agent wasn't able to verify. If stylistic, not explicitly called out in CLAUDE.md. +- **50**: Moderately confident. Verified real issue, but may be a nitpick or rare in practice. Not very important relative to the rest of the PR. +- **75**: Highly confident. Double-checked and verified it is very likely real and will be hit in practice. The existing approach in the PR is insufficient. Directly impacts functionality, or directly mentioned in CLAUDE.md. +- **100**: Absolutely certain. Double-checked and confirmed definitely real, will happen frequently. Evidence directly confirms this. + +### After scoring: + +**Filter out all issues with confidence < 80.** If nothing survives, report no high-confidence issues found and skip to Step 6b (Simplify). + +--- + +## Step 6: Fix & Simplify + +### 6a. Auto-Fix + +Present surviving issues grouped by severity: + +- **Critical** (90-100): Will cause incorrect behavior, data loss, or crash +- **Important** (80-89): Significant issue requiring attention + +For each issue: +- Description and confidence score +- File path and line number +- Why it's an issue (cite specific CLAUDE.md rule or provide bug evidence) + +**Fix all Critical and Important issues directly.** Briefly note what was changed for each. + +If any issues scored 50-79, list under **Risks** -- state without fixing. + +### 6b. Simplify + +After fixes are applied, dispatch the `code-simplifier` agent (PR Review Toolkit) with the current state of all changed files. It simplifies for clarity, consistency, and maintainability while preserving functionality. Apply its suggestions directly. + +### 6c. Summary + +- **Strengths**: What the change does well +- **Fixed**: List of issues with scores and what was changed +- **Risks**: Issues below threshold worth knowing about +- **Verdict**: Is this ready to merge? + +--- + +## Step 7: Improve + +**This runs when the user invokes `/review improve`.** + +This phase makes the skill itself better by editing this SKILL.md file directly. Read the full SKILL.md first, then review the conversation history since the last `/review` was run. + +### Analyze the session: + +1. **False positives**: What did `/review` flag that the user ignored, dismissed, or reverted? Should the false-positive list or scoring rubric be adjusted? +2. **Missed issues**: What did the user fix on their own that `/review` didn't catch? Should an agent condition be added or the scoring rubric adjusted? +3. **Overreach**: Did `/review` rate something too high? Should thresholds change? +4. **Good calls**: What findings did the user agree with and keep? + +### Apply changes to this SKILL.md: + +- **Edit** agent dispatch conditions, scoring rubric, or false-positive guidance that produced bad results +- **Remove** rules that are consistently wrong and can't be salvaged +- **Add** new false-positive examples or dispatch conditions when something was missed +- **Adjust** scoring thresholds if findings were consistently over- or under-rated + +### Constraints: + +- Do not change the overall structure (steps, agent dispatch pattern, scoring pipeline) +- Do not remove the Step 7: Improve section itself +- Do not change the frontmatter +- Keep edits surgical -- every change must trace to a specific session outcome + +After editing, summarize what was changed and why. diff --git a/.claude/skills/start-ticket/SKILL.md b/.claude/skills/start-ticket/SKILL.md new file mode 100644 index 0000000..9a43aca --- /dev/null +++ b/.claude/skills/start-ticket/SKILL.md @@ -0,0 +1,47 @@ +--- +name: start-ticket +description: Read a ticket, create a branch, and implement the work end-to-end +user-invocable: true +allowed-tools: Bash, Read, Edit, Write, Grep, Glob +--- + +# Start Ticket + +Pick up a GitHub Issue, create a branch, and implement the work fully. + +## Steps + +### 1. Select the ticket + +If a ticket number was provided as an argument, use that. Otherwise, list recent open issues and ask the user which one to work on (this is the ONLY time to ask): + +```bash +gh issue list --limit 15 --state open +``` + +### 2. Read the ticket and self-assign + +```bash +gh issue view <NUMBER> +gh issue edit <NUMBER> --add-assignee @me +``` + +Carefully read the full issue body, acceptance criteria, and any linked discussions. + +### 3. Create the branch + +Derive the branch name using the pattern `{number}-{kebab-case-title}` (e.g., ticket 533 titled "Fix login redirect" becomes `533-fix-login-redirect`). + +Create the branch from the latest `Develop`: + +```bash +git checkout Develop && git pull origin Develop && git checkout -b <branch> +``` + +### 4. Implement the ticket + +Use `/lap` repeatedly — each lap finds the highest-leverage improvement, makes it, verifies it, and commits. Keep running laps until every requirement in the ticket is fully met. + +### 5. Done + +Output a one-line summary of what was built. From 113bec87256be8bca77cd99bdfc5d1b1b68c2840 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Sat, 27 Jun 2026 12:41:39 -0700 Subject: [PATCH 04/29] #305 - add calypso-sim VCU mimic stream-mode driver script Adds calypso-sim/scripts/: vcu_mimic.py (drives the sim's stream mode to emulate the VCU), the generated serverdata_pb2.py, and a README. --- calypso-sim/scripts/README.md | 66 +++ calypso-sim/scripts/serverdata_pb2.py | 25 + calypso-sim/scripts/vcu_mimic.py | 644 ++++++++++++++++++++++++++ 3 files changed, 735 insertions(+) create mode 100644 calypso-sim/scripts/README.md create mode 100644 calypso-sim/scripts/serverdata_pb2.py create mode 100644 calypso-sim/scripts/vcu_mimic.py diff --git a/calypso-sim/scripts/README.md b/calypso-sim/scripts/README.md new file mode 100644 index 0000000..3e6b38a --- /dev/null +++ b/calypso-sim/scripts/README.md @@ -0,0 +1,66 @@ +# calypso-sim test scripts + +Python harness that drives `calypso-sim --stream` to exercise the JSON-RPC protocol +under realistic load — mimicking what the Cerberus-2.0 VCU firmware would publish +onto the topics NERO consumes. + +## Setup + +```bash +brew install mosquitto # MQTT broker +python3 -m pip install --user paho-mqtt # MQTT client +cd .. && cargo build --release # build calypso-sim +``` + +Optional visual aid: + +```bash +brew install mqttui # live topic viewer +``` + +The Python protobuf bindings (`serverdata_pb2.py`) are checked in; regenerate if +`src/proto/serverdata.proto` changes: + +```bash +protoc --python_out=scripts -Isrc/proto src/proto/serverdata.proto +``` + +## Run + +```bash +mosquitto # terminal 1 +python3 scripts/vcu_mimic.py # terminal 2 (run from calypso-sim/) +``` + +Useful flags: + +| Flag | Effect | +|---|---| +| `--with-auto` | Run autonomous heartbeat alongside stream — exercises ownership isolation (S4b). | +| `--only S1,S3` | Run only the named scenarios (default: all). | +| `--broker host:port` | Override broker address (default `localhost:1883`). | +| `--debug` | Verbose logging including raw stderr from calypso-sim. | + +## Scenarios + +| ID | What it covers | +|---|---| +| `S1` | Boot — claim every `VCU/*` topic, publish READY/OFF/home, verify on broker. | +| `S2` | Menu navigation — `nero_index` 0→7→0 (wrap at `MAX_NERO_STATES`). | +| `S3` | Enter PIT — `menu_select` rejected without brake+tsms, accepted once both set. | +| `S4` | Drive telemetry sweep — sine accelerator, speed capped at `PIT_MAX_SPEED=5 mph`. | +| `S4b` | (with `--with-auto`) Verify autonomous does not republish claimed topics. | +| `S5` | Enter REVERSE — only allowed from F_PIT, requires brake+tsms+menu cycle. | +| `S6` | Fault — trigger `BSPD_PREFAULT`, verify FAULTED+OFF+home, then recover. | + +Each scenario prints `Sx ✓ ...` on success or raises `AssertionError` on failure. + +## Adding a scenario + +1. Add a function `def s_my_thing(vcu, obs): ...` in `vcu_mimic.py`. +2. Use `vcu.menu_increment()`, `vcu.set_pedals(...)`, etc. to drive state. +3. Use `obs.assert_published(topic, expected_value, within_ms=500)` to verify. +4. Register it in the `SCENARIOS` dict at the bottom. + +The `VcuMock` mirrors the state machine in Cerberus-2.0's `Core/Src/u_statemachine.c` — +when adding behavior, cross-reference the C source to keep semantics aligned. diff --git a/calypso-sim/scripts/serverdata_pb2.py b/calypso-sim/scripts/serverdata_pb2.py new file mode 100644 index 0000000..291e255 --- /dev/null +++ b/calypso-sim/scripts/serverdata_pb2.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: serverdata.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10serverdata.proto\x12\rserverdata.v2\"H\n\nServerData\x12\x0c\n\x04unit\x18\x02 \x01(\t\x12\x0f\n\x07time_us\x18\x03 \x01(\x04\x12\x0e\n\x06values\x18\x04 \x03(\x02J\x04\x08\x01\x10\x02R\x05valueb\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'serverdata_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _SERVERDATA._serialized_start=35 + _SERVERDATA._serialized_end=107 +# @@protoc_insertion_point(module_scope) diff --git a/calypso-sim/scripts/vcu_mimic.py b/calypso-sim/scripts/vcu_mimic.py new file mode 100644 index 0000000..d1af571 --- /dev/null +++ b/calypso-sim/scripts/vcu_mimic.py @@ -0,0 +1,644 @@ +"""VCU mimic harness for calypso-sim --stream. + +Spawns calypso-sim, drives JSON-RPC over its stdio, and verifies via +an independent paho-mqtt subscriber that publishes land on the broker. +Replays six scenarios mirroring Cerberus-2.0's `Core/Src/u_statemachine.c`. + +Run from the calypso-sim directory: + + python3 scripts/vcu_mimic.py + python3 scripts/vcu_mimic.py --with-auto # autonomous heartbeat alongside + python3 scripts/vcu_mimic.py --only S1,S2 # filter scenarios + python3 scripts/vcu_mimic.py --broker localhost:1883 +""" + +from __future__ import annotations + +import argparse +import contextlib +import json +import logging +import math +import os +import queue +import subprocess +import sys +import threading +import time +from dataclasses import dataclass, field +from enum import IntEnum +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +SIM_DIR = SCRIPT_DIR.parent +sys.path.insert(0, str(SCRIPT_DIR)) + +import paho.mqtt.client as mqtt # noqa: E402 +import serverdata_pb2 # noqa: E402 + + +log = logging.getLogger("vcu-mimic") + + +# ---------- StreamClient --------------------------------------------------- + +class StreamRpcError(RuntimeError): + def __init__(self, code: int, message: str): + super().__init__(f"[{code}] {message}") + self.code = code + self.message = message + + +class StreamClient: + """JSON-RPC 2.0 client over calypso-sim --stream's stdio.""" + + def __init__(self, broker: str, with_auto: bool = False, release_bin: Path | None = None): + bin_path = release_bin or (SIM_DIR / "target" / "release" / "calypso-sim") + if not bin_path.exists(): + raise FileNotFoundError( + f"{bin_path} not found — run `cargo build --release` in {SIM_DIR}" + ) + cmd = [str(bin_path), "-u", broker, "--stream"] + if with_auto: + cmd.append("--auto") + log.info("spawning: %s", " ".join(cmd)) + self.proc = subprocess.Popen( + cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, bufsize=1, cwd=str(SIM_DIR), + ) + self._next_id = 1 + self._id_lock = threading.Lock() + self._pending: dict[int, queue.Queue] = {} + self._pending_lock = threading.Lock() + self._closed = False + self._reader = threading.Thread(target=self._read_stdout, daemon=True) + self._reader.start() + self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True) + self._stderr_thread.start() + + def _read_stdout(self): + for line in self.proc.stdout: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + log.warning("non-JSON on stdout: %r", line) + continue + rid = msg.get("id") + if rid is None: + log.debug("notification (no id): %s", msg) + continue + with self._pending_lock: + q = self._pending.pop(rid, None) + if q is not None: + q.put(msg) + else: + log.warning("response for unknown id %s: %s", rid, msg) + + def _drain_stderr(self): + for line in self.proc.stderr: + log.debug("sim-stderr: %s", line.rstrip()) + + def _call(self, method: str, params: dict | None = None, timeout: float = 5.0): + if self._closed: + raise RuntimeError("StreamClient is closed") + with self._id_lock: + rid = self._next_id + self._next_id += 1 + q: queue.Queue = queue.Queue(maxsize=1) + with self._pending_lock: + self._pending[rid] = q + req = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}} + line = json.dumps(req) + "\n" + self.proc.stdin.write(line) + self.proc.stdin.flush() + try: + resp = q.get(timeout=timeout) + except queue.Empty: + with self._pending_lock: + self._pending.pop(rid, None) + raise TimeoutError(f"no response to {method} (id={rid}) within {timeout}s") + if "error" in resp: + err = resp["error"] + raise StreamRpcError(err.get("code", 0), err.get("message", "")) + return resp.get("result", {}) + + def publish(self, topic: str, value: float, unit: str = "") -> int: + r = self._call("publish", {"topic": topic, "value": float(value), "unit": unit}) + return int(r.get("ts_us", 0)) + + def publish_values(self, topic: str, values: list[float], unit: str = "") -> int: + r = self._call("publish", {"topic": topic, "values": [float(v) for v in values], "unit": unit}) + return int(r.get("ts_us", 0)) + + def claim(self, topic: str) -> dict: return self._call("claim", {"topic": topic}) + def release(self, topic: str) -> dict: return self._call("release", {"topic": topic}) + def silence(self, topic: str) -> dict: return self._call("silence", {"topic": topic}) + def status(self) -> list[dict]: return self._call("status").get("overrides", []) + def list_topics(self) -> list[dict]: return self._call("list_topics").get("topics", []) + def ping(self) -> bool: return bool(self._call("ping").get("ok")) + + def close(self): + if self._closed: + return + self._closed = True + with contextlib.suppress(Exception): + self.proc.stdin.close() + try: + self.proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self.proc.terminate() + with contextlib.suppress(subprocess.TimeoutExpired): + self.proc.wait(timeout=2) + log.info("stream client closed (rc=%s)", self.proc.returncode) + + +# ---------- BrokerObserver ------------------------------------------------- + +@dataclass +class LastMsg: + values: list[float] + time_us: int + recv_ts: float + + +class BrokerObserver: + def __init__(self, host: str, port: int): + self.host, self.port = host, port + self.last: dict[str, LastMsg] = {} + self._lock = threading.Lock() + self._connected = threading.Event() + self.client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id="vcu-mimic-observer") + self.client.on_connect = self._on_connect + self.client.on_message = self._on_message + + def _on_connect(self, client, userdata, flags, rc, props=None): + log.info("broker connected (rc=%s); subscribing to #", rc) + client.subscribe("#") + self._connected.set() + + def _on_message(self, client, userdata, msg): + try: + sd = serverdata_pb2.ServerData.FromString(msg.payload) + except Exception as e: + log.debug("payload not ServerData on %s: %s", msg.topic, e) + return + with self._lock: + self.last[msg.topic] = LastMsg(list(sd.values), int(sd.time_us), time.monotonic()) + + def start(self, timeout: float = 3.0): + self.client.connect(self.host, self.port, keepalive=30) + self.client.loop_start() + if not self._connected.wait(timeout): + raise TimeoutError(f"broker {self.host}:{self.port} did not connect within {timeout}s") + + def stop(self): + self.client.loop_stop() + with contextlib.suppress(Exception): + self.client.disconnect() + + def get(self, topic: str) -> LastMsg | None: + with self._lock: + return self.last.get(topic) + + def assert_published(self, topic: str, expected: float, within_ms: int = 500, tol: float = 1e-3): + deadline = time.monotonic() + within_ms / 1000.0 + last_seen = None + while time.monotonic() < deadline: + m = self.get(topic) + if m is not None and m.values and abs(m.values[0] - expected) <= tol: + return m + last_seen = m + time.sleep(0.02) + raise AssertionError( + f"topic {topic!r} did not reach {expected} within {within_ms}ms; " + f"last={last_seen.values if last_seen else None}" + ) + + def assert_owner_only_us(self, topic: str, since: float, max_age_ms: int = 1500): + """Assert that the most recent publish on `topic` happened at or before `since` + (i.e. the autonomous heartbeat hasn't republished it after we stopped).""" + time.sleep(max_age_ms / 1000.0) + m = self.get(topic) + if m is None: + return # nothing seen at all is fine for an idle topic + if m.recv_ts > since + 0.05: + raise AssertionError( + f"topic {topic!r} was republished after our claim cutoff " + f"(diff={(m.recv_ts - since) * 1000:.0f}ms)" + ) + + +# ---------- VcuMock -------------------------------------------------------- + +class FuncState(IntEnum): + READY = 0 + F_PIT = 1 + F_REVERSE = 2 + F_PERFORMANCE = 3 + F_EFFICIENCY = 4 + FAULTED = 5 + + +class NeroMenu(IntEnum): + OFF = 0 + PIT = 1 + REVERSE = 2 + PERFORMANCE = 3 + EFFICIENCY = 4 + GAMES = 5 + THEMES = 6 + EXIT = 7 + + +MAX_NERO_STATES = 8 + +# Calibration constants from Cerberus-2.0 Core/Src/u_pedals.c +MIN_APPS1_VOLTS = 2.15 +MAX_APPS1_VOLTS = 3.12 +MIN_APPS2_VOLTS = 1.15 +MAX_APPS2_VOLTS = 2.01 +MIN_BRAKE1_VOLTS = 0.5 +MAX_BRAKE1_VOLTS = 1.5 +MIN_BRAKE2_VOLTS = 0.5 +MAX_BRAKE2_VOLTS = 1.5 +PEDAL_BRAKE_THRESH = 0.12 +PIT_MAX_SPEED = 5.0 # mph + +CRITICAL_FAULTS = [ + "CAN_OUTGOING_FAULT", "CAN_INCOMING_FAULT", "BMS_CAN_MONITOR_FAULT", + "LIGHTNING_CAN_MONITOR_FAULT", "SHUTDOWN_FAULT", +] +NON_CRITICAL_FAULTS = [ + "ONBOARD_TEMP_FAULT", "IMU_ACCEL_FAULT", "IMU_GYRO_FAULT", "BSPD_PREFAULT", + "ONBOARD_BRAKE_OPEN_CIRCUIT_FAULT", "ONBOARD_ACCEL_OPEN_CIRCUIT_FAULT", + "ONBOARD_BRAKE_SHORT_CIRCUIT_FAULT", "ONBOARD_ACCEL_SHORT_CIRCUIT_FAULT", + "ONBOARD_PEDAL_DIFFERENCE_FAULT", "RTDS_FAULT", "LV_LOW_VOLTAGE_FAULT", +] + + +@dataclass +class VcuMock: + client: StreamClient + functional: FuncState = FuncState.READY + nero_index: NeroMenu = NeroMenu.OFF + home_mode: bool = True + accel_norm: float = 0.0 + brake_norm: float = 0.0 + tsms: bool = False # shutdown closed? + speed_mph: float = 0.0 + faults: dict[str, bool] = field(default_factory=dict) + + def __post_init__(self): + for f in CRITICAL_FAULTS + NON_CRITICAL_FAULTS: + self.faults.setdefault(f, False) + + @property + def all_owned_topics(self) -> list[str]: + out = [ + "VCU/CarState/home_mode", "VCU/CarState/nero_index", "VCU/CarState/speed", + "VCU/CarState/tsms", "VCU/CarState/torque_limit_percentage", + "VCU/CarState/not_in_reverse", "VCU/CarState/regen_limit", + "VCU/CarState/launch_control", "VCU/CarState/functional_state", + "VCU/CarState/traction_control", + "VCU/Pedals/Percentages/acceleration_pedal", + "VCU/Pedals/Percentages/brake_pedal", + "VCU/Pedals/Voltages/accel_1", "VCU/Pedals/Voltages/accel_2", + "VCU/Pedals/Voltages/brake_1", "VCU/Pedals/Voltages/brake_2", + ] + for f in CRITICAL_FAULTS: + out.append(f"VCU/Faults/Critical/{f}") + for f in NON_CRITICAL_FAULTS: + out.append(f"VCU/Faults/Non-Critical/{f}") + return out + + def claim_all(self): + for t in self.all_owned_topics: + self.client.claim(t) + + def release_all(self): + for t in self.all_owned_topics: + with contextlib.suppress(StreamRpcError): + self.client.release(t) + + # --- publishing ---- + def publish_carstate(self): + c = self.client + c.publish("VCU/CarState/home_mode", 1.0 if self.home_mode else 0.0) + c.publish("VCU/CarState/nero_index", float(int(self.nero_index))) + c.publish("VCU/CarState/speed", self.speed_mph, unit="mph") + c.publish("VCU/CarState/tsms", 1.0 if self.tsms else 0.0) + c.publish("VCU/CarState/torque_limit_percentage", 1.0) + c.publish("VCU/CarState/not_in_reverse", 0.0 if self.functional == FuncState.F_REVERSE else 1.0) + c.publish("VCU/CarState/regen_limit", 50.0, unit="A") + c.publish("VCU/CarState/launch_control", 0.0) + c.publish("VCU/CarState/functional_state", float(int(self.functional))) + c.publish("VCU/CarState/traction_control", 0.0) + + def publish_pedals(self): + c = self.client + a, b = self.accel_norm, self.brake_norm + c.publish("VCU/Pedals/Percentages/acceleration_pedal", a) + c.publish("VCU/Pedals/Percentages/brake_pedal", b) + c.publish("VCU/Pedals/Voltages/accel_1", + MIN_APPS1_VOLTS + a * (MAX_APPS1_VOLTS - MIN_APPS1_VOLTS), unit="V") + c.publish("VCU/Pedals/Voltages/accel_2", + MIN_APPS2_VOLTS + a * (MAX_APPS2_VOLTS - MIN_APPS2_VOLTS), unit="V") + c.publish("VCU/Pedals/Voltages/brake_1", + MIN_BRAKE1_VOLTS + b * (MAX_BRAKE1_VOLTS - MIN_BRAKE1_VOLTS), unit="V") + c.publish("VCU/Pedals/Voltages/brake_2", + MIN_BRAKE2_VOLTS + b * (MAX_BRAKE2_VOLTS - MIN_BRAKE2_VOLTS), unit="V") + + def publish_faults(self): + for f in CRITICAL_FAULTS: + self.client.publish(f"VCU/Faults/Critical/{f}", 1.0 if self.faults[f] else 0.0) + for f in NON_CRITICAL_FAULTS: + self.client.publish(f"VCU/Faults/Non-Critical/{f}", 1.0 if self.faults[f] else 0.0) + + def tick(self): + """One 4Hz heartbeat — all topics get refreshed.""" + self.publish_carstate() + self.publish_pedals() + self.publish_faults() + + # --- state machine (mirrors Core/Src/u_statemachine.c) ---- + def boot(self): + self.functional = FuncState.READY + self.nero_index = NeroMenu.OFF + self.home_mode = True + self.accel_norm = 0.0 + self.brake_norm = 0.0 + self.tsms = False + self.speed_mph = 0.0 + for f in self.faults: + self.faults[f] = False + self.tick() + + def menu_increment(self): + nxt = int(self.nero_index) + 1 + self.nero_index = NeroMenu(0 if nxt >= MAX_NERO_STATES else nxt) + self.client.publish("VCU/CarState/nero_index", float(int(self.nero_index))) + + def menu_decrement(self): + nxt = int(self.nero_index) - 1 + if nxt < 0: + nxt = MAX_NERO_STATES - 1 + self.nero_index = NeroMenu(nxt) + self.client.publish("VCU/CarState/nero_index", float(int(self.nero_index))) + + def menu_select(self) -> bool: + """Try to enter the currently-highlighted drive mode. Mirrors + transition_functional_state in u_statemachine.c — only F_PIT, F_PERFORMANCE, + and F_EFFICIENCY require brake-pressed + shutdown-closed; F_REVERSE is gateless.""" + target_map = { + NeroMenu.PIT: FuncState.F_PIT, + NeroMenu.PERFORMANCE: FuncState.F_PERFORMANCE, + NeroMenu.EFFICIENCY: FuncState.F_EFFICIENCY, + } + if self.nero_index == NeroMenu.REVERSE: + self.functional = FuncState.F_REVERSE + elif self.nero_index in target_map: + if self.brake_norm < PEDAL_BRAKE_THRESH: + log.warning("brake not pressed (%.2f < %.2f) — cannot enter drive mode", + self.brake_norm, PEDAL_BRAKE_THRESH) + return False + if not self.tsms: + log.warning("shutdown not closed (tsms=0) — cannot enter drive mode") + return False + self.functional = target_map[self.nero_index] + else: + log.info("nero_index=%s is not a drive mode; no transition", self.nero_index) + return False + self.home_mode = False + self.client.publish("VCU/CarState/home_mode", 0.0) + self.client.publish("VCU/CarState/functional_state", float(int(self.functional))) + self.client.publish("VCU/CarState/not_in_reverse", + 0.0 if self.functional == FuncState.F_REVERSE else 1.0) + return True + + def set_home(self): + if self.functional != FuncState.FAULTED: + self.functional = FuncState.READY + self.home_mode = True + self.client.publish("VCU/CarState/home_mode", 1.0) + self.client.publish("VCU/CarState/functional_state", float(int(self.functional))) + + def set_pedals(self, accel: float, brake: float): + self.accel_norm = max(0.0, min(1.0, accel)) + self.brake_norm = max(0.0, min(1.0, brake)) + self.publish_pedals() + + def set_tsms(self, closed: bool): + self.tsms = closed + self.client.publish("VCU/CarState/tsms", 1.0 if closed else 0.0) + + def set_speed(self, mph: float): + self.speed_mph = mph + self.client.publish("VCU/CarState/speed", mph, unit="mph") + + def trigger_fault(self, name: str): + if name not in self.faults: + raise ValueError(f"unknown fault: {name}") + self.faults[name] = True + # Special case mirrored from transition_functional_state: any fault → + # FAULTED + nero=OFF + home_mode=true + self.functional = FuncState.FAULTED + self.nero_index = NeroMenu.OFF + self.home_mode = True + critical = name in CRITICAL_FAULTS + topic = f"VCU/Faults/{'Critical' if critical else 'Non-Critical'}/{name}" + self.client.publish(topic, 1.0) + self.client.publish("VCU/CarState/functional_state", float(int(self.functional))) + self.client.publish("VCU/CarState/nero_index", float(int(self.nero_index))) + self.client.publish("VCU/CarState/home_mode", 1.0) + + def clear_faults(self): + for f in self.faults: + self.faults[f] = False + self.publish_faults() + self.functional = FuncState.READY + self.client.publish("VCU/CarState/functional_state", float(int(self.functional))) + + +# ---------- Scenarios ------------------------------------------------------ + +def s1_boot(vcu: VcuMock, obs: BrokerObserver): + log.info("S1: boot — claim all VCU topics, publish initial state") + vcu.claim_all() + overrides = {o["topic"]: o["owner"] for o in vcu.client.status()} + for t in vcu.all_owned_topics: + assert overrides.get(t) == "stream", f"claim missing for {t}" + vcu.boot() + obs.assert_published("VCU/CarState/home_mode", 1.0) + obs.assert_published("VCU/CarState/nero_index", 0.0) + obs.assert_published("VCU/CarState/functional_state", 0.0) + obs.assert_published("VCU/Pedals/Percentages/acceleration_pedal", 0.0) + obs.assert_published("VCU/Faults/Non-Critical/BSPD_PREFAULT", 0.0) + log.info("S1 ✓ %d/%d owned topics, boot state observed on broker", + len(vcu.all_owned_topics), len(vcu.all_owned_topics)) + + +def s2_menu_nav(vcu: VcuMock, obs: BrokerObserver): + log.info("S2: menu navigation 0→7→0") + expected = [1, 2, 3, 4, 5, 6, 7, 0] + for want in expected: + vcu.menu_increment() + obs.assert_published("VCU/CarState/nero_index", float(want)) + time.sleep(0.2) + log.info("S2 ✓ wrap at MAX_NERO_STATES verified") + + +def s3_enter_pit(vcu: VcuMock, obs: BrokerObserver): + log.info("S3: enter PIT — gate on brake + tsms") + # Try to select PIT without brake — should be rejected by VcuMock + vcu.menu_increment() # 0 → 1 (PIT) + obs.assert_published("VCU/CarState/nero_index", 1.0) + assert not vcu.menu_select(), "menu_select should fail without brake" + assert vcu.functional == FuncState.READY, "functional should not transition" + # Press brake, close tsms, retry + vcu.set_pedals(0.0, 0.30) + obs.assert_published("VCU/Pedals/Percentages/brake_pedal", 0.30) + vcu.set_tsms(True) + obs.assert_published("VCU/CarState/tsms", 1.0) + assert vcu.menu_select(), "menu_select should succeed once gated conditions met" + obs.assert_published("VCU/CarState/home_mode", 0.0) + obs.assert_published("VCU/CarState/functional_state", float(int(FuncState.F_PIT))) + log.info("S3 ✓ brake+tsms gate enforced, F_PIT entered") + + +def s4_drive_sweep(vcu: VcuMock, obs: BrokerObserver, duration_s: float = 8.0): + log.info("S4: drive telemetry sweep (%.0fs in F_PIT)", duration_s) + assert vcu.functional == FuncState.F_PIT, "must be in F_PIT for S4" + start = time.monotonic() + last_tick = start + samples = 0 + while time.monotonic() - start < duration_s: + t = time.monotonic() - start + accel = max(0.0, math.sin(t * math.pi / duration_s)) + vcu.set_pedals(accel, 0.0) + # PIT speed limit: scale linearly with accel, capped at PIT_MAX_SPEED + vcu.set_speed(min(PIT_MAX_SPEED, accel * PIT_MAX_SPEED * 1.2)) + # heartbeat the rest of the carstate every 250ms + if time.monotonic() - last_tick > 0.25: + vcu.publish_carstate() + vcu.publish_faults() + last_tick = time.monotonic() + samples += 1 + time.sleep(0.05) + # Verify the sweep peaked near 1.0 and speed tracked it + a = obs.get("VCU/Pedals/Percentages/acceleration_pedal") + s = obs.get("VCU/CarState/speed") + assert a and s, "expected last-sample data on broker" + log.info("S4 ✓ %d samples, last accel=%.2f speed=%.2f mph (PIT cap=%.1f)", + samples, a.values[0], s.values[0], PIT_MAX_SPEED) + + +def s4b_owner_isolation(vcu: VcuMock, obs: BrokerObserver, with_auto: bool): + if not with_auto: + log.info("S4b: skip (run with --with-auto to verify autonomous isolation)") + return + log.info("S4b: verify autonomous heartbeat does NOT republish claimed topics") + topic = "VCU/CarState/torque_limit_percentage" + vcu.client.publish(topic, 0.42) + obs.assert_published(topic, 0.42) + cutoff = time.monotonic() + obs.assert_owner_only_us(topic, since=cutoff, max_age_ms=1500) + log.info("S4b ✓ %s held its claimed value across 1.5s of autonomous ticks", topic) + + +def s5_enter_reverse(vcu: VcuMock, obs: BrokerObserver): + log.info("S5: enter REVERSE") + # Per Cerberus, the driver returns to home_mode (which transitions active→READY) + # and then scrolls to REVERSE in the menu and selects it. F_REVERSE has no + # brake/tsms gate in transition_functional_state. + vcu.set_home() + obs.assert_published("VCU/CarState/home_mode", 1.0) + obs.assert_published("VCU/CarState/functional_state", float(int(FuncState.READY))) + while vcu.nero_index != NeroMenu.REVERSE: + vcu.menu_increment() + time.sleep(0.05) + obs.assert_published("VCU/CarState/nero_index", float(int(NeroMenu.REVERSE))) + assert vcu.menu_select(), "REVERSE select failed" + obs.assert_published("VCU/CarState/functional_state", float(int(FuncState.F_REVERSE))) + obs.assert_published("VCU/CarState/not_in_reverse", 0.0) + log.info("S5 ✓ F_REVERSE entered, not_in_reverse=0") + + +def s6_fault_recovery(vcu: VcuMock, obs: BrokerObserver): + log.info("S6: fault & recovery (BSPD_PREFAULT)") + vcu.trigger_fault("BSPD_PREFAULT") + obs.assert_published("VCU/Faults/Non-Critical/BSPD_PREFAULT", 1.0) + obs.assert_published("VCU/CarState/functional_state", float(int(FuncState.FAULTED))) + obs.assert_published("VCU/CarState/nero_index", 0.0) + obs.assert_published("VCU/CarState/home_mode", 1.0) + vcu.clear_faults() + obs.assert_published("VCU/Faults/Non-Critical/BSPD_PREFAULT", 0.0) + obs.assert_published("VCU/CarState/functional_state", float(int(FuncState.READY))) + log.info("S6 ✓ fault → FAULTED+OFF+home, recovered to READY") + + +SCENARIOS: dict[str, callable] = { + "S1": s1_boot, + "S2": s2_menu_nav, + "S3": s3_enter_pit, + "S4": s4_drive_sweep, + "S4b": s4b_owner_isolation, + "S5": s5_enter_reverse, + "S6": s6_fault_recovery, +} + + +# ---------- main ----------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--broker", default="localhost:1883") + ap.add_argument("--with-auto", action="store_true", + help="run autonomous heartbeat alongside stream") + ap.add_argument("--only", default="", + help="comma-separated list of scenario IDs to run (default: all)") + ap.add_argument("--debug", action="store_true") + args = ap.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + host, _, port_s = args.broker.partition(":") + port = int(port_s or "1883") + selected = [s.strip() for s in args.only.split(",") if s.strip()] or list(SCENARIOS) + + obs = BrokerObserver(host, port) + obs.start() + client = StreamClient(args.broker, with_auto=args.with_auto) + try: + # quick sanity ping + assert client.ping(), "stream did not respond to ping" + log.info("stream ping ok; broker observer subscribed") + vcu = VcuMock(client) + for sid in selected: + fn = SCENARIOS.get(sid) + if not fn: + log.warning("unknown scenario: %s (have %s)", sid, list(SCENARIOS)) + continue + print() + print(f"========== {sid} =========") + if sid == "S4b": + fn(vcu, obs, args.with_auto) + else: + fn(vcu, obs) + print() + log.info("all scenarios complete") + finally: + with contextlib.suppress(Exception): + VcuMock(client).release_all() if 'vcu' not in locals() else vcu.release_all() + client.close() + obs.stop() + + +if __name__ == "__main__": + main() From 15c5aa7e378b962e6314a676db92fc28fd5a334f Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Sat, 27 Jun 2026 13:14:11 -0700 Subject: [PATCH 05/29] #305 - make manual_sim_buttons keymap self-contained with explicit units Add `"unit": ""` to the 9 Wheel/Buttons/button_id entries so they publish without relying on a `sim` block in Odyssey-Definitions. We dropped the local-only OD sim-block commit and point the submodule at Develop's pointer, where button_id is not auto-simulatable; explicit units keep these keymap entries working. button_id is the only non-simulatable topic in this keymap (home_mode/nero_index/not_in_reverse remain simulatable via Develop's OD). --- calypso-sim/manual_sim_buttons.keymap.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/calypso-sim/manual_sim_buttons.keymap.json b/calypso-sim/manual_sim_buttons.keymap.json index 7a43b38..7a07b8e 100644 --- a/calypso-sim/manual_sim_buttons.keymap.json +++ b/calypso-sim/manual_sim_buttons.keymap.json @@ -7,15 +7,15 @@ {"topic": "VCU/CarState/home_mode", "value": 0, "delay_ms": 100} ] }, - "l": {"topic": "Wheel/Buttons/button_id", "value": 1, "desc": "left"}, - "2": {"topic": "Wheel/Buttons/button_id", "value": 2, "desc": "launch control toggle"}, - "j": {"topic": "Wheel/Buttons/button_id", "value": 3, "desc": "up (regen)"}, - "k": {"topic": "Wheel/Buttons/button_id", "value": 4, "desc": "down (regen)"}, - "e": {"topic": "Wheel/Buttons/button_id", "value": 5, "desc": "enter"}, - "r": {"topic": "Wheel/Buttons/button_id", "value": 6, "desc": "right"}, - "7": {"topic": "Wheel/Buttons/button_id", "value": 7, "desc": "traction control toggle"}, - "8": {"topic": "Wheel/Buttons/button_id", "value": 8, "desc": "up (torque)"}, - "9": {"topic": "Wheel/Buttons/button_id", "value": 9, "desc": "down (torque)"}, + "l": {"topic": "Wheel/Buttons/button_id", "value": 1, "unit": "", "desc": "left"}, + "2": {"topic": "Wheel/Buttons/button_id", "value": 2, "unit": "", "desc": "launch control toggle"}, + "j": {"topic": "Wheel/Buttons/button_id", "value": 3, "unit": "", "desc": "up (regen)"}, + "k": {"topic": "Wheel/Buttons/button_id", "value": 4, "unit": "", "desc": "down (regen)"}, + "e": {"topic": "Wheel/Buttons/button_id", "value": 5, "unit": "", "desc": "enter"}, + "r": {"topic": "Wheel/Buttons/button_id", "value": 6, "unit": "", "desc": "right"}, + "7": {"topic": "Wheel/Buttons/button_id", "value": 7, "unit": "", "desc": "traction control toggle"}, + "8": {"topic": "Wheel/Buttons/button_id", "value": 8, "unit": "", "desc": "up (torque)"}, + "9": {"topic": "Wheel/Buttons/button_id", "value": 9, "unit": "", "desc": "down (torque)"}, "m": {"topic": "VCU/CarState/home_mode", "value": 0, "desc": "clear home_mode"}, "n": {"topic": "VCU/CarState/nero_index", "value": 0, "desc": "reset nero_index"}, "d": {"topic": "VCU/CarState/not_in_reverse", "value": 1, "desc": "forward drive"}, From 43efd0ab9fe21dfc60956b85ac221e5aaf113c83 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Sat, 27 Jun 2026 13:54:38 -0700 Subject: [PATCH 06/29] #305 - harden calypso-sim modes and drop stray Embedded-Base gitlink - autonomous: validate --enable/--disable-topic regexes up front so a bad pattern exits non-zero instead of silently disabling the heartbeat - script: fail-fast on malformed sleep / multi-char / unknown-key lines (deterministic replay shouldn't silently skip) - stream: async stdout so a stalled consumer can't block a runtime worker - build.rs: rerun-if-changed=Odyssey-Definitions so spec edits rebuild sim data - simulatable_message: guard SimValue::initialize against empty range/options to avoid a startup panic on a degenerate spec - drop set_topic_alias_max(600) to match the decoder and prior simulator - fix S5 README description (REVERSE is gateless) and vcu_mimic cleanup branch - remove Embedded-Base gitlink (not on Develop, no .gitmodules entry, unreferenced) --- Embedded-Base | 1 - calypso-sim/build.rs | 4 ++++ calypso-sim/scripts/README.md | 2 +- calypso-sim/scripts/vcu_mimic.py | 3 ++- calypso-sim/src/main.rs | 14 ++++++++---- calypso-sim/src/modes/auto_script.rs | 31 ++++++++++++++++---------- calypso-sim/src/modes/autonomous.rs | 15 +++---------- calypso-sim/src/modes/stream.rs | 19 ++++++++-------- calypso-sim/src/simulatable_message.rs | 17 +++++++++++--- 9 files changed, 63 insertions(+), 43 deletions(-) delete mode 160000 Embedded-Base diff --git a/Embedded-Base b/Embedded-Base deleted file mode 160000 index 1b99c1c..0000000 --- a/Embedded-Base +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1b99c1cbc44e3a52f21845b21f7a65a2b14fe27e diff --git a/calypso-sim/build.rs b/calypso-sim/build.rs index d38960a..279a412 100644 --- a/calypso-sim/build.rs +++ b/calypso-sim/build.rs @@ -1,5 +1,9 @@ fn main() { println!("cargo:rerun-if-changed=src/proto"); + // The daedalus `gen_simulate_data!` proc macro reads the CAN spec via + // `fs::read_to_string` at expansion time, which cargo does not track on its + // own. Rebuild when the spec changes so generated sim data stays fresh. + println!("cargo:rerun-if-changed=Odyssey-Definitions"); protobuf_codegen::Codegen::new() .pure() diff --git a/calypso-sim/scripts/README.md b/calypso-sim/scripts/README.md index 3e6b38a..2619de9 100644 --- a/calypso-sim/scripts/README.md +++ b/calypso-sim/scripts/README.md @@ -50,7 +50,7 @@ Useful flags: | `S3` | Enter PIT — `menu_select` rejected without brake+tsms, accepted once both set. | | `S4` | Drive telemetry sweep — sine accelerator, speed capped at `PIT_MAX_SPEED=5 mph`. | | `S4b` | (with `--with-auto`) Verify autonomous does not republish claimed topics. | -| `S5` | Enter REVERSE — only allowed from F_PIT, requires brake+tsms+menu cycle. | +| `S5` | Enter REVERSE — return home, scroll to REVERSE, select (gateless: no brake/tsms required), verify `not_in_reverse=0`. | | `S6` | Fault — trigger `BSPD_PREFAULT`, verify FAULTED+OFF+home, then recover. | Each scenario prints `Sx ✓ ...` on success or raises `AssertionError` on failure. diff --git a/calypso-sim/scripts/vcu_mimic.py b/calypso-sim/scripts/vcu_mimic.py index d1af571..ab28dcc 100644 --- a/calypso-sim/scripts/vcu_mimic.py +++ b/calypso-sim/scripts/vcu_mimic.py @@ -635,7 +635,8 @@ def main(): log.info("all scenarios complete") finally: with contextlib.suppress(Exception): - VcuMock(client).release_all() if 'vcu' not in locals() else vcu.release_all() + if 'vcu' in locals(): + vcu.release_all() client.close() obs.stop() diff --git a/calypso-sim/src/main.rs b/calypso-sim/src/main.rs index 1a02452..3e604ec 100644 --- a/calypso-sim/src/main.rs +++ b/calypso-sim/src/main.rs @@ -47,12 +47,19 @@ async fn main() { let registry = TopicRegistry::shared(); let auto_handle = if cli.run_autonomous() { + // Validate the enable/disable regex patterns up front so a bad pattern + // fails fast with a non-zero exit instead of silently disabling the + // entire autonomous heartbeat inside the spawned task. + let filter = modes::autonomous::FilterMode::build(&cli.enable_topic, &cli.disable_topic) + .unwrap_or_else(|err| { + eprintln!("Error: {err}"); + exit(1); + }); Some(tokio::spawn(modes::autonomous::run( token.clone(), client.clone(), registry.clone(), - cli.enable_topic.clone(), - cli.disable_topic.clone(), + filter, ))) } else { None @@ -152,7 +159,6 @@ fn connect_mqtt(host_url: &str) -> Result<(AsyncClient, EventLoop), String> { .set_keep_alive(Duration::from_secs(20)) .set_clean_start(true) .set_connection_timeout(3) - .set_session_expiry_interval(Some(u32::MAX)) - .set_topic_alias_max(Some(600)); + .set_session_expiry_interval(Some(u32::MAX)); Ok(AsyncClient::new(mqtt_opts, 600)) } diff --git a/calypso-sim/src/modes/auto_script.rs b/calypso-sim/src/modes/auto_script.rs index b58ed7c..10cd7b4 100644 --- a/calypso-sim/src/modes/auto_script.rs +++ b/calypso-sim/src/modes/auto_script.rs @@ -29,26 +29,33 @@ pub async fn run( continue; } if let Some(rest) = line.strip_prefix("sleep ") { - match rest.trim().parse::<u64>() { - Ok(ms) => tokio::time::sleep(Duration::from_millis(ms)).await, - Err(_) => eprintln!("script: line {}: bad sleep arg '{rest}'", lineno + 1), - } + // Deterministic replay: a malformed directive aborts the run rather + // than silently skipping it and throwing off downstream timing. + let ms: u64 = rest.trim().parse().map_err(|_| { + format!( + "script: line {}: bad sleep arg '{}'", + lineno + 1, + rest.trim() + ) + })?; + tokio::time::sleep(Duration::from_millis(ms)).await; continue; } let mut chars = line.chars(); let Some(ch) = chars.next() else { continue }; if chars.next().is_some() { - eprintln!( + return Err(format!( "script: line {}: expected single char or 'sleep N', got '{line}'", lineno + 1 - ); - continue; - } - if let Some(state) = states.get_mut(&ch) { - publish_injection(ch, state, &client, ®istry).await; - } else { - eprintln!("script: line {}: no binding for key '{ch}'", lineno + 1); + )); } + let Some(state) = states.get_mut(&ch) else { + return Err(format!( + "script: line {}: no binding for key '{ch}'", + lineno + 1 + )); + }; + publish_injection(ch, state, &client, ®istry).await; } // Allow broker time to flush. diff --git a/calypso-sim/src/modes/autonomous.rs b/calypso-sim/src/modes/autonomous.rs index faf2f15..71f66d6 100644 --- a/calypso-sim/src/modes/autonomous.rs +++ b/calypso-sim/src/modes/autonomous.rs @@ -10,14 +10,14 @@ use crate::publish::publish_data; use crate::registry::{Owner, SharedRegistry}; #[derive(Debug)] -enum FilterMode { +pub(crate) enum FilterMode { Disabled, Blacklist(Vec<Regex>), Whitelist(Vec<Regex>), } impl FilterMode { - fn build(enable: &[String], disable: &[String]) -> Result<Self, String> { + pub(crate) fn build(enable: &[String], disable: &[String]) -> Result<Self, String> { if !disable.is_empty() { Ok(Self::Blacklist(compile_patterns(disable)?)) } else if !enable.is_empty() { @@ -52,17 +52,8 @@ pub async fn run( token: CancellationToken, client: AsyncClient, registry: SharedRegistry, - enable: Vec<String>, - disable: Vec<String>, + filter: FilterMode, ) { - let filter = match FilterMode::build(&enable, &disable) { - Ok(f) => f, - Err(err) => { - eprintln!("Autonomous mode: {err}"); - return; - } - }; - let mut components: Vec<_> = create_simulated_components() .into_iter() .filter(|c| filter.allows(&c.name)) diff --git a/calypso-sim/src/modes/stream.rs b/calypso-sim/src/modes/stream.rs index 22c59d8..ecfea58 100644 --- a/calypso-sim/src/modes/stream.rs +++ b/calypso-sim/src/modes/stream.rs @@ -1,10 +1,8 @@ -use std::io::{self, Write}; - use crate::simulate_data::create_simulated_components; use rumqttc::v5::AsyncClient; use serde::Deserialize; use serde_json::{Value, json}; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio_util::sync::CancellationToken; use crate::publish::publish_data; @@ -38,7 +36,7 @@ pub async fn run( continue; } let resp = handle_line(&line, &client, ®istry).await; - write_line(&resp); + write_line(&resp).await; } Ok(None) => break, // stdin closed Err(e) => { @@ -194,9 +192,12 @@ fn error(id: Value, code: i32, message: &str) -> Value { json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) } -fn write_line(value: &Value) { - let s = serde_json::to_string(value).unwrap_or_else(|_| "{}".into()); - let mut out = io::stdout().lock(); - let _ = writeln!(out, "{s}"); - let _ = out.flush(); +async fn write_line(value: &Value) { + let mut s = serde_json::to_string(value).unwrap_or_else(|_| "{}".into()); + s.push('\n'); + // Async stdout so a slow/stalled stream consumer can't block a runtime + // worker thread (the read side is already async). + let mut out = tokio::io::stdout(); + let _ = out.write_all(s.as_bytes()).await; + let _ = out.flush().await; } diff --git a/calypso-sim/src/simulatable_message.rs b/calypso-sim/src/simulatable_message.rs index b619c75..a3aca07 100644 --- a/calypso-sim/src/simulatable_message.rs +++ b/calypso-sim/src/simulatable_message.rs @@ -2,6 +2,9 @@ // Vendored from main calypso (src/simulatable_message.rs). Some struct // fields are read only by the codegen-expanded `create_simulated_components` // initializer and not by sim code paths. +// +// Divergence from upstream: `SimValue::initialize` adds bounds guards (empty +// range / empty options) so a degenerate spec entry can't panic at startup. use super::data::DecodeData; use rand::prelude::*; @@ -129,7 +132,13 @@ impl SimValue { current, .. } => { - *current = rng.random_range(*min..*max); + // Guard a degenerate (min == max) range: `random_range` panics + // on an empty range. Mirrors `keymap::randomize_component`. + *current = if (*max - *min).abs() < f32::EPSILON { + *min + } else { + rng.random_range(*min..*max) + }; if *inc_min != 0.0 { *current = (*current / *inc_min).round() * *inc_min; // Round to nearest inc_min } @@ -138,8 +147,10 @@ impl SimValue { } } SimValue::Discrete { options, current } => { - let idx = rng.random_range(0..options.len()); - *current = options[idx].0; + // `choose` is empty-safe (returns None); direct indexing panics. + if let Some(&(v, _)) = options.choose(&mut rng) { + *current = v; + } } } } From 8eca5a2180116d089fd0b83f4835b384216cf364 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Sun, 28 Jun 2026 10:56:26 -0700 Subject: [PATCH 07/29] #305 - drop unused crossterm dep and guard SimValue::initialize against degenerate spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `crossterm` from the root Cargo.toml (zero refs in src/ or libs/; only the separate calypso-sim crate uses it, via its own manifest) and drop its now-orphaned transitive crates from Cargo.lock — pure removal, no other version changes. - Guard `SimValue::initialize` in the main crate against degenerate spec entries (empty min==max range, empty discrete options) so a malformed CAN spec can't panic at startup, bringing it to parity with the already-hardened calypso-sim copy. - Remove the now-stale "Divergence from upstream" note from the vendored calypso-sim copy, since upstream now carries the same guards. --- Cargo.lock | 70 -------------------------- Cargo.toml | 1 - calypso-sim/src/simulatable_message.rs | 3 -- src/simulatable_message.rs | 14 ++++-- 4 files changed, 11 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e806627..b371248 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,7 +139,6 @@ dependencies = [ "calypso-cangen", "can-dbc", "clap", - "crossterm", "daedalus", "futures-util", "num_enum", @@ -320,32 +319,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags", - "crossterm_winapi", - "futures-core", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -1457,27 +1430,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1928,28 +1880,6 @@ dependencies = [ "rustix 0.38.44", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index e74994d..8c08301 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,6 @@ futures-util = "0.3.31" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["ansi", "env-filter"] } tokio-util = { version = "0.7.16", features = ["full"] } -crossterm = { version = "0.28", features = ["event-stream"] } can-dbc = "9.1.0" serde.workspace = true serde_json.workspace = true diff --git a/calypso-sim/src/simulatable_message.rs b/calypso-sim/src/simulatable_message.rs index a3aca07..ebcd9b8 100644 --- a/calypso-sim/src/simulatable_message.rs +++ b/calypso-sim/src/simulatable_message.rs @@ -2,9 +2,6 @@ // Vendored from main calypso (src/simulatable_message.rs). Some struct // fields are read only by the codegen-expanded `create_simulated_components` // initializer and not by sim code paths. -// -// Divergence from upstream: `SimValue::initialize` adds bounds guards (empty -// range / empty options) so a degenerate spec entry can't panic at startup. use super::data::DecodeData; use rand::prelude::*; diff --git a/src/simulatable_message.rs b/src/simulatable_message.rs index c08ac90..9357317 100644 --- a/src/simulatable_message.rs +++ b/src/simulatable_message.rs @@ -124,7 +124,13 @@ impl SimValue { current, .. } => { - *current = rng.random_range(*min..*max); + // Guard a degenerate (min == max) range: `random_range` panics + // on an empty range. + *current = if (*max - *min).abs() < f32::EPSILON { + *min + } else { + rng.random_range(*min..*max) + }; if *inc_min != 0.0 { *current = (*current / *inc_min).round() * *inc_min; // Round to nearest inc_min } @@ -133,8 +139,10 @@ impl SimValue { } } SimValue::Discrete { options, current } => { - let idx = rng.random_range(0..options.len()); - *current = options[idx].0; + // `choose` is empty-safe (returns None); direct indexing panics. + if let Some(&(v, _)) = options.choose(&mut rng) { + *current = v; + } } } } From 92708d4d0f34955bf0b0b2838b247c4a8ffc75fc Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Sun, 28 Jun 2026 12:24:00 -0700 Subject: [PATCH 08/29] #305 - simplify calypso-sim: centralize publish-ownership policy and compile placeholder regex once Centralize the per-topic publish/ownership policy that was re-derived inline at four call sites across three mode files: add TopicRegistry::auto_may_publish (the heartbeat publishes only while a topic is still Auto-owned) and driver_may_publish (stream/keymap drivers publish unless a topic is Silenced), co-located with the Owner enum, and route autonomous/keymap/stream through them. Behavior-preserving. In the vendored simulatable_message.rs, hoist the `{}` placeholder Regex in topic_values_inject into a compile-once LazyLock and drop the per-call intermediate Vec - a documented sim-local divergence from the upstream copy, which still recompiles per call. --- calypso-sim/src/keymap.rs | 4 ++-- calypso-sim/src/modes/autonomous.rs | 5 ++--- calypso-sim/src/modes/stream.rs | 2 +- calypso-sim/src/registry.rs | 13 ++++++++++++ calypso-sim/src/simulatable_message.rs | 29 +++++++++++++------------- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/calypso-sim/src/keymap.rs b/calypso-sim/src/keymap.rs index 588fabb..33c6b99 100644 --- a/calypso-sim/src/keymap.rs +++ b/calypso-sim/src/keymap.rs @@ -349,7 +349,7 @@ pub async fn publish_injection( } let steps = steps.clone(); for step in steps { - if registry.read().await.owner(&step.topic) == Owner::Silenced { + if !registry.read().await.driver_may_publish(&step.topic) { continue; } if step.delay_ms > 0 { @@ -361,7 +361,7 @@ pub async fn publish_injection( return; } - if registry.read().await.owner(&state.topic) == Owner::Silenced { + if !registry.read().await.driver_may_publish(&state.topic) { return; } diff --git a/calypso-sim/src/modes/autonomous.rs b/calypso-sim/src/modes/autonomous.rs index 71f66d6..1911bb3 100644 --- a/calypso-sim/src/modes/autonomous.rs +++ b/calypso-sim/src/modes/autonomous.rs @@ -7,7 +7,7 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; use crate::publish::publish_data; -use crate::registry::{Owner, SharedRegistry}; +use crate::registry::SharedRegistry; #[derive(Debug)] pub(crate) enum FilterMode { @@ -78,8 +78,7 @@ pub async fn run( if !component.should_update() { continue; } - let owner = registry.read().await.owner(&component.name); - if owner != Owner::Auto { + if !registry.read().await.auto_may_publish(&component.name) { continue; } component.update(); diff --git a/calypso-sim/src/modes/stream.rs b/calypso-sim/src/modes/stream.rs index ecfea58..bcc5a21 100644 --- a/calypso-sim/src/modes/stream.rs +++ b/calypso-sim/src/modes/stream.rs @@ -135,7 +135,7 @@ async fn handle_publish( _ => return error(id, ERR_INVALID_PARAMS, "missing `value` or `values`"), }; - if registry.read().await.owner(&p.topic) == Owner::Silenced { + if !registry.read().await.driver_may_publish(&p.topic) { return ok(id, json!({"skipped": "silenced"})); } diff --git a/calypso-sim/src/registry.rs b/calypso-sim/src/registry.rs index ca63526..daf1bb7 100644 --- a/calypso-sim/src/registry.rs +++ b/calypso-sim/src/registry.rs @@ -44,6 +44,19 @@ impl TopicRegistry { self.overrides.get(topic).copied().unwrap_or(Owner::Auto) } + /// Whether the autonomous heartbeat may publish `topic`. The heartbeat + /// only drives a topic while it is still `Auto`-owned; once a stream or + /// keymap driver claims or silences it, the heartbeat yields. + pub fn auto_may_publish(&self, topic: &str) -> bool { + self.owner(topic) == Owner::Auto + } + + /// Whether a stream/keymap driver may publish `topic`. Drivers may publish + /// anything except a topic that has been explicitly `Silenced`. + pub fn driver_may_publish(&self, topic: &str) -> bool { + self.owner(topic) != Owner::Silenced + } + /// Set ownership; returns the previous owner. Setting back to `Auto` /// removes the override entirely. pub fn set(&mut self, topic: &str, owner: Owner) -> Owner { diff --git a/calypso-sim/src/simulatable_message.rs b/calypso-sim/src/simulatable_message.rs index ebcd9b8..cfd3401 100644 --- a/calypso-sim/src/simulatable_message.rs +++ b/calypso-sim/src/simulatable_message.rs @@ -6,6 +6,7 @@ use super::data::DecodeData; use rand::prelude::*; use regex::Regex; +use std::sync::LazyLock; use std::time::Instant; /********************* SIMULATE_MESSAGE.H *********************/ @@ -243,6 +244,11 @@ impl SimValue { } } +/// Placeholder pattern (`{}`) for in-topic value injection. Compiled once and +/// reused, unlike the upstream copy in `calypso`'s `src/simulatable_message.rs` +/// which recompiles it on every call; mirror this hoist if you optimize upstream. +static PLACEHOLDER_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\}").unwrap()); + /** * This helper function takes a `SimComponent`, injects the associated `CANPoint` values into the topic string * e.g. "Hello/{}/World/{}" -> "Hello/{4}/World{5}" @@ -256,26 +262,21 @@ pub fn topic_values_inject(component: &SimComponent) -> String { if let Some(points_intopic) = &component.points_intopic { let component_name = &component.name; // check: placeholder count lines up with in point vector array length - let re = Regex::new(r"\{\}").unwrap(); - if points_intopic.len() != re.find_iter(component_name).count() { + if points_intopic.len() != PLACEHOLDER_RE.find_iter(component_name).count() { eprintln!( "[error] in-topic points vector length does not line up with placeholder count" ); return component_name.clone(); } - let in_topic_values: Vec<u32> = points_intopic - .iter() - .map(|p| p.get_value() as u32) - .collect(); - // Replace {} placeholders with values - let mut value_iter = in_topic_values.iter(); - re.replace_all(component_name, |_: ®ex::Captures| { - value_iter - .next() - .map_or("{}".to_string(), std::string::ToString::to_string) - }) - .into_owned() + // Replace {} placeholders with values, pulling each in-topic point's + // value on the fly (no intermediate Vec). + let mut values = points_intopic.iter().map(|p| p.get_value() as u32); + PLACEHOLDER_RE + .replace_all(component_name, |_: ®ex::Captures| { + values.next().map_or("{}".to_string(), |v| v.to_string()) + }) + .into_owned() } else { component.name.clone() } From e06e6b418addd3860f32bc1cfab050df4c360f62 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Sun, 28 Jun 2026 12:57:53 -0700 Subject: [PATCH 09/29] #305 - stop tracking .claude commands/skills (already gitignored) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These per-repo Claude Code command/skill files were force-added in fdab310 despite `.claude` already being in .gitignore (since 7ac8de7). Untrack them with `git rm --cached` so the ignore rule takes effect — the files stay on disk locally and remain in history at fdab310; this only drops them from the branch tip going forward. --- .claude/commands/commit.md | 25 ----- .claude/commands/open-pr.md | 52 --------- .claude/commands/update-pr.md | 72 ------------ .claude/skills/create-ticket/SKILL.md | 62 ----------- .claude/skills/review/SKILL.md | 151 -------------------------- .claude/skills/start-ticket/SKILL.md | 47 -------- 6 files changed, 409 deletions(-) delete mode 100644 .claude/commands/commit.md delete mode 100644 .claude/commands/open-pr.md delete mode 100644 .claude/commands/update-pr.md delete mode 100644 .claude/skills/create-ticket/SKILL.md delete mode 100644 .claude/skills/review/SKILL.md delete mode 100644 .claude/skills/start-ticket/SKILL.md diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md deleted file mode 100644 index d69e1d0..0000000 --- a/.claude/commands/commit.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*), Bash(git diff:*) -description: Stage and commit using this repo's commit message convention ---- - -# Context - -Current branch: -!`git branch --show-current` - -Git status: -!`git status` - -Staged + unstaged diff: -!`git diff HEAD` - -Recent commits: -!`git log --oneline -5` - -# Task - -1. Extract the ticket number from the current branch name using pattern: leading digits before the first `-` (e.g. `289-sim-custom-topic-injection-mode` -> `#289`) -2. Stage all relevant changes (prefer naming specific files over `git add -A`) -3. Write a commit message in the format: `#NUMBER - description` — example: `#225 - added simulation black/whitelist based on topic name, using regex` -4. Commit in a single operation using a HEREDOC for the message diff --git a/.claude/commands/open-pr.md b/.claude/commands/open-pr.md deleted file mode 100644 index 7bd9053..0000000 --- a/.claude/commands/open-pr.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -allowed-tools: Bash(git:*), Bash(gh pr create:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo build:*) -description: Run pre-PR checks, push the branch, and open a pull request ---- - -## Your task - -Run each step in order. Stop and report if any step fails. - -### 1. Verify commit format - -!`git log --oneline Develop..HEAD` - -Confirm all commits match `#TICKET - description` (ticket number extracted from branch prefix digits before first `-`). Flag any that don't. - -### 2. Lint and test -```bash -cargo fmt --all --check -cargo clippy --verbose --all -- -D warnings -cargo test --verbose -``` - -### 3. Conflict check -```bash -git fetch origin Develop -git merge --no-commit --no-ff origin/Develop -git merge --abort -``` -If conflicts exist, stop and ask the user to resolve. - -### 4. Write PR body - -Use `.github/pull_request_template.md` as the base. Read the full diff with `git diff Develop..HEAD`. - -**PR style for this repo:** -- **Changes**: 1-2 concise sentences. What was added/changed and why, not a line-by-line summary. -- **Notes**: Brief, informal context — design decisions, tradeoffs, things the reviewer should know. Remove this section if nothing to say. -- **Test Cases**: Practical CLI examples showing how to verify (e.g. `simulate --topic "BMS/Pack/Voltage=30000"`). Include expected behavior for each. -- **To Do**: Remove this section unless there are actual remaining items. -- **Checklist**: Check off all applicable items. Always check "Remove any non-applicable sections." -- **Tone**: Casual and direct. No formal language or over-explanation. -- End with `Closes #TICKET` (ticket number from branch prefix). - -Write the filled-in body to `/tmp/pr-body.md`. - -### 5. Push and open PR -```bash -git push -u origin $(git branch --show-current) -gh pr create --draft --base Develop --title "#TICKET - brief title" --body-file /tmp/pr-body.md -``` - -Use the ticket number extracted from the branch name. Keep the title under 70 characters. Report the PR URL when done. diff --git a/.claude/commands/update-pr.md b/.claude/commands/update-pr.md deleted file mode 100644 index baf9c79..0000000 --- a/.claude/commands/update-pr.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -allowed-tools: Bash(git:*), Bash(gh pr view:*), Bash(gh pr edit:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo build:*) -description: Update the current branch's PR title, body, and checks after new commits ---- - -## Context - -Current branch: -!`git branch --show-current` - -Recent commits since Develop: -!`git log --oneline Develop..HEAD` - -## Task - -Run each step in order. Stop and report if any step fails. - -### 1. Get the current PR - -```bash -gh pr view --json number,title,body,commits,reviews -``` - -If no PR exists for this branch, stop and tell the user to run `/open-pr` first. - -### 2. Run lint and tests - -```bash -cargo fmt --all --check -cargo clippy --verbose --all -- -D warnings -cargo test --verbose -``` - -If any check fails, stop and report the failure. Do not push broken code. - -### 3. Push latest changes - -```bash -git push -``` - -### 4. Update PR title and body - -Extract the ticket number from the branch name using the leading digits before the first `-` (e.g. `289-sim-custom-topic-injection-mode` -> `#289`). - -Read the full diff with `git diff Develop..HEAD` and the full commit log with `git log Develop..HEAD --oneline`. - -Use `.github/pull_request_template.md` as the base for the updated body. Follow the same style rules as `/open-pr`: - -- **Changes**: 1-2 concise sentences. What was added/changed and why, not a line-by-line summary. Reflect ALL commits on the branch, not just the latest. -- **Notes**: Brief, informal context — design decisions, tradeoffs, things the reviewer should know. Remove this section if nothing to say. -- **Test Cases**: Practical CLI examples showing how to verify. Include expected behavior for each. -- **To Do**: Remove this section unless there are actual remaining items. -- **Checklist**: Check off all applicable items. Always check "Remove any non-applicable sections." -- **Tone**: Casual and direct. No formal language or over-explanation. -- End with `Closes #TICKET` (ticket number from branch prefix). - -Write the filled-in body to `/tmp/pr-body.md`. - -Update the PR: -```bash -gh pr edit --title "#TICKET - brief title" --body-file /tmp/pr-body.md -``` - -Keep the title under 70 characters. The title should summarize the full scope of the branch, not just the latest commit. - -### 5. Report - -Output the updated PR URL: -```bash -gh pr view --json url --jq '.url' -``` diff --git a/.claude/skills/create-ticket/SKILL.md b/.claude/skills/create-ticket/SKILL.md deleted file mode 100644 index dd76772..0000000 --- a/.claude/skills/create-ticket/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: create-ticket -description: Create a new GitHub Issue from a description -user-invocable: true -allowed-tools: Bash, Read, Grep, Glob ---- - -# Create Ticket - -Create a new GitHub Issue with proper formatting and labels. - -## Steps - -### 1. Get the description - -If the user provided a description as an argument, use that. Otherwise, ask what the ticket should cover (this is the ONLY time to ask). - -### 2. Classify and pick a template - -Based on the description, classify the work as one of the repo's issue types: -- **bug** — something broken -- **feature-request** — new capability -- **task** — general work item -- **epic** — large multi-issue effort -- **spike** — research / investigation -- **other** — anything else - -### 3. Generate the issue - -- **Title**: Use the pattern `[Area] - Short Description` (e.g. `[SIM] - Add custom topic injection mode`) -- **Body**: Include a clear description, acceptance criteria, and any relevant context. Follow the structure from the matching `.github/ISSUE_TEMPLATE/` template. -- **Labels**: Apply the most relevant label(s) from the repo's actual label list (run `gh label list` if unsure). Common ones: `bug`, `good first issue`, `epic`, `dependencies`, `submodules`, `rust`, `github_actions`. Don't invent labels — pick from what exists. - -### 3a. Write like a teammate, not like an AI - -Use plain English. Short sentences. The reader is another engineer skimming a backlog, not a marketing audience. - -**Avoid these tells:** -- "compose" / "compose freely" / "can't compose" → "run together", "run at the same time", "work alongside each other" -- "control plane" / "primitive" / "unlock" / "first-class" / "surface area" / "blast radius" → describe the thing in plain words -- "agent-driven way to drive X" / "X-driven Y" stacked → pick one phrasing -- "yields cleanly" / "honors the registry" / "mediates ownership" → "skips", "checks", "tracks" -- "elegant" / "clean" / "robust" / "powerful" / "seamless" → describe what makes it good instead, or drop the adjective -- Empty connective tissue: "the key insight is", "the unlock here is", "fundamentally" -- Stacking tables, nested headings, and bullet sub-lists when a short paragraph would do - -**Rewrite test:** if a sentence has a phrase you wouldn't say out loud to a coworker, rewrite it. - -**Length:** short sections. Three short paragraphs beat one long one. Acceptance criteria as a checklist beats prose. - -### 4. Create the issue - -```bash -gh issue create --title "<title>" --label "<label>" --body "$(cat <<'EOF' -<body> -EOF -)" -``` - -### 5. Done - -Output the issue number and URL. diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md deleted file mode 100644 index 0ba9a3a..0000000 --- a/.claude/skills/review/SKILL.md +++ /dev/null @@ -1,151 +0,0 @@ ---- -name: review -description: "Hierarchical code review: triage, review, score, fix, simplify. Run /review to review branch changes, /review improve to learn from this session." -argument-hint: "[improve]" -allowed-tools: Read, Write, Edit, Glob, Grep, Bash(git *), Bash(gh *), Agent ---- - -# Code Review - -Hierarchical review pipeline modeled after Anthropic's code-review plugin. Cheap triage, focused review, independent scoring, then fix and simplify. - -## Argument Handling - -If `` is "improve", skip to **Step 7: Improve** below. - ---- - -## Step 1: Eligibility Check - -Use a **Haiku agent** (`model: "haiku"`). Determine if there is anything to review: - -- Get the base branch from CLAUDE.md conventions (default: `develop` if found, otherwise `main`) -- Run `git diff --name-only $(git merge-base HEAD <base-branch>)...HEAD` -- If no changed files, tell the user and stop - -## Step 2: Gather Context - -Use a **Haiku agent** (`model: "haiku"`). Return: - -- List of all changed file paths, categorized (source, test, config, docs, types) -- Paths to relevant CLAUDE.md files (root + any in directories containing changed files) -- Contents of `.claude/review-rules.md` if it exists - -## Step 3: Summarize the Change - -Use a **Haiku agent** (`model: "haiku"`): - -- Run `git diff $(git merge-base HEAD <base-branch>)...HEAD` -- Return a brief summary of what this change does and why (intent, not implementation) - -Steps 1-3 can run in parallel. - ---- - -## Step 4: Review - -Read every changed file in full (not just the diff) for surrounding context. - -Launch **parallel agents** to independently review the change. Each agent returns a list of issues with the reason each was flagged (CLAUDE.md adherence, bug, historical context, etc.). - -### Agent dispatch: - -| # | Agent | Condition | What it does | -|---|---|---|---| -| A | `code-reviewer` (PR Review Toolkit) | Always | CLAUDE.md compliance, bug detection, general code quality. Scores 0-100, reports 80+. | -| B | Shallow bug scan | Always | Read just the diff. Scan for obvious bugs -- logic errors, null safety, async issues, wrong operators. Focus on big bugs, skip nitpicks. Ignore likely false positives. | -| C | Git history context | Always | Read `git blame` and history of modified code. Identify bugs in light of historical context. | -| D | `silent-failure-hunter` (PR Review Toolkit) | Error handling code present (catch blocks, .catch(), Result types, fallback logic) | Audit error handlers for silent failures, logging quality, catch specificity. | -| E | `comment-analyzer` (PR Review Toolkit) | Comments or docs added/modified | Verify comment accuracy against code, flag misleading docs. | - -Agents B and C are inline (not toolkit) -- use `model: "sonnet"` for these. - -### False positive examples (pass to agents B and C verbatim): - -Do NOT flag: -- Pre-existing issues (not introduced by this change) -- Pedantic nitpicks a senior engineer wouldn't call out -- Functionality changes clearly intentional given the broader change -- Real issues on lines the user did not modify - ---- - -## Step 5: Confidence Scoring - -For **each issue** found in Step 4, launch a parallel **Haiku agent** (`model: "haiku"`) that takes the issue, the PR diff, and the CLAUDE.md files, and returns a confidence score. - -For issues flagged due to CLAUDE.md, the agent must double-check that the CLAUDE.md actually calls out that issue specifically. - -### Scoring rubric (pass to each agent verbatim): - -- **0**: Not confident at all. False positive that doesn't stand up to light scrutiny, or is a pre-existing issue. -- **25**: Somewhat confident. Might be real, might be false positive. Agent wasn't able to verify. If stylistic, not explicitly called out in CLAUDE.md. -- **50**: Moderately confident. Verified real issue, but may be a nitpick or rare in practice. Not very important relative to the rest of the PR. -- **75**: Highly confident. Double-checked and verified it is very likely real and will be hit in practice. The existing approach in the PR is insufficient. Directly impacts functionality, or directly mentioned in CLAUDE.md. -- **100**: Absolutely certain. Double-checked and confirmed definitely real, will happen frequently. Evidence directly confirms this. - -### After scoring: - -**Filter out all issues with confidence < 80.** If nothing survives, report no high-confidence issues found and skip to Step 6b (Simplify). - ---- - -## Step 6: Fix & Simplify - -### 6a. Auto-Fix - -Present surviving issues grouped by severity: - -- **Critical** (90-100): Will cause incorrect behavior, data loss, or crash -- **Important** (80-89): Significant issue requiring attention - -For each issue: -- Description and confidence score -- File path and line number -- Why it's an issue (cite specific CLAUDE.md rule or provide bug evidence) - -**Fix all Critical and Important issues directly.** Briefly note what was changed for each. - -If any issues scored 50-79, list under **Risks** -- state without fixing. - -### 6b. Simplify - -After fixes are applied, dispatch the `code-simplifier` agent (PR Review Toolkit) with the current state of all changed files. It simplifies for clarity, consistency, and maintainability while preserving functionality. Apply its suggestions directly. - -### 6c. Summary - -- **Strengths**: What the change does well -- **Fixed**: List of issues with scores and what was changed -- **Risks**: Issues below threshold worth knowing about -- **Verdict**: Is this ready to merge? - ---- - -## Step 7: Improve - -**This runs when the user invokes `/review improve`.** - -This phase makes the skill itself better by editing this SKILL.md file directly. Read the full SKILL.md first, then review the conversation history since the last `/review` was run. - -### Analyze the session: - -1. **False positives**: What did `/review` flag that the user ignored, dismissed, or reverted? Should the false-positive list or scoring rubric be adjusted? -2. **Missed issues**: What did the user fix on their own that `/review` didn't catch? Should an agent condition be added or the scoring rubric adjusted? -3. **Overreach**: Did `/review` rate something too high? Should thresholds change? -4. **Good calls**: What findings did the user agree with and keep? - -### Apply changes to this SKILL.md: - -- **Edit** agent dispatch conditions, scoring rubric, or false-positive guidance that produced bad results -- **Remove** rules that are consistently wrong and can't be salvaged -- **Add** new false-positive examples or dispatch conditions when something was missed -- **Adjust** scoring thresholds if findings were consistently over- or under-rated - -### Constraints: - -- Do not change the overall structure (steps, agent dispatch pattern, scoring pipeline) -- Do not remove the Step 7: Improve section itself -- Do not change the frontmatter -- Keep edits surgical -- every change must trace to a specific session outcome - -After editing, summarize what was changed and why. diff --git a/.claude/skills/start-ticket/SKILL.md b/.claude/skills/start-ticket/SKILL.md deleted file mode 100644 index 9a43aca..0000000 --- a/.claude/skills/start-ticket/SKILL.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: start-ticket -description: Read a ticket, create a branch, and implement the work end-to-end -user-invocable: true -allowed-tools: Bash, Read, Edit, Write, Grep, Glob ---- - -# Start Ticket - -Pick up a GitHub Issue, create a branch, and implement the work fully. - -## Steps - -### 1. Select the ticket - -If a ticket number was provided as an argument, use that. Otherwise, list recent open issues and ask the user which one to work on (this is the ONLY time to ask): - -```bash -gh issue list --limit 15 --state open -``` - -### 2. Read the ticket and self-assign - -```bash -gh issue view <NUMBER> -gh issue edit <NUMBER> --add-assignee @me -``` - -Carefully read the full issue body, acceptance criteria, and any linked discussions. - -### 3. Create the branch - -Derive the branch name using the pattern `{number}-{kebab-case-title}` (e.g., ticket 533 titled "Fix login redirect" becomes `533-fix-login-redirect`). - -Create the branch from the latest `Develop`: - -```bash -git checkout Develop && git pull origin Develop && git checkout -b <branch> -``` - -### 4. Implement the ticket - -Use `/lap` repeatedly — each lap finds the highest-leverage improvement, makes it, verifies it, and commits. Keep running laps until every requirement in the ticket is fully met. - -### 5. Done - -Output a one-line summary of what was built. From 9425ebeffc7dc8a24d1624b4231995d611ceefb2 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Tue, 7 Jul 2026 12:00:32 -0400 Subject: [PATCH 10/29] #305 - port calypso-sim test harness from Python to Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace scripts/vcu_mimic.py with native Rust tests that run under `cargo test` with no broker required: - tests/: spawn the real `calypso-sim --stream` binary and drive JSON-RPC over stdio — protocol conformance plus a VcuMock (mirroring Cerberus-2.0's u_statemachine.c) exercising boot/claim-all, the PIT brake+shutdown gate, and ownership isolation. - registry.rs: unit tests for the auto/stream/silenced ownership model. - publish.rs: extract encode_server_data so ServerData encoding is covered by a round-trip test, independent of any client/broker. - Add serde_json dev-dependency; document the suite in README.md. No broker is needed because publish only enqueues and ownership is answered from the JSON-RPC responses; broker-backed end-to-end value observation (drive-sweep/reverse/fault scenarios) is a planned follow-up. --- calypso-sim/Cargo.lock | 4 +- calypso-sim/Cargo.toml | 4 + calypso-sim/README.md | 20 + calypso-sim/scripts/README.md | 66 --- calypso-sim/scripts/serverdata_pb2.py | 25 - calypso-sim/scripts/vcu_mimic.py | 645 -------------------------- calypso-sim/src/publish.rs | 49 +- calypso-sim/src/registry.rs | 58 +++ calypso-sim/tests/common/mod.rs | 179 +++++++ calypso-sim/tests/protocol.rs | 83 ++++ calypso-sim/tests/scenarios.rs | 378 +++++++++++++++ 11 files changed, 765 insertions(+), 746 deletions(-) delete mode 100644 calypso-sim/scripts/README.md delete mode 100644 calypso-sim/scripts/serverdata_pb2.py delete mode 100644 calypso-sim/scripts/vcu_mimic.py create mode 100644 calypso-sim/tests/common/mod.rs create mode 100644 calypso-sim/tests/protocol.rs create mode 100644 calypso-sim/tests/scenarios.rs diff --git a/calypso-sim/Cargo.lock b/calypso-sim/Cargo.lock index 1381f9b..edbd529 100644 --- a/calypso-sim/Cargo.lock +++ b/calypso-sim/Cargo.lock @@ -1048,9 +1048,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/calypso-sim/Cargo.toml b/calypso-sim/Cargo.toml index 04c1c33..74e01c2 100644 --- a/calypso-sim/Cargo.toml +++ b/calypso-sim/Cargo.toml @@ -32,6 +32,10 @@ futures-util = "0.3.31" daedalus = { path = "../libs/daedalus" } calypso-cangen = { path = "../libs/calypso-cangen" } +[dev-dependencies] +# Used by the integration tests in `tests/` to build/parse JSON-RPC lines. +serde_json = "1.0.149" + [build-dependencies] protobuf-codegen = "3.7.2" diff --git a/calypso-sim/README.md b/calypso-sim/README.md index c5b928e..9c17ba0 100644 --- a/calypso-sim/README.md +++ b/calypso-sim/README.md @@ -99,3 +99,23 @@ JSON-RPC 2.0 over stdio — one request per line on stdin, one response per line | `ping` | `{}` | `{ok: true}` | Errors follow JSON-RPC 2.0 (`{error: {code, message}}`) with the standard codes: `-32700` (parse), `-32600` (invalid request), `-32601` (method not found), `-32602` (invalid params), and `-32603` (internal). + +## Testing + +The tests need **no broker** — just run: + +``` +cd calypso-sim +cargo test +``` + +| Layer | Where | What it checks | +|---|---|---| +| Unit — ownership | `src/registry.rs` | The `auto` / `stream` / `silenced` state machine: a claim makes the heartbeat yield, silence blocks everyone, release restores `auto`. | +| Unit — encoding | `src/publish.rs` | `ServerData` round-trips: unit, values, and timestamp survive encode → decode. | +| Integration — protocol | `tests/protocol.rs` | Spawns the real `calypso-sim --stream` binary and checks the JSON-RPC contract (`ping`, `list_topics`, `publish`, and the `-32600` / `-32601` / `-32602` error paths). | +| Integration — scenarios | `tests/scenarios.rs` | A `VcuMock` mirroring Cerberus-2.0's `Core/Src/u_statemachine.c` drives the binary over stdio: boot claims every `VCU/*` topic (S1), the brake + shutdown gate on entering PIT (S3), and ownership isolation via claim / silence / release with the heartbeat running (S4b). | + +No broker is needed because `publish` only enqueues (the eventloop retries a missing broker rather than dropping the queue), so it still returns a `ts_us`, and ownership arbitration is answered entirely from the JSON-RPC responses. Confirming the actual *bytes on the wire* end-to-end — the drive-sweep, reverse, and fault-recovery scenarios the former Python harness observed through a subscriber — is the one slice that needs a live broker and is a planned follow-up; value encoding itself is already covered by the `src/publish.rs` round-trip test. + +CI (`.github/workflows/calypso-sim-ci.yml`) runs the suite on any change under `calypso-sim/**` or its path-dependencies. diff --git a/calypso-sim/scripts/README.md b/calypso-sim/scripts/README.md deleted file mode 100644 index 2619de9..0000000 --- a/calypso-sim/scripts/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# calypso-sim test scripts - -Python harness that drives `calypso-sim --stream` to exercise the JSON-RPC protocol -under realistic load — mimicking what the Cerberus-2.0 VCU firmware would publish -onto the topics NERO consumes. - -## Setup - -```bash -brew install mosquitto # MQTT broker -python3 -m pip install --user paho-mqtt # MQTT client -cd .. && cargo build --release # build calypso-sim -``` - -Optional visual aid: - -```bash -brew install mqttui # live topic viewer -``` - -The Python protobuf bindings (`serverdata_pb2.py`) are checked in; regenerate if -`src/proto/serverdata.proto` changes: - -```bash -protoc --python_out=scripts -Isrc/proto src/proto/serverdata.proto -``` - -## Run - -```bash -mosquitto # terminal 1 -python3 scripts/vcu_mimic.py # terminal 2 (run from calypso-sim/) -``` - -Useful flags: - -| Flag | Effect | -|---|---| -| `--with-auto` | Run autonomous heartbeat alongside stream — exercises ownership isolation (S4b). | -| `--only S1,S3` | Run only the named scenarios (default: all). | -| `--broker host:port` | Override broker address (default `localhost:1883`). | -| `--debug` | Verbose logging including raw stderr from calypso-sim. | - -## Scenarios - -| ID | What it covers | -|---|---| -| `S1` | Boot — claim every `VCU/*` topic, publish READY/OFF/home, verify on broker. | -| `S2` | Menu navigation — `nero_index` 0→7→0 (wrap at `MAX_NERO_STATES`). | -| `S3` | Enter PIT — `menu_select` rejected without brake+tsms, accepted once both set. | -| `S4` | Drive telemetry sweep — sine accelerator, speed capped at `PIT_MAX_SPEED=5 mph`. | -| `S4b` | (with `--with-auto`) Verify autonomous does not republish claimed topics. | -| `S5` | Enter REVERSE — return home, scroll to REVERSE, select (gateless: no brake/tsms required), verify `not_in_reverse=0`. | -| `S6` | Fault — trigger `BSPD_PREFAULT`, verify FAULTED+OFF+home, then recover. | - -Each scenario prints `Sx ✓ ...` on success or raises `AssertionError` on failure. - -## Adding a scenario - -1. Add a function `def s_my_thing(vcu, obs): ...` in `vcu_mimic.py`. -2. Use `vcu.menu_increment()`, `vcu.set_pedals(...)`, etc. to drive state. -3. Use `obs.assert_published(topic, expected_value, within_ms=500)` to verify. -4. Register it in the `SCENARIOS` dict at the bottom. - -The `VcuMock` mirrors the state machine in Cerberus-2.0's `Core/Src/u_statemachine.c` — -when adding behavior, cross-reference the C source to keep semantics aligned. diff --git a/calypso-sim/scripts/serverdata_pb2.py b/calypso-sim/scripts/serverdata_pb2.py deleted file mode 100644 index 291e255..0000000 --- a/calypso-sim/scripts/serverdata_pb2.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# source: serverdata.proto -"""Generated protocol buffer code.""" -from google.protobuf.internal import builder as _builder -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import symbol_database as _symbol_database -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10serverdata.proto\x12\rserverdata.v2\"H\n\nServerData\x12\x0c\n\x04unit\x18\x02 \x01(\t\x12\x0f\n\x07time_us\x18\x03 \x01(\x04\x12\x0e\n\x06values\x18\x04 \x03(\x02J\x04\x08\x01\x10\x02R\x05valueb\x06proto3') - -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'serverdata_pb2', globals()) -if _descriptor._USE_C_DESCRIPTORS == False: - - DESCRIPTOR._options = None - _SERVERDATA._serialized_start=35 - _SERVERDATA._serialized_end=107 -# @@protoc_insertion_point(module_scope) diff --git a/calypso-sim/scripts/vcu_mimic.py b/calypso-sim/scripts/vcu_mimic.py deleted file mode 100644 index ab28dcc..0000000 --- a/calypso-sim/scripts/vcu_mimic.py +++ /dev/null @@ -1,645 +0,0 @@ -"""VCU mimic harness for calypso-sim --stream. - -Spawns calypso-sim, drives JSON-RPC over its stdio, and verifies via -an independent paho-mqtt subscriber that publishes land on the broker. -Replays six scenarios mirroring Cerberus-2.0's `Core/Src/u_statemachine.c`. - -Run from the calypso-sim directory: - - python3 scripts/vcu_mimic.py - python3 scripts/vcu_mimic.py --with-auto # autonomous heartbeat alongside - python3 scripts/vcu_mimic.py --only S1,S2 # filter scenarios - python3 scripts/vcu_mimic.py --broker localhost:1883 -""" - -from __future__ import annotations - -import argparse -import contextlib -import json -import logging -import math -import os -import queue -import subprocess -import sys -import threading -import time -from dataclasses import dataclass, field -from enum import IntEnum -from pathlib import Path - -SCRIPT_DIR = Path(__file__).resolve().parent -SIM_DIR = SCRIPT_DIR.parent -sys.path.insert(0, str(SCRIPT_DIR)) - -import paho.mqtt.client as mqtt # noqa: E402 -import serverdata_pb2 # noqa: E402 - - -log = logging.getLogger("vcu-mimic") - - -# ---------- StreamClient --------------------------------------------------- - -class StreamRpcError(RuntimeError): - def __init__(self, code: int, message: str): - super().__init__(f"[{code}] {message}") - self.code = code - self.message = message - - -class StreamClient: - """JSON-RPC 2.0 client over calypso-sim --stream's stdio.""" - - def __init__(self, broker: str, with_auto: bool = False, release_bin: Path | None = None): - bin_path = release_bin or (SIM_DIR / "target" / "release" / "calypso-sim") - if not bin_path.exists(): - raise FileNotFoundError( - f"{bin_path} not found — run `cargo build --release` in {SIM_DIR}" - ) - cmd = [str(bin_path), "-u", broker, "--stream"] - if with_auto: - cmd.append("--auto") - log.info("spawning: %s", " ".join(cmd)) - self.proc = subprocess.Popen( - cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, bufsize=1, cwd=str(SIM_DIR), - ) - self._next_id = 1 - self._id_lock = threading.Lock() - self._pending: dict[int, queue.Queue] = {} - self._pending_lock = threading.Lock() - self._closed = False - self._reader = threading.Thread(target=self._read_stdout, daemon=True) - self._reader.start() - self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True) - self._stderr_thread.start() - - def _read_stdout(self): - for line in self.proc.stdout: - line = line.strip() - if not line: - continue - try: - msg = json.loads(line) - except json.JSONDecodeError: - log.warning("non-JSON on stdout: %r", line) - continue - rid = msg.get("id") - if rid is None: - log.debug("notification (no id): %s", msg) - continue - with self._pending_lock: - q = self._pending.pop(rid, None) - if q is not None: - q.put(msg) - else: - log.warning("response for unknown id %s: %s", rid, msg) - - def _drain_stderr(self): - for line in self.proc.stderr: - log.debug("sim-stderr: %s", line.rstrip()) - - def _call(self, method: str, params: dict | None = None, timeout: float = 5.0): - if self._closed: - raise RuntimeError("StreamClient is closed") - with self._id_lock: - rid = self._next_id - self._next_id += 1 - q: queue.Queue = queue.Queue(maxsize=1) - with self._pending_lock: - self._pending[rid] = q - req = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}} - line = json.dumps(req) + "\n" - self.proc.stdin.write(line) - self.proc.stdin.flush() - try: - resp = q.get(timeout=timeout) - except queue.Empty: - with self._pending_lock: - self._pending.pop(rid, None) - raise TimeoutError(f"no response to {method} (id={rid}) within {timeout}s") - if "error" in resp: - err = resp["error"] - raise StreamRpcError(err.get("code", 0), err.get("message", "")) - return resp.get("result", {}) - - def publish(self, topic: str, value: float, unit: str = "") -> int: - r = self._call("publish", {"topic": topic, "value": float(value), "unit": unit}) - return int(r.get("ts_us", 0)) - - def publish_values(self, topic: str, values: list[float], unit: str = "") -> int: - r = self._call("publish", {"topic": topic, "values": [float(v) for v in values], "unit": unit}) - return int(r.get("ts_us", 0)) - - def claim(self, topic: str) -> dict: return self._call("claim", {"topic": topic}) - def release(self, topic: str) -> dict: return self._call("release", {"topic": topic}) - def silence(self, topic: str) -> dict: return self._call("silence", {"topic": topic}) - def status(self) -> list[dict]: return self._call("status").get("overrides", []) - def list_topics(self) -> list[dict]: return self._call("list_topics").get("topics", []) - def ping(self) -> bool: return bool(self._call("ping").get("ok")) - - def close(self): - if self._closed: - return - self._closed = True - with contextlib.suppress(Exception): - self.proc.stdin.close() - try: - self.proc.wait(timeout=3) - except subprocess.TimeoutExpired: - self.proc.terminate() - with contextlib.suppress(subprocess.TimeoutExpired): - self.proc.wait(timeout=2) - log.info("stream client closed (rc=%s)", self.proc.returncode) - - -# ---------- BrokerObserver ------------------------------------------------- - -@dataclass -class LastMsg: - values: list[float] - time_us: int - recv_ts: float - - -class BrokerObserver: - def __init__(self, host: str, port: int): - self.host, self.port = host, port - self.last: dict[str, LastMsg] = {} - self._lock = threading.Lock() - self._connected = threading.Event() - self.client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, - client_id="vcu-mimic-observer") - self.client.on_connect = self._on_connect - self.client.on_message = self._on_message - - def _on_connect(self, client, userdata, flags, rc, props=None): - log.info("broker connected (rc=%s); subscribing to #", rc) - client.subscribe("#") - self._connected.set() - - def _on_message(self, client, userdata, msg): - try: - sd = serverdata_pb2.ServerData.FromString(msg.payload) - except Exception as e: - log.debug("payload not ServerData on %s: %s", msg.topic, e) - return - with self._lock: - self.last[msg.topic] = LastMsg(list(sd.values), int(sd.time_us), time.monotonic()) - - def start(self, timeout: float = 3.0): - self.client.connect(self.host, self.port, keepalive=30) - self.client.loop_start() - if not self._connected.wait(timeout): - raise TimeoutError(f"broker {self.host}:{self.port} did not connect within {timeout}s") - - def stop(self): - self.client.loop_stop() - with contextlib.suppress(Exception): - self.client.disconnect() - - def get(self, topic: str) -> LastMsg | None: - with self._lock: - return self.last.get(topic) - - def assert_published(self, topic: str, expected: float, within_ms: int = 500, tol: float = 1e-3): - deadline = time.monotonic() + within_ms / 1000.0 - last_seen = None - while time.monotonic() < deadline: - m = self.get(topic) - if m is not None and m.values and abs(m.values[0] - expected) <= tol: - return m - last_seen = m - time.sleep(0.02) - raise AssertionError( - f"topic {topic!r} did not reach {expected} within {within_ms}ms; " - f"last={last_seen.values if last_seen else None}" - ) - - def assert_owner_only_us(self, topic: str, since: float, max_age_ms: int = 1500): - """Assert that the most recent publish on `topic` happened at or before `since` - (i.e. the autonomous heartbeat hasn't republished it after we stopped).""" - time.sleep(max_age_ms / 1000.0) - m = self.get(topic) - if m is None: - return # nothing seen at all is fine for an idle topic - if m.recv_ts > since + 0.05: - raise AssertionError( - f"topic {topic!r} was republished after our claim cutoff " - f"(diff={(m.recv_ts - since) * 1000:.0f}ms)" - ) - - -# ---------- VcuMock -------------------------------------------------------- - -class FuncState(IntEnum): - READY = 0 - F_PIT = 1 - F_REVERSE = 2 - F_PERFORMANCE = 3 - F_EFFICIENCY = 4 - FAULTED = 5 - - -class NeroMenu(IntEnum): - OFF = 0 - PIT = 1 - REVERSE = 2 - PERFORMANCE = 3 - EFFICIENCY = 4 - GAMES = 5 - THEMES = 6 - EXIT = 7 - - -MAX_NERO_STATES = 8 - -# Calibration constants from Cerberus-2.0 Core/Src/u_pedals.c -MIN_APPS1_VOLTS = 2.15 -MAX_APPS1_VOLTS = 3.12 -MIN_APPS2_VOLTS = 1.15 -MAX_APPS2_VOLTS = 2.01 -MIN_BRAKE1_VOLTS = 0.5 -MAX_BRAKE1_VOLTS = 1.5 -MIN_BRAKE2_VOLTS = 0.5 -MAX_BRAKE2_VOLTS = 1.5 -PEDAL_BRAKE_THRESH = 0.12 -PIT_MAX_SPEED = 5.0 # mph - -CRITICAL_FAULTS = [ - "CAN_OUTGOING_FAULT", "CAN_INCOMING_FAULT", "BMS_CAN_MONITOR_FAULT", - "LIGHTNING_CAN_MONITOR_FAULT", "SHUTDOWN_FAULT", -] -NON_CRITICAL_FAULTS = [ - "ONBOARD_TEMP_FAULT", "IMU_ACCEL_FAULT", "IMU_GYRO_FAULT", "BSPD_PREFAULT", - "ONBOARD_BRAKE_OPEN_CIRCUIT_FAULT", "ONBOARD_ACCEL_OPEN_CIRCUIT_FAULT", - "ONBOARD_BRAKE_SHORT_CIRCUIT_FAULT", "ONBOARD_ACCEL_SHORT_CIRCUIT_FAULT", - "ONBOARD_PEDAL_DIFFERENCE_FAULT", "RTDS_FAULT", "LV_LOW_VOLTAGE_FAULT", -] - - -@dataclass -class VcuMock: - client: StreamClient - functional: FuncState = FuncState.READY - nero_index: NeroMenu = NeroMenu.OFF - home_mode: bool = True - accel_norm: float = 0.0 - brake_norm: float = 0.0 - tsms: bool = False # shutdown closed? - speed_mph: float = 0.0 - faults: dict[str, bool] = field(default_factory=dict) - - def __post_init__(self): - for f in CRITICAL_FAULTS + NON_CRITICAL_FAULTS: - self.faults.setdefault(f, False) - - @property - def all_owned_topics(self) -> list[str]: - out = [ - "VCU/CarState/home_mode", "VCU/CarState/nero_index", "VCU/CarState/speed", - "VCU/CarState/tsms", "VCU/CarState/torque_limit_percentage", - "VCU/CarState/not_in_reverse", "VCU/CarState/regen_limit", - "VCU/CarState/launch_control", "VCU/CarState/functional_state", - "VCU/CarState/traction_control", - "VCU/Pedals/Percentages/acceleration_pedal", - "VCU/Pedals/Percentages/brake_pedal", - "VCU/Pedals/Voltages/accel_1", "VCU/Pedals/Voltages/accel_2", - "VCU/Pedals/Voltages/brake_1", "VCU/Pedals/Voltages/brake_2", - ] - for f in CRITICAL_FAULTS: - out.append(f"VCU/Faults/Critical/{f}") - for f in NON_CRITICAL_FAULTS: - out.append(f"VCU/Faults/Non-Critical/{f}") - return out - - def claim_all(self): - for t in self.all_owned_topics: - self.client.claim(t) - - def release_all(self): - for t in self.all_owned_topics: - with contextlib.suppress(StreamRpcError): - self.client.release(t) - - # --- publishing ---- - def publish_carstate(self): - c = self.client - c.publish("VCU/CarState/home_mode", 1.0 if self.home_mode else 0.0) - c.publish("VCU/CarState/nero_index", float(int(self.nero_index))) - c.publish("VCU/CarState/speed", self.speed_mph, unit="mph") - c.publish("VCU/CarState/tsms", 1.0 if self.tsms else 0.0) - c.publish("VCU/CarState/torque_limit_percentage", 1.0) - c.publish("VCU/CarState/not_in_reverse", 0.0 if self.functional == FuncState.F_REVERSE else 1.0) - c.publish("VCU/CarState/regen_limit", 50.0, unit="A") - c.publish("VCU/CarState/launch_control", 0.0) - c.publish("VCU/CarState/functional_state", float(int(self.functional))) - c.publish("VCU/CarState/traction_control", 0.0) - - def publish_pedals(self): - c = self.client - a, b = self.accel_norm, self.brake_norm - c.publish("VCU/Pedals/Percentages/acceleration_pedal", a) - c.publish("VCU/Pedals/Percentages/brake_pedal", b) - c.publish("VCU/Pedals/Voltages/accel_1", - MIN_APPS1_VOLTS + a * (MAX_APPS1_VOLTS - MIN_APPS1_VOLTS), unit="V") - c.publish("VCU/Pedals/Voltages/accel_2", - MIN_APPS2_VOLTS + a * (MAX_APPS2_VOLTS - MIN_APPS2_VOLTS), unit="V") - c.publish("VCU/Pedals/Voltages/brake_1", - MIN_BRAKE1_VOLTS + b * (MAX_BRAKE1_VOLTS - MIN_BRAKE1_VOLTS), unit="V") - c.publish("VCU/Pedals/Voltages/brake_2", - MIN_BRAKE2_VOLTS + b * (MAX_BRAKE2_VOLTS - MIN_BRAKE2_VOLTS), unit="V") - - def publish_faults(self): - for f in CRITICAL_FAULTS: - self.client.publish(f"VCU/Faults/Critical/{f}", 1.0 if self.faults[f] else 0.0) - for f in NON_CRITICAL_FAULTS: - self.client.publish(f"VCU/Faults/Non-Critical/{f}", 1.0 if self.faults[f] else 0.0) - - def tick(self): - """One 4Hz heartbeat — all topics get refreshed.""" - self.publish_carstate() - self.publish_pedals() - self.publish_faults() - - # --- state machine (mirrors Core/Src/u_statemachine.c) ---- - def boot(self): - self.functional = FuncState.READY - self.nero_index = NeroMenu.OFF - self.home_mode = True - self.accel_norm = 0.0 - self.brake_norm = 0.0 - self.tsms = False - self.speed_mph = 0.0 - for f in self.faults: - self.faults[f] = False - self.tick() - - def menu_increment(self): - nxt = int(self.nero_index) + 1 - self.nero_index = NeroMenu(0 if nxt >= MAX_NERO_STATES else nxt) - self.client.publish("VCU/CarState/nero_index", float(int(self.nero_index))) - - def menu_decrement(self): - nxt = int(self.nero_index) - 1 - if nxt < 0: - nxt = MAX_NERO_STATES - 1 - self.nero_index = NeroMenu(nxt) - self.client.publish("VCU/CarState/nero_index", float(int(self.nero_index))) - - def menu_select(self) -> bool: - """Try to enter the currently-highlighted drive mode. Mirrors - transition_functional_state in u_statemachine.c — only F_PIT, F_PERFORMANCE, - and F_EFFICIENCY require brake-pressed + shutdown-closed; F_REVERSE is gateless.""" - target_map = { - NeroMenu.PIT: FuncState.F_PIT, - NeroMenu.PERFORMANCE: FuncState.F_PERFORMANCE, - NeroMenu.EFFICIENCY: FuncState.F_EFFICIENCY, - } - if self.nero_index == NeroMenu.REVERSE: - self.functional = FuncState.F_REVERSE - elif self.nero_index in target_map: - if self.brake_norm < PEDAL_BRAKE_THRESH: - log.warning("brake not pressed (%.2f < %.2f) — cannot enter drive mode", - self.brake_norm, PEDAL_BRAKE_THRESH) - return False - if not self.tsms: - log.warning("shutdown not closed (tsms=0) — cannot enter drive mode") - return False - self.functional = target_map[self.nero_index] - else: - log.info("nero_index=%s is not a drive mode; no transition", self.nero_index) - return False - self.home_mode = False - self.client.publish("VCU/CarState/home_mode", 0.0) - self.client.publish("VCU/CarState/functional_state", float(int(self.functional))) - self.client.publish("VCU/CarState/not_in_reverse", - 0.0 if self.functional == FuncState.F_REVERSE else 1.0) - return True - - def set_home(self): - if self.functional != FuncState.FAULTED: - self.functional = FuncState.READY - self.home_mode = True - self.client.publish("VCU/CarState/home_mode", 1.0) - self.client.publish("VCU/CarState/functional_state", float(int(self.functional))) - - def set_pedals(self, accel: float, brake: float): - self.accel_norm = max(0.0, min(1.0, accel)) - self.brake_norm = max(0.0, min(1.0, brake)) - self.publish_pedals() - - def set_tsms(self, closed: bool): - self.tsms = closed - self.client.publish("VCU/CarState/tsms", 1.0 if closed else 0.0) - - def set_speed(self, mph: float): - self.speed_mph = mph - self.client.publish("VCU/CarState/speed", mph, unit="mph") - - def trigger_fault(self, name: str): - if name not in self.faults: - raise ValueError(f"unknown fault: {name}") - self.faults[name] = True - # Special case mirrored from transition_functional_state: any fault → - # FAULTED + nero=OFF + home_mode=true - self.functional = FuncState.FAULTED - self.nero_index = NeroMenu.OFF - self.home_mode = True - critical = name in CRITICAL_FAULTS - topic = f"VCU/Faults/{'Critical' if critical else 'Non-Critical'}/{name}" - self.client.publish(topic, 1.0) - self.client.publish("VCU/CarState/functional_state", float(int(self.functional))) - self.client.publish("VCU/CarState/nero_index", float(int(self.nero_index))) - self.client.publish("VCU/CarState/home_mode", 1.0) - - def clear_faults(self): - for f in self.faults: - self.faults[f] = False - self.publish_faults() - self.functional = FuncState.READY - self.client.publish("VCU/CarState/functional_state", float(int(self.functional))) - - -# ---------- Scenarios ------------------------------------------------------ - -def s1_boot(vcu: VcuMock, obs: BrokerObserver): - log.info("S1: boot — claim all VCU topics, publish initial state") - vcu.claim_all() - overrides = {o["topic"]: o["owner"] for o in vcu.client.status()} - for t in vcu.all_owned_topics: - assert overrides.get(t) == "stream", f"claim missing for {t}" - vcu.boot() - obs.assert_published("VCU/CarState/home_mode", 1.0) - obs.assert_published("VCU/CarState/nero_index", 0.0) - obs.assert_published("VCU/CarState/functional_state", 0.0) - obs.assert_published("VCU/Pedals/Percentages/acceleration_pedal", 0.0) - obs.assert_published("VCU/Faults/Non-Critical/BSPD_PREFAULT", 0.0) - log.info("S1 ✓ %d/%d owned topics, boot state observed on broker", - len(vcu.all_owned_topics), len(vcu.all_owned_topics)) - - -def s2_menu_nav(vcu: VcuMock, obs: BrokerObserver): - log.info("S2: menu navigation 0→7→0") - expected = [1, 2, 3, 4, 5, 6, 7, 0] - for want in expected: - vcu.menu_increment() - obs.assert_published("VCU/CarState/nero_index", float(want)) - time.sleep(0.2) - log.info("S2 ✓ wrap at MAX_NERO_STATES verified") - - -def s3_enter_pit(vcu: VcuMock, obs: BrokerObserver): - log.info("S3: enter PIT — gate on brake + tsms") - # Try to select PIT without brake — should be rejected by VcuMock - vcu.menu_increment() # 0 → 1 (PIT) - obs.assert_published("VCU/CarState/nero_index", 1.0) - assert not vcu.menu_select(), "menu_select should fail without brake" - assert vcu.functional == FuncState.READY, "functional should not transition" - # Press brake, close tsms, retry - vcu.set_pedals(0.0, 0.30) - obs.assert_published("VCU/Pedals/Percentages/brake_pedal", 0.30) - vcu.set_tsms(True) - obs.assert_published("VCU/CarState/tsms", 1.0) - assert vcu.menu_select(), "menu_select should succeed once gated conditions met" - obs.assert_published("VCU/CarState/home_mode", 0.0) - obs.assert_published("VCU/CarState/functional_state", float(int(FuncState.F_PIT))) - log.info("S3 ✓ brake+tsms gate enforced, F_PIT entered") - - -def s4_drive_sweep(vcu: VcuMock, obs: BrokerObserver, duration_s: float = 8.0): - log.info("S4: drive telemetry sweep (%.0fs in F_PIT)", duration_s) - assert vcu.functional == FuncState.F_PIT, "must be in F_PIT for S4" - start = time.monotonic() - last_tick = start - samples = 0 - while time.monotonic() - start < duration_s: - t = time.monotonic() - start - accel = max(0.0, math.sin(t * math.pi / duration_s)) - vcu.set_pedals(accel, 0.0) - # PIT speed limit: scale linearly with accel, capped at PIT_MAX_SPEED - vcu.set_speed(min(PIT_MAX_SPEED, accel * PIT_MAX_SPEED * 1.2)) - # heartbeat the rest of the carstate every 250ms - if time.monotonic() - last_tick > 0.25: - vcu.publish_carstate() - vcu.publish_faults() - last_tick = time.monotonic() - samples += 1 - time.sleep(0.05) - # Verify the sweep peaked near 1.0 and speed tracked it - a = obs.get("VCU/Pedals/Percentages/acceleration_pedal") - s = obs.get("VCU/CarState/speed") - assert a and s, "expected last-sample data on broker" - log.info("S4 ✓ %d samples, last accel=%.2f speed=%.2f mph (PIT cap=%.1f)", - samples, a.values[0], s.values[0], PIT_MAX_SPEED) - - -def s4b_owner_isolation(vcu: VcuMock, obs: BrokerObserver, with_auto: bool): - if not with_auto: - log.info("S4b: skip (run with --with-auto to verify autonomous isolation)") - return - log.info("S4b: verify autonomous heartbeat does NOT republish claimed topics") - topic = "VCU/CarState/torque_limit_percentage" - vcu.client.publish(topic, 0.42) - obs.assert_published(topic, 0.42) - cutoff = time.monotonic() - obs.assert_owner_only_us(topic, since=cutoff, max_age_ms=1500) - log.info("S4b ✓ %s held its claimed value across 1.5s of autonomous ticks", topic) - - -def s5_enter_reverse(vcu: VcuMock, obs: BrokerObserver): - log.info("S5: enter REVERSE") - # Per Cerberus, the driver returns to home_mode (which transitions active→READY) - # and then scrolls to REVERSE in the menu and selects it. F_REVERSE has no - # brake/tsms gate in transition_functional_state. - vcu.set_home() - obs.assert_published("VCU/CarState/home_mode", 1.0) - obs.assert_published("VCU/CarState/functional_state", float(int(FuncState.READY))) - while vcu.nero_index != NeroMenu.REVERSE: - vcu.menu_increment() - time.sleep(0.05) - obs.assert_published("VCU/CarState/nero_index", float(int(NeroMenu.REVERSE))) - assert vcu.menu_select(), "REVERSE select failed" - obs.assert_published("VCU/CarState/functional_state", float(int(FuncState.F_REVERSE))) - obs.assert_published("VCU/CarState/not_in_reverse", 0.0) - log.info("S5 ✓ F_REVERSE entered, not_in_reverse=0") - - -def s6_fault_recovery(vcu: VcuMock, obs: BrokerObserver): - log.info("S6: fault & recovery (BSPD_PREFAULT)") - vcu.trigger_fault("BSPD_PREFAULT") - obs.assert_published("VCU/Faults/Non-Critical/BSPD_PREFAULT", 1.0) - obs.assert_published("VCU/CarState/functional_state", float(int(FuncState.FAULTED))) - obs.assert_published("VCU/CarState/nero_index", 0.0) - obs.assert_published("VCU/CarState/home_mode", 1.0) - vcu.clear_faults() - obs.assert_published("VCU/Faults/Non-Critical/BSPD_PREFAULT", 0.0) - obs.assert_published("VCU/CarState/functional_state", float(int(FuncState.READY))) - log.info("S6 ✓ fault → FAULTED+OFF+home, recovered to READY") - - -SCENARIOS: dict[str, callable] = { - "S1": s1_boot, - "S2": s2_menu_nav, - "S3": s3_enter_pit, - "S4": s4_drive_sweep, - "S4b": s4b_owner_isolation, - "S5": s5_enter_reverse, - "S6": s6_fault_recovery, -} - - -# ---------- main ----------------------------------------------------------- - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--broker", default="localhost:1883") - ap.add_argument("--with-auto", action="store_true", - help="run autonomous heartbeat alongside stream") - ap.add_argument("--only", default="", - help="comma-separated list of scenario IDs to run (default: all)") - ap.add_argument("--debug", action="store_true") - args = ap.parse_args() - - logging.basicConfig( - level=logging.DEBUG if args.debug else logging.INFO, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - datefmt="%H:%M:%S", - ) - host, _, port_s = args.broker.partition(":") - port = int(port_s or "1883") - selected = [s.strip() for s in args.only.split(",") if s.strip()] or list(SCENARIOS) - - obs = BrokerObserver(host, port) - obs.start() - client = StreamClient(args.broker, with_auto=args.with_auto) - try: - # quick sanity ping - assert client.ping(), "stream did not respond to ping" - log.info("stream ping ok; broker observer subscribed") - vcu = VcuMock(client) - for sid in selected: - fn = SCENARIOS.get(sid) - if not fn: - log.warning("unknown scenario: %s (have %s)", sid, list(SCENARIOS)) - continue - print() - print(f"========== {sid} =========") - if sid == "S4b": - fn(vcu, obs, args.with_auto) - else: - fn(vcu, obs) - print() - log.info("all scenarios complete") - finally: - with contextlib.suppress(Exception): - if 'vcu' in locals(): - vcu.release_all() - client.close() - obs.stop() - - -if __name__ == "__main__": - main() diff --git a/calypso-sim/src/publish.rs b/calypso-sim/src/publish.rs index ac10436..9c0412b 100644 --- a/calypso-sim/src/publish.rs +++ b/calypso-sim/src/publish.rs @@ -5,14 +5,11 @@ use protobuf::Message; use rumqttc::v5::AsyncClient; use rumqttc::v5::mqttbytes::QoS; -/// Encode a `ServerData` payload and publish it to the broker. Returns the -/// timestamp (microseconds since UNIX epoch) embedded in the payload. -pub async fn publish_data( - client: &AsyncClient, - topic: &str, - unit: &str, - values: &[f32], -) -> Result<u64, String> { +/// Encode a `ServerData` payload for `unit`/`values`, stamped with the current +/// time (microseconds since the UNIX epoch). Returns the serialized bytes and +/// that timestamp. Split out from [`publish_data`] so the encoding can be unit +/// tested without a broker or client. +fn encode_server_data(unit: &str, values: &[f32]) -> Result<(Vec<u8>, u64), String> { let timestamp = UNIX_EPOCH .elapsed() .map(|d| d.as_micros() as u64) @@ -27,6 +24,19 @@ pub async fn publish_data( .write_to_bytes() .map_err(|e| format!("serialize: {e}"))?; + Ok((bytes, timestamp)) +} + +/// Encode a `ServerData` payload and publish it to the broker. Returns the +/// timestamp (microseconds since UNIX epoch) embedded in the payload. +pub async fn publish_data( + client: &AsyncClient, + topic: &str, + unit: &str, + values: &[f32], +) -> Result<u64, String> { + let (bytes, timestamp) = encode_server_data(unit, values)?; + client .publish(topic, QoS::AtMostOnce, false, bytes) .await @@ -34,3 +44,26 @@ pub async fn publish_data( Ok(timestamp) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_round_trips_unit_values_and_timestamp() { + let (bytes, ts) = encode_server_data("mph", &[1.5, -2.0, 3.25]).unwrap(); + let decoded = serverdata::ServerData::parse_from_bytes(&bytes).unwrap(); + assert_eq!(decoded.unit, "mph"); + assert_eq!(decoded.values, vec![1.5, -2.0, 3.25]); + assert_eq!(decoded.time_us, ts); + assert!(ts > 0, "timestamp should be a real epoch time"); + } + + #[test] + fn encode_handles_empty_unit_and_values() { + let (bytes, _) = encode_server_data("", &[]).unwrap(); + let decoded = serverdata::ServerData::parse_from_bytes(&bytes).unwrap(); + assert!(decoded.unit.is_empty()); + assert!(decoded.values.is_empty()); + } +} diff --git a/calypso-sim/src/registry.rs b/calypso-sim/src/registry.rs index daf1bb7..7be9f33 100644 --- a/calypso-sim/src/registry.rs +++ b/calypso-sim/src/registry.rs @@ -80,3 +80,61 @@ impl TopicRegistry { entries } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_topic_defaults_to_auto() { + let reg = TopicRegistry::new(); + assert_eq!(reg.owner("VCU/CarState/speed"), Owner::Auto); + assert!(reg.auto_may_publish("VCU/CarState/speed")); + assert!(reg.driver_may_publish("VCU/CarState/speed")); + } + + #[test] + fn claim_makes_auto_yield_but_keeps_driver() { + let mut reg = TopicRegistry::new(); + let prev = reg.set("T", Owner::Stream); + assert_eq!(prev, Owner::Auto, "previous owner should be reported"); + assert_eq!(reg.owner("T"), Owner::Stream); + // The autonomous heartbeat must yield a claimed topic... + assert!(!reg.auto_may_publish("T")); + // ...while the stream/keymap driver may still publish it. + assert!(reg.driver_may_publish("T")); + } + + #[test] + fn silence_blocks_both_auto_and_driver() { + let mut reg = TopicRegistry::new(); + reg.set("T", Owner::Silenced); + assert!(!reg.auto_may_publish("T")); + assert!(!reg.driver_may_publish("T")); + } + + #[test] + fn releasing_to_auto_clears_the_override() { + let mut reg = TopicRegistry::new(); + reg.set("T", Owner::Stream); + let prev = reg.set("T", Owner::Auto); + assert_eq!(prev, Owner::Stream); + assert_eq!(reg.owner("T"), Owner::Auto); + assert!(reg.snapshot().is_empty(), "auto topics carry no override"); + } + + #[test] + fn snapshot_is_sorted_and_excludes_auto() { + let mut reg = TopicRegistry::new(); + reg.set("beta", Owner::Stream); + reg.set("alpha", Owner::Silenced); + reg.set("gamma", Owner::Auto); // no-op: stays default + assert_eq!( + reg.snapshot(), + vec![ + ("alpha".to_string(), Owner::Silenced), + ("beta".to_string(), Owner::Stream), + ] + ); + } +} diff --git a/calypso-sim/tests/common/mod.rs b/calypso-sim/tests/common/mod.rs new file mode 100644 index 0000000..14e977d --- /dev/null +++ b/calypso-sim/tests/common/mod.rs @@ -0,0 +1,179 @@ +//! Shared test harness: spawns `calypso-sim --stream` and drives its JSON-RPC +//! stdio protocol synchronously (one request, one response). +//! +//! No MQTT broker is required. `AsyncClient::publish` only enqueues, and the +//! sim's eventloop poller retries a missing broker instead of dropping the +//! queue (see `modes::poll_eventloop`), so every `publish` still returns a +//! `ts_us`. A broker is only needed to observe the *bytes on the wire*, which +//! is covered separately by the `encode_server_data` unit test in +//! `src/publish.rs`. +#![allow(dead_code)] // each test file uses a different subset of these helpers. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use serde_json::{Value, json}; + +/// A live `calypso-sim --stream` child process plus its stdio handles. +pub struct StreamHarness { + child: Child, + stdin: ChildStdin, + stdout: BufReader<ChildStdout>, + next_id: i64, +} + +impl StreamHarness { + /// Spawn the sim in stream mode with the autonomous heartbeat off. + pub fn spawn() -> Self { + Self::spawn_inner(false) + } + + /// Spawn with `--auto` so the autonomous heartbeat runs alongside the + /// stream driver (used to exercise ownership isolation). + pub fn spawn_with_auto() -> Self { + Self::spawn_inner(true) + } + + fn spawn_inner(with_auto: bool) -> Self { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_calypso-sim")); + // Point at a closed port: no broker is needed, and this keeps the sim + // from touching any real broker a developer happens to be running. + cmd.arg("-u").arg("127.0.0.1:47654").arg("--stream"); + if with_auto { + cmd.arg("--auto"); + } + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .expect("failed to spawn calypso-sim binary for the stream test"); + let stdin = child.stdin.take().expect("child stdin"); + let stdout = BufReader::new(child.stdout.take().expect("child stdout")); + + // Drain stderr on a background thread so the child never blocks writing + // to a full stderr pipe (it logs an MQTT connection error every ~500ms + // against the closed broker). The thread exits on EOF when we kill it. + let stderr = child.stderr.take().expect("child stderr"); + std::thread::spawn(move || { + let mut reader = BufReader::new(stderr); + let mut line = String::new(); + while reader.read_line(&mut line).is_ok_and(|n| n > 0) { + line.clear(); + } + }); + + StreamHarness { + child, + stdin, + stdout, + next_id: 1, + } + } + + /// Send a raw request object (an `id` is injected) and return the full + /// response object. Lets negative tests craft malformed wire shapes. + pub fn raw(&mut self, mut request: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + request["id"] = json!(id); + + let mut line = serde_json::to_string(&request).expect("serialize request"); + line.push('\n'); + self.stdin + .write_all(line.as_bytes()) + .expect("write request"); + self.stdin.flush().expect("flush request"); + + let mut response = String::new(); + let n = self + .stdout + .read_line(&mut response) + .expect("read response line"); + assert!(n > 0, "sim closed stdout before responding"); + serde_json::from_str(response.trim()) + .unwrap_or_else(|e| panic!("non-JSON response {response:?}: {e}")) + } + + /// Call `method` with `params`, returning the `result` on success or + /// `(code, message)` on a JSON-RPC error. + pub fn call(&mut self, method: &str, params: Value) -> Result<Value, (i64, String)> { + let mut request = json!({"jsonrpc": "2.0", "method": method}); + request["params"] = params; // moves `params` into the request object + let resp = self.raw(request); + if let Some(err) = resp.get("error").filter(|e| !e.is_null()) { + let code = err["code"].as_i64().unwrap_or(0); + let message = err["message"].as_str().unwrap_or_default().to_string(); + return Err((code, message)); + } + Ok(resp.get("result").cloned().unwrap_or(Value::Null)) + } + + /// Call `method`, panicking on any JSON-RPC error (for the success path). + fn expect_ok(&mut self, method: &str, params: Value) -> Value { + self.call(method, params) + .unwrap_or_else(|(code, msg)| panic!("{method} failed: [{code}] {msg}")) + } + + pub fn ping(&mut self) -> bool { + self.expect_ok("ping", json!({}))["ok"] + .as_bool() + .unwrap_or(false) + } + + pub fn list_topics(&mut self) -> Vec<Value> { + self.expect_ok("list_topics", json!({}))["topics"] + .as_array() + .cloned() + .unwrap_or_default() + } + + pub fn publish(&mut self, topic: &str, value: f64) -> Value { + self.expect_ok("publish", json!({"topic": topic, "value": value})) + } + + pub fn publish_unit(&mut self, topic: &str, value: f64, unit: &str) -> Value { + self.expect_ok( + "publish", + json!({"topic": topic, "value": value, "unit": unit}), + ) + } + + pub fn claim(&mut self, topic: &str) -> Value { + self.expect_ok("claim", json!({"topic": topic})) + } + + pub fn release(&mut self, topic: &str) -> Value { + self.expect_ok("release", json!({"topic": topic})) + } + + pub fn silence(&mut self, topic: &str) -> Value { + self.expect_ok("silence", json!({"topic": topic})) + } + + pub fn status(&mut self) -> Vec<Value> { + self.expect_ok("status", json!({}))["overrides"] + .as_array() + .cloned() + .unwrap_or_default() + } + + /// The owner string for `topic` per the current `status`, or `None` when + /// the topic carries no override (i.e. still `auto`). + pub fn owner_of(&mut self, topic: &str) -> Option<String> { + self.status() + .into_iter() + .find(|o| o["topic"] == json!(topic)) + .map(|o| o["owner"].as_str().unwrap_or_default().to_string()) + } +} + +impl Drop for StreamHarness { + fn drop(&mut self) { + // The test is done with the child; kill and reap it so no sim process + // (or its stderr-drain thread) lingers past the test. + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} diff --git a/calypso-sim/tests/protocol.rs b/calypso-sim/tests/protocol.rs new file mode 100644 index 0000000..23db37c --- /dev/null +++ b/calypso-sim/tests/protocol.rs @@ -0,0 +1,83 @@ +//! JSON-RPC protocol conformance for `calypso-sim --stream`. Spawns the real +//! binary and checks the wire contract — no broker required. +mod common; + +use common::StreamHarness; +use serde_json::json; + +#[test] +fn ping_responds_ok() { + let mut sim = StreamHarness::spawn(); + assert!(sim.ping(), "ping should return {{ok: true}}"); +} + +#[test] +fn list_topics_is_nonempty_and_well_formed() { + let mut sim = StreamHarness::spawn(); + let topics = sim.list_topics(); + assert!( + !topics.is_empty(), + "the CAN spec should yield at least one simulatable topic" + ); + for topic in &topics { + assert!( + topic.get("name").and_then(|v| v.as_str()).is_some(), + "topic entry missing string `name`: {topic}" + ); + assert!( + topic.get("unit").is_some(), + "topic entry missing `unit`: {topic}" + ); + } +} + +#[test] +fn publish_returns_a_timestamp() { + let mut sim = StreamHarness::spawn(); + let result = sim.publish("VCU/CarState/speed", 12.5); + assert!( + result["ts_us"].as_u64().unwrap_or(0) > 0, + "publish should echo a microsecond timestamp, got {result}" + ); +} + +#[test] +fn publish_without_a_value_is_invalid_params() { + let mut sim = StreamHarness::spawn(); + let (code, _) = sim + .call("publish", json!({"topic": "VCU/CarState/speed"})) + .expect_err("publish with no value/values must error"); + assert_eq!(code, -32602, "expected Invalid params"); +} + +#[test] +fn publish_with_both_value_and_values_is_invalid_params() { + let mut sim = StreamHarness::spawn(); + let (code, _) = sim + .call( + "publish", + json!({"topic": "X", "value": 1.0, "values": [1.0, 2.0]}), + ) + .expect_err("value + values together must error"); + assert_eq!(code, -32602); +} + +#[test] +fn unknown_method_is_method_not_found() { + let mut sim = StreamHarness::spawn(); + let (code, _) = sim + .call("frobnicate", json!({})) + .expect_err("unknown method must error"); + assert_eq!(code, -32601); +} + +#[test] +fn wrong_jsonrpc_version_is_invalid_request() { + let mut sim = StreamHarness::spawn(); + let resp = sim.raw(json!({"jsonrpc": "2.1", "method": "ping", "params": {}})); + assert_eq!( + resp["error"]["code"].as_i64(), + Some(-32600), + "jsonrpc != 2.0 must be rejected, got {resp}" + ); +} diff --git a/calypso-sim/tests/scenarios.rs b/calypso-sim/tests/scenarios.rs new file mode 100644 index 0000000..b580237 --- /dev/null +++ b/calypso-sim/tests/scenarios.rs @@ -0,0 +1,378 @@ +//! Scenario tests that mimic what the Cerberus-2.0 VCU firmware +//! (`Core/Src/u_statemachine.c`) publishes, driving `calypso-sim --stream`. +//! +//! Ported from the former `scripts/vcu_mimic.py`. This first cut covers the +//! parts verifiable without a broker: topic **ownership** (claim / status / +//! silence / release) and the `VcuMock` **state machine** (the brake + shutdown +//! gate on entering a drive mode). The end-to-end "these exact values landed on +//! the topic" checks — Python's `assert_published` — are the one slice that +//! needs a real broker and are left for a follow-up; value *encoding* itself is +//! covered by the `encode_server_data` unit test in `src/publish.rs`. +#![allow(dead_code)] // full firmware enums are mirrored; not every variant is exercised yet. +mod common; + +use std::collections::HashMap; + +use common::StreamHarness; + +// --- constants mirrored from Cerberus-2.0 --------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum FuncState { + Ready = 0, + FPit = 1, + FReverse = 2, + FPerformance = 3, + FEfficiency = 4, + Faulted = 5, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum NeroMenu { + Off = 0, + Pit = 1, + Reverse = 2, + Performance = 3, + Efficiency = 4, + Games = 5, + Themes = 6, + Exit = 7, +} + +const MAX_NERO_STATES: u8 = 8; +const PEDAL_BRAKE_THRESH: f64 = 0.12; + +// Pedal calibration from Cerberus-2.0 Core/Src/u_pedals.c +const MIN_APPS1_V: f64 = 2.15; +const MAX_APPS1_V: f64 = 3.12; +const MIN_APPS2_V: f64 = 1.15; +const MAX_APPS2_V: f64 = 2.01; +const MIN_BRAKE1_V: f64 = 0.5; +const MAX_BRAKE1_V: f64 = 1.5; +const MIN_BRAKE2_V: f64 = 0.5; +const MAX_BRAKE2_V: f64 = 1.5; + +const CRITICAL_FAULTS: &[&str] = &[ + "CAN_OUTGOING_FAULT", + "CAN_INCOMING_FAULT", + "BMS_CAN_MONITOR_FAULT", + "LIGHTNING_CAN_MONITOR_FAULT", + "SHUTDOWN_FAULT", +]; +const NON_CRITICAL_FAULTS: &[&str] = &[ + "ONBOARD_TEMP_FAULT", + "IMU_ACCEL_FAULT", + "IMU_GYRO_FAULT", + "BSPD_PREFAULT", + "ONBOARD_BRAKE_OPEN_CIRCUIT_FAULT", + "ONBOARD_ACCEL_OPEN_CIRCUIT_FAULT", + "ONBOARD_BRAKE_SHORT_CIRCUIT_FAULT", + "ONBOARD_ACCEL_SHORT_CIRCUIT_FAULT", + "ONBOARD_PEDAL_DIFFERENCE_FAULT", + "RTDS_FAULT", + "LV_LOW_VOLTAGE_FAULT", +]; + +/// Fixed VCU CarState/Pedals topics the mock owns (fault topics are appended — +/// see [`VcuMock::owned_topics`]). +const CARSTATE_PEDAL_TOPICS: &[&str] = &[ + "VCU/CarState/home_mode", + "VCU/CarState/nero_index", + "VCU/CarState/speed", + "VCU/CarState/tsms", + "VCU/CarState/torque_limit_percentage", + "VCU/CarState/not_in_reverse", + "VCU/CarState/regen_limit", + "VCU/CarState/launch_control", + "VCU/CarState/functional_state", + "VCU/CarState/traction_control", + "VCU/Pedals/Percentages/acceleration_pedal", + "VCU/Pedals/Percentages/brake_pedal", + "VCU/Pedals/Voltages/accel_1", + "VCU/Pedals/Voltages/accel_2", + "VCU/Pedals/Voltages/brake_1", + "VCU/Pedals/Voltages/brake_2", +]; + +/// Firmware publishes booleans as 1.0 / 0.0 floats. +fn bit(flag: bool) -> f64 { + if flag { 1.0 } else { 0.0 } +} + +// --- VcuMock --------------------------------------------------------------- + +/// A minimal mirror of the VCU state machine that drives the sim over the +/// stream protocol. Owns the spawned sim in `sim`. +struct VcuMock { + sim: StreamHarness, + functional: FuncState, + nero_index: u8, + home_mode: bool, + accel: f64, + brake: f64, + tsms: bool, + speed: f64, +} + +impl VcuMock { + fn new(sim: StreamHarness) -> Self { + VcuMock { + sim, + functional: FuncState::Ready, + nero_index: NeroMenu::Off as u8, + home_mode: true, + accel: 0.0, + brake: 0.0, + tsms: false, + speed: 0.0, + } + } + + /// Every topic the mock publishes, in claim order. + fn owned_topics() -> Vec<String> { + let mut out: Vec<String> = CARSTATE_PEDAL_TOPICS + .iter() + .map(|t| (*t).to_string()) + .collect(); + for f in CRITICAL_FAULTS { + out.push(format!("VCU/Faults/Critical/{f}")); + } + for f in NON_CRITICAL_FAULTS { + out.push(format!("VCU/Faults/Non-Critical/{f}")); + } + out + } + + fn claim_all(&mut self) { + for topic in Self::owned_topics() { + self.sim.claim(&topic); + } + } + + fn publish_carstate(&mut self) { + self.sim + .publish("VCU/CarState/home_mode", bit(self.home_mode)); + self.sim + .publish("VCU/CarState/nero_index", f64::from(self.nero_index)); + self.sim + .publish_unit("VCU/CarState/speed", self.speed, "mph"); + self.sim.publish("VCU/CarState/tsms", bit(self.tsms)); + self.sim + .publish("VCU/CarState/torque_limit_percentage", 1.0); + self.sim.publish( + "VCU/CarState/not_in_reverse", + bit(self.functional != FuncState::FReverse), + ); + self.sim.publish_unit("VCU/CarState/regen_limit", 50.0, "A"); + self.sim.publish("VCU/CarState/launch_control", 0.0); + self.sim.publish( + "VCU/CarState/functional_state", + f64::from(self.functional as u8), + ); + self.sim.publish("VCU/CarState/traction_control", 0.0); + } + + fn publish_pedals(&mut self) { + let (a, b) = (self.accel, self.brake); + self.sim + .publish("VCU/Pedals/Percentages/acceleration_pedal", a); + self.sim.publish("VCU/Pedals/Percentages/brake_pedal", b); + self.sim.publish_unit( + "VCU/Pedals/Voltages/accel_1", + MIN_APPS1_V + a * (MAX_APPS1_V - MIN_APPS1_V), + "V", + ); + self.sim.publish_unit( + "VCU/Pedals/Voltages/accel_2", + MIN_APPS2_V + a * (MAX_APPS2_V - MIN_APPS2_V), + "V", + ); + self.sim.publish_unit( + "VCU/Pedals/Voltages/brake_1", + MIN_BRAKE1_V + b * (MAX_BRAKE1_V - MIN_BRAKE1_V), + "V", + ); + self.sim.publish_unit( + "VCU/Pedals/Voltages/brake_2", + MIN_BRAKE2_V + b * (MAX_BRAKE2_V - MIN_BRAKE2_V), + "V", + ); + } + + fn publish_faults(&mut self) { + for f in CRITICAL_FAULTS { + self.sim.publish(&format!("VCU/Faults/Critical/{f}"), 0.0); + } + for f in NON_CRITICAL_FAULTS { + self.sim + .publish(&format!("VCU/Faults/Non-Critical/{f}"), 0.0); + } + } + + /// Reset to power-on state and publish a full frame. + fn boot(&mut self) { + self.functional = FuncState::Ready; + self.nero_index = NeroMenu::Off as u8; + self.home_mode = true; + self.accel = 0.0; + self.brake = 0.0; + self.tsms = false; + self.speed = 0.0; + self.publish_carstate(); + self.publish_pedals(); + self.publish_faults(); + } + + fn menu_increment(&mut self) { + let next = self.nero_index + 1; + self.nero_index = if next >= MAX_NERO_STATES { 0 } else { next }; + self.sim + .publish("VCU/CarState/nero_index", f64::from(self.nero_index)); + } + + fn set_pedals(&mut self, accel: f64, brake: f64) { + self.accel = accel.clamp(0.0, 1.0); + self.brake = brake.clamp(0.0, 1.0); + self.publish_pedals(); + } + + fn set_tsms(&mut self, closed: bool) { + self.tsms = closed; + self.sim.publish("VCU/CarState/tsms", bit(closed)); + } + + /// Try to enter the highlighted drive mode. Mirrors + /// `transition_functional_state`: PIT / PERFORMANCE / EFFICIENCY require + /// brake pressed + shutdown closed; REVERSE is gateless. Returns whether a + /// transition happened. + fn menu_select(&mut self) -> bool { + let target = match self.nero_index { + x if x == NeroMenu::Pit as u8 => Some(FuncState::FPit), + x if x == NeroMenu::Performance as u8 => Some(FuncState::FPerformance), + x if x == NeroMenu::Efficiency as u8 => Some(FuncState::FEfficiency), + x if x == NeroMenu::Reverse as u8 => Some(FuncState::FReverse), + _ => None, + }; + let Some(target) = target else { + return false; + }; + // REVERSE is gateless; the others need brake + shutdown closed. + if target != FuncState::FReverse && (self.brake < PEDAL_BRAKE_THRESH || !self.tsms) { + return false; + } + self.functional = target; + self.home_mode = false; + self.sim.publish("VCU/CarState/home_mode", 0.0); + self.sim.publish( + "VCU/CarState/functional_state", + f64::from(self.functional as u8), + ); + self.sim.publish( + "VCU/CarState/not_in_reverse", + bit(self.functional != FuncState::FReverse), + ); + true + } +} + +// --- scenarios ------------------------------------------------------------- + +/// S1: boot — claim every VCU topic, confirm the registry marks them all +/// `stream`-owned, then publish the initial frame (each publish must be +/// accepted). +#[test] +fn s1_boot_claims_all_topics_and_publishes_initial_frame() { + let mut vcu = VcuMock::new(StreamHarness::spawn()); + vcu.claim_all(); + + let owners: HashMap<String, String> = vcu + .sim + .status() + .into_iter() + .map(|o| { + ( + o["topic"].as_str().unwrap_or_default().to_string(), + o["owner"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect(); + for topic in VcuMock::owned_topics() { + assert_eq!( + owners.get(&topic).map(String::as_str), + Some("stream"), + "claim missing for {topic}" + ); + } + + // A full boot frame publishes cleanly (publish() panics on any RPC error). + vcu.boot(); +} + +/// S3: entering PIT is gated on brake pressed + shutdown closed. Mirrors +/// `transition_functional_state`. Verifiable without a broker because the gate +/// lives in the mock's state machine. +#[test] +fn s3_enter_pit_is_gated_on_brake_and_shutdown() { + let mut vcu = VcuMock::new(StreamHarness::spawn()); + vcu.claim_all(); + + vcu.menu_increment(); // OFF -> PIT + assert_eq!(vcu.nero_index, NeroMenu::Pit as u8); + + // No brake, shutdown open: rejected, no transition. + assert!( + !vcu.menu_select(), + "PIT must be refused without brake + shutdown" + ); + assert_eq!(vcu.functional, FuncState::Ready); + + // Press brake and close shutdown: now it enters. + vcu.set_pedals(0.0, 0.30); + vcu.set_tsms(true); + assert!( + vcu.menu_select(), + "PIT should enter once gated conditions met" + ); + assert_eq!(vcu.functional, FuncState::FPit); + assert!(!vcu.home_mode, "entering a drive mode leaves home_mode"); +} + +/// S4b: ownership isolation. With the autonomous heartbeat running, a claimed +/// topic is `stream`-owned (so `auto_may_publish` is false and the heartbeat +/// yields), a silenced topic rejects even driver publishes, and releasing +/// restores them. The end-to-end "heartbeat didn't republish" observation +/// needs a broker and is deferred. +#[test] +fn s4b_ownership_isolation_through_claim_silence_release() { + let mut vcu = VcuMock::new(StreamHarness::spawn_with_auto()); + let topic = "VCU/CarState/torque_limit_percentage"; + + vcu.sim.claim(topic); + assert_eq!( + vcu.sim.owner_of(topic).as_deref(), + Some("stream"), + "claim must mark the topic stream-owned so autonomous yields" + ); + assert!( + vcu.sim.publish(topic, 0.42)["ts_us"].as_u64().unwrap_or(0) > 0, + "driver publish accepted while claimed" + ); + + vcu.sim.silence(topic); + assert_eq!( + vcu.sim.publish(topic, 0.99)["skipped"].as_str(), + Some("silenced"), + "silenced topics reject even the driver" + ); + + vcu.sim.release(topic); + assert_eq!( + vcu.sim.owner_of(topic), + None, + "release returns the topic to auto (no override)" + ); + assert!( + vcu.sim.publish(topic, 0.5)["ts_us"].as_u64().unwrap_or(0) > 0, + "driver publish accepted again after release" + ); +} From 5b5a79c53b79e080ba6cc28b70dc16a5c38daedc Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Tue, 7 Jul 2026 12:01:06 -0400 Subject: [PATCH 11/29] #305 - add path-filtered CI for the calypso-sim crate --- .github/workflows/calypso-sim-ci.yml | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/calypso-sim-ci.yml diff --git a/.github/workflows/calypso-sim-ci.yml b/.github/workflows/calypso-sim-ci.yml new file mode 100644 index 0000000..737968e --- /dev/null +++ b/.github/workflows/calypso-sim-ci.yml @@ -0,0 +1,42 @@ +name: calypso-sim CI + +# calypso-sim is a detached workspace, so the top-level `cargo *--all` in +# rust-ci.yml never reaches it. Build/check it here, but only when the sim +# crate — or a path-dependency it pulls in (the daedalus/calypso-cangen libs, +# or the CAN spec submodule) — actually changes, to avoid running on every PR. +on: + push: + branches: + - main + - Develop + paths: &sim-paths + - 'calypso-sim/**' + - 'libs/daedalus/**' + - 'libs/calypso-cangen/**' + - 'Odyssey-Definitions' + - '.github/workflows/calypso-sim-ci.yml' + pull_request: + branches: + - main + - Develop + paths: *sim-paths + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: calypso-sim + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + submodules: recursive + - name: Build + run: cargo build --all --verbose + - name: Test + run: cargo test --verbose + - name: Fmt + run: cargo fmt --all --check + - name: Clippy + run: cargo clippy --verbose --all -- -D warnings From 24c575ddc8f64572773f5fb622cc98b0ca089cea Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Tue, 7 Jul 2026 12:01:06 -0400 Subject: [PATCH 12/29] #305 - preserve the simulate entry point when building standalone calypso-sim --- Dockerfile | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index ae8bd1c..fa910e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,15 +4,21 @@ WORKDIR /usr/src/calypso COPY . . RUN git submodule update --init -RUN apt-get update && apt-get install -y libssl-dev build-essential cmake +RUN apt-get update && apt-get install -y libssl-dev build-essential cmake RUN cargo install --path . +# The simulator was extracted into the standalone `calypso-sim` crate (its own +# detached workspace), so build it separately and install its binary as +# `simulate` to preserve the historical entry point that downstream compose +# files invoke (Argos `compose.calypso.yml` relies on the default CMD below; +# Argos `compose.calypso.debug.sim.yml` and Nero use `command: ["simulate"]`). +RUN cargo install --path ./calypso-sim FROM debian:bookworm-slim RUN apt update RUN apt install openssl -y COPY --from=builder /usr/local/cargo/bin/calypso /usr/local/bin/calypso -COPY --from=builder /usr/local/cargo/bin/simulate /usr/local/bin/simulate +COPY --from=builder /usr/local/cargo/bin/calypso-sim /usr/local/bin/simulate CMD ["simulate"] From d44f3d11b6f5e0824d570e3b0cc43bb4c7142f8e Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Tue, 7 Jul 2026 12:01:06 -0400 Subject: [PATCH 13/29] #305 - simplify calypso-sim keymap randomization + main shutdown drain, drop unused root serde dep --- Cargo.toml | 1 - calypso-sim/src/keymap.rs | 47 +++++++++++---------------------------- calypso-sim/src/main.rs | 12 ++++++++-- 3 files changed, 23 insertions(+), 37 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8c08301..1ea44e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,6 @@ tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["ansi", "env-filter"] } tokio-util = { version = "0.7.16", features = ["full"] } can-dbc = "9.1.0" -serde.workspace = true serde_json.workspace = true schemars.workspace = true num_enum = "0.7.5" diff --git a/calypso-sim/src/keymap.rs b/calypso-sim/src/keymap.rs index 33c6b99..a7b719e 100644 --- a/calypso-sim/src/keymap.rs +++ b/calypso-sim/src/keymap.rs @@ -4,7 +4,6 @@ use std::time::Duration; use crate::simulatable_message::{SimComponent, SimValue}; use crate::simulate_data::create_simulated_components; -use rand::prelude::*; use rumqttc::v5::AsyncClient; use serde::Deserialize; @@ -52,8 +51,9 @@ pub async fn claim_keymap_topics(states: &HashMap<char, KeyState>, registry: &Sh /// list. /// * Object with `step` — increment mode. Publishes `value` (or `min`, or 0) /// on first press, then advances by `step` each press, wrapping -/// independently when each bound is supplied. `unit` falls back to the sim -/// component's unit if the topic is known. +/// independently when each bound is supplied. As with pinned mode, a known +/// topic uses the sim component's unit; the `unit` field applies only to +/// topics not in the generated simulated-components list. /// * Object with `sequence` — publishes a scripted series of (topic, value) /// pairs on each keypress, with optional per-step `delay_ms` before publish. /// @@ -250,39 +250,18 @@ pub fn build_topic_states(key_map: HashMap<char, KeyEntry>) -> HashMap<char, Key result } -/// Generate a fresh random value within each point's defined bounds. +/// Generate a fresh random value within each point's defined bounds. Delegates +/// to `SimValue::initialize` (which ignores any `default` and always +/// randomizes), then clamps `Range` values back into `[min, max]` in case the +/// inc/round snapping pushed them just outside. pub fn randomize_component(component: &mut SimComponent) { - let mut rng = rand::rng(); for point in &mut component.points { - match &mut point.value { - SimValue::Range { - min, - max, - inc_min, - round, - current, - .. - } => { - if (*max - *min).abs() < f32::EPSILON { - *current = *min; - } else { - *current = rng.random_range(*min..*max); - } - if *inc_min != 0.0 { - *current = (*current / *inc_min).round() * *inc_min; - } - if *round { - *current = current.round(); - } - *current = current.clamp(*min, *max); - } - SimValue::Discrete { - options, current, .. - } => { - if let Some(&(v, _)) = options.choose(&mut rng) { - *current = v; - } - } + point.value.initialize(); + if let SimValue::Range { + min, max, current, .. + } = &mut point.value + { + *current = current.clamp(*min, *max); } } } diff --git a/calypso-sim/src/main.rs b/calypso-sim/src/main.rs index 3e604ec..eca6820 100644 --- a/calypso-sim/src/main.rs +++ b/calypso-sim/src/main.rs @@ -67,6 +67,13 @@ async fn main() { let foreground = run_foreground(&cli, &token, &client, ®istry).await; + // Let the MQTT eventloop drain any just-enqueued publishes before we cancel + // it. `AsyncClient::publish` only enqueues; the eventloop's `poll()` is what + // writes to the socket. Cancelling first drops the eventloop with the queue + // unflushed — best-effort for QoS0, but this lets the last messages of a + // clean stream-EOF / Ctrl+C shutdown actually land. + tokio::time::sleep(Duration::from_millis(50)).await; + token.cancel(); if let Some(h) = auto_handle && let Err(e) = h.await @@ -76,7 +83,6 @@ async fn main() { if let Err(e) = poll_handle.await { tracing::error!("MQTT eventloop task panicked: {e}"); } - tokio::time::sleep(Duration::from_millis(50)).await; if let Err(err) = foreground { eprintln!("Error: {err}"); @@ -93,10 +99,12 @@ async fn run_foreground( if cli.stream { modes::stream::run(token.clone(), client.clone(), registry.clone()).await } else if let Some(script_path) = &cli.script { + // clap enforces `--script requires --key-map` (see cli.rs), so a missing + // key map here is an impossible state, not a reachable runtime error. let key_map_path = cli .key_map .as_deref() - .ok_or_else(|| "--script requires --key-map".to_string())?; + .expect("clap enforces --script requires --key-map"); modes::auto_script::run(client.clone(), key_map_path, script_path, registry.clone()).await } else if let Some(key_map_path) = &cli.key_map { modes::interactive::run( From 377753be4a3d940b9a5953535a20bfb31584f1a9 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Tue, 7 Jul 2026 12:38:35 -0400 Subject: [PATCH 14/29] #305 - add keymap + CLI unit tests, prune the obvious encode test Cover the real, previously-untested logic: - keymap: advance_increment wrap/saturate semantics, the serde untagged entry disambiguation (step beats value; sequence wins), and build_topic_states skip/keep rules - cli: run_autonomous mode arbitration Drop encode_handles_empty_unit_and_values (it exercised protobuf, not our code; the field-mapping round-trip stays). Reframe the README + scenarios e2e notes from a "planned follow-up" to intentionally out of scope, since Siren in the compose stack covers real bytes-on-the-wire. --- calypso-sim/README.md | 4 +- calypso-sim/src/cli.rs | 28 +++++++ calypso-sim/src/keymap.rs | 143 +++++++++++++++++++++++++++++++++ calypso-sim/src/publish.rs | 8 -- calypso-sim/tests/scenarios.rs | 9 ++- 5 files changed, 179 insertions(+), 13 deletions(-) diff --git a/calypso-sim/README.md b/calypso-sim/README.md index 9c17ba0..9539d1d 100644 --- a/calypso-sim/README.md +++ b/calypso-sim/README.md @@ -112,10 +112,12 @@ cargo test | Layer | Where | What it checks | |---|---|---| | Unit — ownership | `src/registry.rs` | The `auto` / `stream` / `silenced` state machine: a claim makes the heartbeat yield, silence blocks everyone, release restores `auto`. | +| Unit — keymap | `src/keymap.rs` | Increment wrap/saturate semantics (`advance_increment`), the serde `untagged` entry disambiguation (`step` → increment beats `value` → pinned; `sequence` wins), and the load-time skip rules (unknown topic with no `unit`, empty sequence). | +| Unit — CLI modes | `src/cli.rs` | `run_autonomous` arbitration: heartbeat on by default, off under a foreground mode or `--list-topics`, forced on by explicit `--auto`. | | Unit — encoding | `src/publish.rs` | `ServerData` round-trips: unit, values, and timestamp survive encode → decode. | | Integration — protocol | `tests/protocol.rs` | Spawns the real `calypso-sim --stream` binary and checks the JSON-RPC contract (`ping`, `list_topics`, `publish`, and the `-32600` / `-32601` / `-32602` error paths). | | Integration — scenarios | `tests/scenarios.rs` | A `VcuMock` mirroring Cerberus-2.0's `Core/Src/u_statemachine.c` drives the binary over stdio: boot claims every `VCU/*` topic (S1), the brake + shutdown gate on entering PIT (S3), and ownership isolation via claim / silence / release with the heartbeat running (S4b). | -No broker is needed because `publish` only enqueues (the eventloop retries a missing broker rather than dropping the queue), so it still returns a `ts_us`, and ownership arbitration is answered entirely from the JSON-RPC responses. Confirming the actual *bytes on the wire* end-to-end — the drive-sweep, reverse, and fault-recovery scenarios the former Python harness observed through a subscriber — is the one slice that needs a live broker and is a planned follow-up; value encoding itself is already covered by the `src/publish.rs` round-trip test. +No broker is needed because `publish` only enqueues (the eventloop retries a missing broker rather than dropping the queue), so it still returns a `ts_us`, and ownership arbitration is answered entirely from the JSON-RPC responses. Observing the actual *bytes on the wire* — that a payload reaches a subscriber — needs a live broker, which in practice is **Siren** in the Docker compose stack (see the repo `Dockerfile`); a standalone end-to-end test broker is intentionally out of scope. The behavior such a test would exercise is already covered here: value encoding by the `src/publish.rs` round-trip, and the injection/arbitration logic by the registry, keymap, CLI, and scenario tests. CI (`.github/workflows/calypso-sim-ci.yml`) runs the suite on any change under `calypso-sim/**` or its path-dependencies. diff --git a/calypso-sim/src/cli.rs b/calypso-sim/src/cli.rs index ec8a324..0800534 100644 --- a/calypso-sim/src/cli.rs +++ b/calypso-sim/src/cli.rs @@ -62,3 +62,31 @@ impl Cli { self.auto || (!any_other_mode && !self.list_topics) } } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn cli(args: &[&str]) -> Cli { + Cli::try_parse_from(args).expect("valid args") + } + + #[test] + fn autonomous_runs_by_default_when_no_mode_is_chosen() { + assert!(cli(&["calypso-sim"]).run_autonomous()); + } + + #[test] + fn a_foreground_mode_or_list_disables_the_heartbeat() { + assert!(!cli(&["calypso-sim", "--stream"]).run_autonomous()); + assert!(!cli(&["calypso-sim", "--key-map", "keys.json"]).run_autonomous()); + assert!(!cli(&["calypso-sim", "--list-topics"]).run_autonomous()); + } + + #[test] + fn explicit_auto_forces_the_heartbeat_alongside_a_mode() { + assert!(cli(&["calypso-sim", "--auto"]).run_autonomous()); + assert!(cli(&["calypso-sim", "--stream", "--auto"]).run_autonomous()); + } +} diff --git a/calypso-sim/src/keymap.rs b/calypso-sim/src/keymap.rs index a7b719e..fc848b9 100644 --- a/calypso-sim/src/keymap.rs +++ b/calypso-sim/src/keymap.rs @@ -374,3 +374,146 @@ async fn log_and_publish( } let _ = std::io::stdout().flush(); } + +#[cfg(test)] +mod tests { + use super::*; + + // --- advance_increment: emit-before-advance + wrap semantics ---------- + // + // Float values are compared through `Vec<f32>`/arrays (element-wise + // `PartialEq`) rather than bare `f32 == f32` to stay clear of clippy's + // pedantic `float_cmp`; the values here are exact and deterministic. + + /// Press an increment state `presses` times, collecting the value emitted + /// on each press (increment emits the current value *before* advancing). + fn press_sequence( + start: f32, + step: f32, + min: Option<f32>, + max: Option<f32>, + presses: usize, + ) -> Vec<f32> { + let mut current = start; + (0..presses) + .map(|_| advance_increment(&mut current, step, min, max)) + .collect() + } + + #[test] + fn increment_emits_start_first_then_wraps_at_max() { + // Climb 0,1,2, then wrap past max back to min. + assert_eq!( + press_sequence(0.0, 1.0, Some(0.0), Some(2.0), 5), + vec![0.0, 1.0, 2.0, 0.0, 1.0] + ); + } + + #[test] + fn increment_without_a_min_saturates_at_max() { + // No min to wrap to: once at max it stays there. + assert_eq!( + press_sequence(0.0, 1.0, None, Some(2.0), 5), + vec![0.0, 1.0, 2.0, 2.0, 2.0] + ); + } + + #[test] + fn increment_negative_step_wraps_min_to_max() { + // Counting down past min wraps up to max. + assert_eq!( + press_sequence(2.0, -1.0, Some(0.0), Some(2.0), 5), + vec![2.0, 1.0, 0.0, 2.0, 1.0] + ); + } + + #[test] + fn increment_unbounded_keeps_climbing() { + assert_eq!( + press_sequence(0.0, 5.0, None, None, 3), + vec![0.0, 5.0, 10.0] + ); + } + + // --- parse_key_map: serde `untagged` disambiguation ------------------- + + #[test] + fn parse_disambiguates_the_four_entry_shapes() { + let map = parse_key_map( + r#"{ + "r": "Some/Topic", + "p": {"topic": "T", "value": 1.0}, + "i": {"topic": "T", "value": 0.0, "step": 1.0}, + "s": {"sequence": [{"topic": "T", "value": 1.0}]} + }"#, + ) + .expect("valid keymap"); + + // Order matters: `step` must win over `value` (Increment before Pinned), + // and `sequence` must be recognized ahead of the object forms. + assert!( + matches!(map.get(&'r'), Some(KeyEntry::TopicOnly(_))), + "bare string should be random-mode" + ); + assert!( + matches!(map.get(&'p'), Some(KeyEntry::Pinned { .. })), + "`value` without `step` should be pinned" + ); + assert!( + matches!(map.get(&'i'), Some(KeyEntry::Increment { .. })), + "`step` should select increment even with `value` present" + ); + assert!( + matches!(map.get(&'s'), Some(KeyEntry::Sequence { .. })), + "`sequence` should select sequence mode" + ); + } + + #[test] + fn parse_rejects_multi_character_keys() { + let err = parse_key_map(r#"{"ab": "Some/Topic"}"#).expect_err("multi-char key must fail"); + assert!(err.contains("single characters"), "unexpected error: {err}"); + } + + // --- build_topic_states: classification & skip rules ------------------ + + fn build(json: &str) -> HashMap<char, KeyState> { + build_topic_states(parse_key_map(json).expect("valid keymap")) + } + + #[test] + fn unknown_topic_without_a_unit_is_dropped() { + // Pinned to a topic the spec doesn't know, with no `unit` fallback. + let states = build(r#"{"a": {"topic": "Not/A/Real/Topic", "value": 1.0}}"#); + assert!(!states.contains_key(&'a'), "unresolvable unit should skip"); + } + + #[test] + fn unknown_topic_with_an_explicit_unit_is_kept() { + let states = build(r#"{"a": {"topic": "Not/A/Real/Topic", "value": 1.0, "unit": "V"}}"#); + let state = states.get(&'a').expect("explicit unit should keep it"); + assert_eq!(state.unit, "V"); + match &state.mode { + KeyMode::Pinned { value } => assert_eq!(vec![*value], vec![1.0_f32]), + other => panic!("expected Pinned, got {other:?}"), + } + } + + #[test] + fn increment_start_falls_back_from_value_to_min() { + // No `value`, so the starting point resolves to `min`. + let states = + build(r#"{"a": {"topic": "Not/A/Real/Topic", "step": 1.0, "min": 3.0, "unit": "V"}}"#); + let state = states.get(&'a').expect("explicit unit should keep it"); + match &state.mode { + KeyMode::Increment { current, .. } => assert_eq!(vec![*current], vec![3.0_f32]), + other => panic!("expected Increment, got {other:?}"), + } + } + + #[test] + fn empty_sequence_is_dropped() { + let states = build(r#"{"a": {"sequence": []}}"#); + assert!(!states.contains_key(&'a'), "empty sequence should skip"); + } +} diff --git a/calypso-sim/src/publish.rs b/calypso-sim/src/publish.rs index 9c0412b..22fe503 100644 --- a/calypso-sim/src/publish.rs +++ b/calypso-sim/src/publish.rs @@ -58,12 +58,4 @@ mod tests { assert_eq!(decoded.time_us, ts); assert!(ts > 0, "timestamp should be a real epoch time"); } - - #[test] - fn encode_handles_empty_unit_and_values() { - let (bytes, _) = encode_server_data("", &[]).unwrap(); - let decoded = serverdata::ServerData::parse_from_bytes(&bytes).unwrap(); - assert!(decoded.unit.is_empty()); - assert!(decoded.values.is_empty()); - } } diff --git a/calypso-sim/tests/scenarios.rs b/calypso-sim/tests/scenarios.rs index b580237..0dd9980 100644 --- a/calypso-sim/tests/scenarios.rs +++ b/calypso-sim/tests/scenarios.rs @@ -5,9 +5,10 @@ //! parts verifiable without a broker: topic **ownership** (claim / status / //! silence / release) and the `VcuMock` **state machine** (the brake + shutdown //! gate on entering a drive mode). The end-to-end "these exact values landed on -//! the topic" checks — Python's `assert_published` — are the one slice that -//! needs a real broker and are left for a follow-up; value *encoding* itself is -//! covered by the `encode_server_data` unit test in `src/publish.rs`. +//! the topic" checks — Python's `assert_published` — need a live broker (Siren, +//! in the Docker compose stack) and are intentionally out of scope here; value +//! *encoding* is covered by the `encode_server_data` unit test in +//! `src/publish.rs`. #![allow(dead_code)] // full firmware enums are mirrored; not every variant is exercised yet. mod common; @@ -341,7 +342,7 @@ fn s3_enter_pit_is_gated_on_brake_and_shutdown() { /// topic is `stream`-owned (so `auto_may_publish` is false and the heartbeat /// yields), a silenced topic rejects even driver publishes, and releasing /// restores them. The end-to-end "heartbeat didn't republish" observation -/// needs a broker and is deferred. +/// needs a live broker (Siren) and is out of scope here. #[test] fn s4b_ownership_isolation_through_claim_silence_release() { let mut vcu = VcuMock::new(StreamHarness::spawn_with_auto()); From 24bc8cfd5c742ebbdaccaf017f1829e75115aed2 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Tue, 7 Jul 2026 14:25:50 -0400 Subject: [PATCH 15/29] #305 - slim calypso-sim tests to a confirmable core Replace the sprawling suite with a lean one where each assertion is independently verifiable: - delete scenarios.rs (a VcuMock mirroring unverifiable Cerberus pedal / fault constants; s3 only asserted the mock's own gate) and fold the protocol + ownership-isolation integration tests into one dedicated tests/stream.rs (harness inlined) - trim inline unit tests: registry 5->3, keymap 10->6, cli 3->1 (publish unchanged) Net 29 -> 18 tests. No JSON-RPC protocol coverage lost; the dropped cases are low-risk edges (snapshot sort order, empty-sequence / multi-char keymap guards, unbounded increment). README Testing section updated to match. --- calypso-sim/README.md | 7 +- calypso-sim/src/cli.rs | 13 +- calypso-sim/src/keymap.rs | 42 +--- calypso-sim/src/registry.rs | 52 ++--- calypso-sim/tests/common/mod.rs | 179 --------------- calypso-sim/tests/protocol.rs | 83 ------- calypso-sim/tests/scenarios.rs | 379 -------------------------------- calypso-sim/tests/stream.rs | 247 +++++++++++++++++++++ 8 files changed, 280 insertions(+), 722 deletions(-) delete mode 100644 calypso-sim/tests/common/mod.rs delete mode 100644 calypso-sim/tests/protocol.rs delete mode 100644 calypso-sim/tests/scenarios.rs create mode 100644 calypso-sim/tests/stream.rs diff --git a/calypso-sim/README.md b/calypso-sim/README.md index 9539d1d..8b5d959 100644 --- a/calypso-sim/README.md +++ b/calypso-sim/README.md @@ -112,12 +112,11 @@ cargo test | Layer | Where | What it checks | |---|---|---| | Unit — ownership | `src/registry.rs` | The `auto` / `stream` / `silenced` state machine: a claim makes the heartbeat yield, silence blocks everyone, release restores `auto`. | -| Unit — keymap | `src/keymap.rs` | Increment wrap/saturate semantics (`advance_increment`), the serde `untagged` entry disambiguation (`step` → increment beats `value` → pinned; `sequence` wins), and the load-time skip rules (unknown topic with no `unit`, empty sequence). | +| Unit — keymap | `src/keymap.rs` | Increment wrap/saturate semantics (`advance_increment`), the serde `untagged` entry disambiguation (`step` → increment beats `value` → pinned; `sequence` wins), and the load-time rules (unknown topic with no `unit` is dropped; increment start falls back `value` → `min`). | | Unit — CLI modes | `src/cli.rs` | `run_autonomous` arbitration: heartbeat on by default, off under a foreground mode or `--list-topics`, forced on by explicit `--auto`. | | Unit — encoding | `src/publish.rs` | `ServerData` round-trips: unit, values, and timestamp survive encode → decode. | -| Integration — protocol | `tests/protocol.rs` | Spawns the real `calypso-sim --stream` binary and checks the JSON-RPC contract (`ping`, `list_topics`, `publish`, and the `-32600` / `-32601` / `-32602` error paths). | -| Integration — scenarios | `tests/scenarios.rs` | A `VcuMock` mirroring Cerberus-2.0's `Core/Src/u_statemachine.c` drives the binary over stdio: boot claims every `VCU/*` topic (S1), the brake + shutdown gate on entering PIT (S3), and ownership isolation via claim / silence / release with the heartbeat running (S4b). | +| Integration — stream | `tests/stream.rs` | Spawns the real `calypso-sim --stream` binary and checks the JSON-RPC contract (`ping`, `list_topics`, `publish` + its `-32600` / `-32601` / `-32602` error paths) plus an end-to-end ownership-isolation flow (claim → silence → release with the heartbeat running). | -No broker is needed because `publish` only enqueues (the eventloop retries a missing broker rather than dropping the queue), so it still returns a `ts_us`, and ownership arbitration is answered entirely from the JSON-RPC responses. Observing the actual *bytes on the wire* — that a payload reaches a subscriber — needs a live broker, which in practice is **Siren** in the Docker compose stack (see the repo `Dockerfile`); a standalone end-to-end test broker is intentionally out of scope. The behavior such a test would exercise is already covered here: value encoding by the `src/publish.rs` round-trip, and the injection/arbitration logic by the registry, keymap, CLI, and scenario tests. +No broker is needed because `publish` only enqueues (the eventloop retries a missing broker rather than dropping the queue), so it still returns a `ts_us`, and ownership arbitration is answered entirely from the JSON-RPC responses. Observing the actual *bytes on the wire* — that a payload reaches a subscriber — needs a live broker, which in practice is **Siren** in the Docker compose stack (see the repo `Dockerfile`); a standalone end-to-end test broker is intentionally out of scope. The behavior such a test would exercise is already covered here: value encoding by the `src/publish.rs` round-trip, and the injection/arbitration logic by the registry, keymap, and CLI unit tests plus the `tests/stream.rs` integration flow. CI (`.github/workflows/calypso-sim-ci.yml`) runs the suite on any change under `calypso-sim/**` or its path-dependencies. diff --git a/calypso-sim/src/cli.rs b/calypso-sim/src/cli.rs index 0800534..9399fec 100644 --- a/calypso-sim/src/cli.rs +++ b/calypso-sim/src/cli.rs @@ -73,19 +73,14 @@ mod tests { } #[test] - fn autonomous_runs_by_default_when_no_mode_is_chosen() { + fn run_autonomous_arbitrates_the_heartbeat_against_other_modes() { + // On by default when nothing else is selected. assert!(cli(&["calypso-sim"]).run_autonomous()); - } - - #[test] - fn a_foreground_mode_or_list_disables_the_heartbeat() { + // A foreground mode or --list-topics turns the heartbeat off... assert!(!cli(&["calypso-sim", "--stream"]).run_autonomous()); assert!(!cli(&["calypso-sim", "--key-map", "keys.json"]).run_autonomous()); assert!(!cli(&["calypso-sim", "--list-topics"]).run_autonomous()); - } - - #[test] - fn explicit_auto_forces_the_heartbeat_alongside_a_mode() { + // ...unless --auto forces it back on alongside that mode. assert!(cli(&["calypso-sim", "--auto"]).run_autonomous()); assert!(cli(&["calypso-sim", "--stream", "--auto"]).run_autonomous()); } diff --git a/calypso-sim/src/keymap.rs b/calypso-sim/src/keymap.rs index fc848b9..017dee9 100644 --- a/calypso-sim/src/keymap.rs +++ b/calypso-sim/src/keymap.rs @@ -381,9 +381,9 @@ mod tests { // --- advance_increment: emit-before-advance + wrap semantics ---------- // - // Float values are compared through `Vec<f32>`/arrays (element-wise - // `PartialEq`) rather than bare `f32 == f32` to stay clear of clippy's - // pedantic `float_cmp`; the values here are exact and deterministic. + // Float values are compared through `Vec<f32>` (element-wise `PartialEq`) + // rather than bare `f32 == f32` to stay clear of clippy's pedantic + // `float_cmp`; the values here are exact and deterministic. /// Press an increment state `presses` times, collecting the value emitted /// on each press (increment emits the current value *before* advancing). @@ -427,14 +427,6 @@ mod tests { ); } - #[test] - fn increment_unbounded_keeps_climbing() { - assert_eq!( - press_sequence(0.0, 5.0, None, None, 3), - vec![0.0, 5.0, 10.0] - ); - } - // --- parse_key_map: serde `untagged` disambiguation ------------------- #[test] @@ -469,13 +461,7 @@ mod tests { ); } - #[test] - fn parse_rejects_multi_character_keys() { - let err = parse_key_map(r#"{"ab": "Some/Topic"}"#).expect_err("multi-char key must fail"); - assert!(err.contains("single characters"), "unexpected error: {err}"); - } - - // --- build_topic_states: classification & skip rules ------------------ + // --- build_topic_states: load-time skip / start resolution ------------ fn build(json: &str) -> HashMap<char, KeyState> { build_topic_states(parse_key_map(json).expect("valid keymap")) @@ -488,20 +474,10 @@ mod tests { assert!(!states.contains_key(&'a'), "unresolvable unit should skip"); } - #[test] - fn unknown_topic_with_an_explicit_unit_is_kept() { - let states = build(r#"{"a": {"topic": "Not/A/Real/Topic", "value": 1.0, "unit": "V"}}"#); - let state = states.get(&'a').expect("explicit unit should keep it"); - assert_eq!(state.unit, "V"); - match &state.mode { - KeyMode::Pinned { value } => assert_eq!(vec![*value], vec![1.0_f32]), - other => panic!("expected Pinned, got {other:?}"), - } - } - #[test] fn increment_start_falls_back_from_value_to_min() { - // No `value`, so the starting point resolves to `min`. + // No `value`, so the starting point resolves to `min` (the topic is + // kept via the explicit `unit`). let states = build(r#"{"a": {"topic": "Not/A/Real/Topic", "step": 1.0, "min": 3.0, "unit": "V"}}"#); let state = states.get(&'a').expect("explicit unit should keep it"); @@ -510,10 +486,4 @@ mod tests { other => panic!("expected Increment, got {other:?}"), } } - - #[test] - fn empty_sequence_is_dropped() { - let states = build(r#"{"a": {"sequence": []}}"#); - assert!(!states.contains_key(&'a'), "empty sequence should skip"); - } } diff --git a/calypso-sim/src/registry.rs b/calypso-sim/src/registry.rs index 7be9f33..8b04742 100644 --- a/calypso-sim/src/registry.rs +++ b/calypso-sim/src/registry.rs @@ -86,23 +86,23 @@ mod tests { use super::*; #[test] - fn unknown_topic_defaults_to_auto() { - let reg = TopicRegistry::new(); - assert_eq!(reg.owner("VCU/CarState/speed"), Owner::Auto); - assert!(reg.auto_may_publish("VCU/CarState/speed")); - assert!(reg.driver_may_publish("VCU/CarState/speed")); - } - - #[test] - fn claim_makes_auto_yield_but_keeps_driver() { + fn claim_makes_auto_yield_but_keeps_the_driver() { let mut reg = TopicRegistry::new(); - let prev = reg.set("T", Owner::Stream); - assert_eq!(prev, Owner::Auto, "previous owner should be reported"); + // Unmapped topics default to Auto — heartbeat and driver both publish. + assert_eq!(reg.owner("T"), Owner::Auto); + assert!(reg.auto_may_publish("T") && reg.driver_may_publish("T")); + + assert_eq!( + reg.set("T", Owner::Stream), + Owner::Auto, + "prev owner reported" + ); assert_eq!(reg.owner("T"), Owner::Stream); - // The autonomous heartbeat must yield a claimed topic... - assert!(!reg.auto_may_publish("T")); - // ...while the stream/keymap driver may still publish it. - assert!(reg.driver_may_publish("T")); + assert!( + !reg.auto_may_publish("T"), + "heartbeat yields a claimed topic" + ); + assert!(reg.driver_may_publish("T"), "driver still owns it"); } #[test] @@ -117,24 +117,12 @@ mod tests { fn releasing_to_auto_clears_the_override() { let mut reg = TopicRegistry::new(); reg.set("T", Owner::Stream); - let prev = reg.set("T", Owner::Auto); - assert_eq!(prev, Owner::Stream); - assert_eq!(reg.owner("T"), Owner::Auto); - assert!(reg.snapshot().is_empty(), "auto topics carry no override"); - } - - #[test] - fn snapshot_is_sorted_and_excludes_auto() { - let mut reg = TopicRegistry::new(); - reg.set("beta", Owner::Stream); - reg.set("alpha", Owner::Silenced); - reg.set("gamma", Owner::Auto); // no-op: stays default assert_eq!( - reg.snapshot(), - vec![ - ("alpha".to_string(), Owner::Silenced), - ("beta".to_string(), Owner::Stream), - ] + reg.set("T", Owner::Auto), + Owner::Stream, + "prev owner reported" ); + assert_eq!(reg.owner("T"), Owner::Auto); + assert!(reg.snapshot().is_empty(), "auto topics carry no override"); } } diff --git a/calypso-sim/tests/common/mod.rs b/calypso-sim/tests/common/mod.rs deleted file mode 100644 index 14e977d..0000000 --- a/calypso-sim/tests/common/mod.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! Shared test harness: spawns `calypso-sim --stream` and drives its JSON-RPC -//! stdio protocol synchronously (one request, one response). -//! -//! No MQTT broker is required. `AsyncClient::publish` only enqueues, and the -//! sim's eventloop poller retries a missing broker instead of dropping the -//! queue (see `modes::poll_eventloop`), so every `publish` still returns a -//! `ts_us`. A broker is only needed to observe the *bytes on the wire*, which -//! is covered separately by the `encode_server_data` unit test in -//! `src/publish.rs`. -#![allow(dead_code)] // each test file uses a different subset of these helpers. - -use std::io::{BufRead, BufReader, Write}; -use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; - -use serde_json::{Value, json}; - -/// A live `calypso-sim --stream` child process plus its stdio handles. -pub struct StreamHarness { - child: Child, - stdin: ChildStdin, - stdout: BufReader<ChildStdout>, - next_id: i64, -} - -impl StreamHarness { - /// Spawn the sim in stream mode with the autonomous heartbeat off. - pub fn spawn() -> Self { - Self::spawn_inner(false) - } - - /// Spawn with `--auto` so the autonomous heartbeat runs alongside the - /// stream driver (used to exercise ownership isolation). - pub fn spawn_with_auto() -> Self { - Self::spawn_inner(true) - } - - fn spawn_inner(with_auto: bool) -> Self { - let mut cmd = Command::new(env!("CARGO_BIN_EXE_calypso-sim")); - // Point at a closed port: no broker is needed, and this keeps the sim - // from touching any real broker a developer happens to be running. - cmd.arg("-u").arg("127.0.0.1:47654").arg("--stream"); - if with_auto { - cmd.arg("--auto"); - } - cmd.stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let mut child = cmd - .spawn() - .expect("failed to spawn calypso-sim binary for the stream test"); - let stdin = child.stdin.take().expect("child stdin"); - let stdout = BufReader::new(child.stdout.take().expect("child stdout")); - - // Drain stderr on a background thread so the child never blocks writing - // to a full stderr pipe (it logs an MQTT connection error every ~500ms - // against the closed broker). The thread exits on EOF when we kill it. - let stderr = child.stderr.take().expect("child stderr"); - std::thread::spawn(move || { - let mut reader = BufReader::new(stderr); - let mut line = String::new(); - while reader.read_line(&mut line).is_ok_and(|n| n > 0) { - line.clear(); - } - }); - - StreamHarness { - child, - stdin, - stdout, - next_id: 1, - } - } - - /// Send a raw request object (an `id` is injected) and return the full - /// response object. Lets negative tests craft malformed wire shapes. - pub fn raw(&mut self, mut request: Value) -> Value { - let id = self.next_id; - self.next_id += 1; - request["id"] = json!(id); - - let mut line = serde_json::to_string(&request).expect("serialize request"); - line.push('\n'); - self.stdin - .write_all(line.as_bytes()) - .expect("write request"); - self.stdin.flush().expect("flush request"); - - let mut response = String::new(); - let n = self - .stdout - .read_line(&mut response) - .expect("read response line"); - assert!(n > 0, "sim closed stdout before responding"); - serde_json::from_str(response.trim()) - .unwrap_or_else(|e| panic!("non-JSON response {response:?}: {e}")) - } - - /// Call `method` with `params`, returning the `result` on success or - /// `(code, message)` on a JSON-RPC error. - pub fn call(&mut self, method: &str, params: Value) -> Result<Value, (i64, String)> { - let mut request = json!({"jsonrpc": "2.0", "method": method}); - request["params"] = params; // moves `params` into the request object - let resp = self.raw(request); - if let Some(err) = resp.get("error").filter(|e| !e.is_null()) { - let code = err["code"].as_i64().unwrap_or(0); - let message = err["message"].as_str().unwrap_or_default().to_string(); - return Err((code, message)); - } - Ok(resp.get("result").cloned().unwrap_or(Value::Null)) - } - - /// Call `method`, panicking on any JSON-RPC error (for the success path). - fn expect_ok(&mut self, method: &str, params: Value) -> Value { - self.call(method, params) - .unwrap_or_else(|(code, msg)| panic!("{method} failed: [{code}] {msg}")) - } - - pub fn ping(&mut self) -> bool { - self.expect_ok("ping", json!({}))["ok"] - .as_bool() - .unwrap_or(false) - } - - pub fn list_topics(&mut self) -> Vec<Value> { - self.expect_ok("list_topics", json!({}))["topics"] - .as_array() - .cloned() - .unwrap_or_default() - } - - pub fn publish(&mut self, topic: &str, value: f64) -> Value { - self.expect_ok("publish", json!({"topic": topic, "value": value})) - } - - pub fn publish_unit(&mut self, topic: &str, value: f64, unit: &str) -> Value { - self.expect_ok( - "publish", - json!({"topic": topic, "value": value, "unit": unit}), - ) - } - - pub fn claim(&mut self, topic: &str) -> Value { - self.expect_ok("claim", json!({"topic": topic})) - } - - pub fn release(&mut self, topic: &str) -> Value { - self.expect_ok("release", json!({"topic": topic})) - } - - pub fn silence(&mut self, topic: &str) -> Value { - self.expect_ok("silence", json!({"topic": topic})) - } - - pub fn status(&mut self) -> Vec<Value> { - self.expect_ok("status", json!({}))["overrides"] - .as_array() - .cloned() - .unwrap_or_default() - } - - /// The owner string for `topic` per the current `status`, or `None` when - /// the topic carries no override (i.e. still `auto`). - pub fn owner_of(&mut self, topic: &str) -> Option<String> { - self.status() - .into_iter() - .find(|o| o["topic"] == json!(topic)) - .map(|o| o["owner"].as_str().unwrap_or_default().to_string()) - } -} - -impl Drop for StreamHarness { - fn drop(&mut self) { - // The test is done with the child; kill and reap it so no sim process - // (or its stderr-drain thread) lingers past the test. - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} diff --git a/calypso-sim/tests/protocol.rs b/calypso-sim/tests/protocol.rs deleted file mode 100644 index 23db37c..0000000 --- a/calypso-sim/tests/protocol.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! JSON-RPC protocol conformance for `calypso-sim --stream`. Spawns the real -//! binary and checks the wire contract — no broker required. -mod common; - -use common::StreamHarness; -use serde_json::json; - -#[test] -fn ping_responds_ok() { - let mut sim = StreamHarness::spawn(); - assert!(sim.ping(), "ping should return {{ok: true}}"); -} - -#[test] -fn list_topics_is_nonempty_and_well_formed() { - let mut sim = StreamHarness::spawn(); - let topics = sim.list_topics(); - assert!( - !topics.is_empty(), - "the CAN spec should yield at least one simulatable topic" - ); - for topic in &topics { - assert!( - topic.get("name").and_then(|v| v.as_str()).is_some(), - "topic entry missing string `name`: {topic}" - ); - assert!( - topic.get("unit").is_some(), - "topic entry missing `unit`: {topic}" - ); - } -} - -#[test] -fn publish_returns_a_timestamp() { - let mut sim = StreamHarness::spawn(); - let result = sim.publish("VCU/CarState/speed", 12.5); - assert!( - result["ts_us"].as_u64().unwrap_or(0) > 0, - "publish should echo a microsecond timestamp, got {result}" - ); -} - -#[test] -fn publish_without_a_value_is_invalid_params() { - let mut sim = StreamHarness::spawn(); - let (code, _) = sim - .call("publish", json!({"topic": "VCU/CarState/speed"})) - .expect_err("publish with no value/values must error"); - assert_eq!(code, -32602, "expected Invalid params"); -} - -#[test] -fn publish_with_both_value_and_values_is_invalid_params() { - let mut sim = StreamHarness::spawn(); - let (code, _) = sim - .call( - "publish", - json!({"topic": "X", "value": 1.0, "values": [1.0, 2.0]}), - ) - .expect_err("value + values together must error"); - assert_eq!(code, -32602); -} - -#[test] -fn unknown_method_is_method_not_found() { - let mut sim = StreamHarness::spawn(); - let (code, _) = sim - .call("frobnicate", json!({})) - .expect_err("unknown method must error"); - assert_eq!(code, -32601); -} - -#[test] -fn wrong_jsonrpc_version_is_invalid_request() { - let mut sim = StreamHarness::spawn(); - let resp = sim.raw(json!({"jsonrpc": "2.1", "method": "ping", "params": {}})); - assert_eq!( - resp["error"]["code"].as_i64(), - Some(-32600), - "jsonrpc != 2.0 must be rejected, got {resp}" - ); -} diff --git a/calypso-sim/tests/scenarios.rs b/calypso-sim/tests/scenarios.rs deleted file mode 100644 index 0dd9980..0000000 --- a/calypso-sim/tests/scenarios.rs +++ /dev/null @@ -1,379 +0,0 @@ -//! Scenario tests that mimic what the Cerberus-2.0 VCU firmware -//! (`Core/Src/u_statemachine.c`) publishes, driving `calypso-sim --stream`. -//! -//! Ported from the former `scripts/vcu_mimic.py`. This first cut covers the -//! parts verifiable without a broker: topic **ownership** (claim / status / -//! silence / release) and the `VcuMock` **state machine** (the brake + shutdown -//! gate on entering a drive mode). The end-to-end "these exact values landed on -//! the topic" checks — Python's `assert_published` — need a live broker (Siren, -//! in the Docker compose stack) and are intentionally out of scope here; value -//! *encoding* is covered by the `encode_server_data` unit test in -//! `src/publish.rs`. -#![allow(dead_code)] // full firmware enums are mirrored; not every variant is exercised yet. -mod common; - -use std::collections::HashMap; - -use common::StreamHarness; - -// --- constants mirrored from Cerberus-2.0 --------------------------------- - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum FuncState { - Ready = 0, - FPit = 1, - FReverse = 2, - FPerformance = 3, - FEfficiency = 4, - Faulted = 5, -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum NeroMenu { - Off = 0, - Pit = 1, - Reverse = 2, - Performance = 3, - Efficiency = 4, - Games = 5, - Themes = 6, - Exit = 7, -} - -const MAX_NERO_STATES: u8 = 8; -const PEDAL_BRAKE_THRESH: f64 = 0.12; - -// Pedal calibration from Cerberus-2.0 Core/Src/u_pedals.c -const MIN_APPS1_V: f64 = 2.15; -const MAX_APPS1_V: f64 = 3.12; -const MIN_APPS2_V: f64 = 1.15; -const MAX_APPS2_V: f64 = 2.01; -const MIN_BRAKE1_V: f64 = 0.5; -const MAX_BRAKE1_V: f64 = 1.5; -const MIN_BRAKE2_V: f64 = 0.5; -const MAX_BRAKE2_V: f64 = 1.5; - -const CRITICAL_FAULTS: &[&str] = &[ - "CAN_OUTGOING_FAULT", - "CAN_INCOMING_FAULT", - "BMS_CAN_MONITOR_FAULT", - "LIGHTNING_CAN_MONITOR_FAULT", - "SHUTDOWN_FAULT", -]; -const NON_CRITICAL_FAULTS: &[&str] = &[ - "ONBOARD_TEMP_FAULT", - "IMU_ACCEL_FAULT", - "IMU_GYRO_FAULT", - "BSPD_PREFAULT", - "ONBOARD_BRAKE_OPEN_CIRCUIT_FAULT", - "ONBOARD_ACCEL_OPEN_CIRCUIT_FAULT", - "ONBOARD_BRAKE_SHORT_CIRCUIT_FAULT", - "ONBOARD_ACCEL_SHORT_CIRCUIT_FAULT", - "ONBOARD_PEDAL_DIFFERENCE_FAULT", - "RTDS_FAULT", - "LV_LOW_VOLTAGE_FAULT", -]; - -/// Fixed VCU CarState/Pedals topics the mock owns (fault topics are appended — -/// see [`VcuMock::owned_topics`]). -const CARSTATE_PEDAL_TOPICS: &[&str] = &[ - "VCU/CarState/home_mode", - "VCU/CarState/nero_index", - "VCU/CarState/speed", - "VCU/CarState/tsms", - "VCU/CarState/torque_limit_percentage", - "VCU/CarState/not_in_reverse", - "VCU/CarState/regen_limit", - "VCU/CarState/launch_control", - "VCU/CarState/functional_state", - "VCU/CarState/traction_control", - "VCU/Pedals/Percentages/acceleration_pedal", - "VCU/Pedals/Percentages/brake_pedal", - "VCU/Pedals/Voltages/accel_1", - "VCU/Pedals/Voltages/accel_2", - "VCU/Pedals/Voltages/brake_1", - "VCU/Pedals/Voltages/brake_2", -]; - -/// Firmware publishes booleans as 1.0 / 0.0 floats. -fn bit(flag: bool) -> f64 { - if flag { 1.0 } else { 0.0 } -} - -// --- VcuMock --------------------------------------------------------------- - -/// A minimal mirror of the VCU state machine that drives the sim over the -/// stream protocol. Owns the spawned sim in `sim`. -struct VcuMock { - sim: StreamHarness, - functional: FuncState, - nero_index: u8, - home_mode: bool, - accel: f64, - brake: f64, - tsms: bool, - speed: f64, -} - -impl VcuMock { - fn new(sim: StreamHarness) -> Self { - VcuMock { - sim, - functional: FuncState::Ready, - nero_index: NeroMenu::Off as u8, - home_mode: true, - accel: 0.0, - brake: 0.0, - tsms: false, - speed: 0.0, - } - } - - /// Every topic the mock publishes, in claim order. - fn owned_topics() -> Vec<String> { - let mut out: Vec<String> = CARSTATE_PEDAL_TOPICS - .iter() - .map(|t| (*t).to_string()) - .collect(); - for f in CRITICAL_FAULTS { - out.push(format!("VCU/Faults/Critical/{f}")); - } - for f in NON_CRITICAL_FAULTS { - out.push(format!("VCU/Faults/Non-Critical/{f}")); - } - out - } - - fn claim_all(&mut self) { - for topic in Self::owned_topics() { - self.sim.claim(&topic); - } - } - - fn publish_carstate(&mut self) { - self.sim - .publish("VCU/CarState/home_mode", bit(self.home_mode)); - self.sim - .publish("VCU/CarState/nero_index", f64::from(self.nero_index)); - self.sim - .publish_unit("VCU/CarState/speed", self.speed, "mph"); - self.sim.publish("VCU/CarState/tsms", bit(self.tsms)); - self.sim - .publish("VCU/CarState/torque_limit_percentage", 1.0); - self.sim.publish( - "VCU/CarState/not_in_reverse", - bit(self.functional != FuncState::FReverse), - ); - self.sim.publish_unit("VCU/CarState/regen_limit", 50.0, "A"); - self.sim.publish("VCU/CarState/launch_control", 0.0); - self.sim.publish( - "VCU/CarState/functional_state", - f64::from(self.functional as u8), - ); - self.sim.publish("VCU/CarState/traction_control", 0.0); - } - - fn publish_pedals(&mut self) { - let (a, b) = (self.accel, self.brake); - self.sim - .publish("VCU/Pedals/Percentages/acceleration_pedal", a); - self.sim.publish("VCU/Pedals/Percentages/brake_pedal", b); - self.sim.publish_unit( - "VCU/Pedals/Voltages/accel_1", - MIN_APPS1_V + a * (MAX_APPS1_V - MIN_APPS1_V), - "V", - ); - self.sim.publish_unit( - "VCU/Pedals/Voltages/accel_2", - MIN_APPS2_V + a * (MAX_APPS2_V - MIN_APPS2_V), - "V", - ); - self.sim.publish_unit( - "VCU/Pedals/Voltages/brake_1", - MIN_BRAKE1_V + b * (MAX_BRAKE1_V - MIN_BRAKE1_V), - "V", - ); - self.sim.publish_unit( - "VCU/Pedals/Voltages/brake_2", - MIN_BRAKE2_V + b * (MAX_BRAKE2_V - MIN_BRAKE2_V), - "V", - ); - } - - fn publish_faults(&mut self) { - for f in CRITICAL_FAULTS { - self.sim.publish(&format!("VCU/Faults/Critical/{f}"), 0.0); - } - for f in NON_CRITICAL_FAULTS { - self.sim - .publish(&format!("VCU/Faults/Non-Critical/{f}"), 0.0); - } - } - - /// Reset to power-on state and publish a full frame. - fn boot(&mut self) { - self.functional = FuncState::Ready; - self.nero_index = NeroMenu::Off as u8; - self.home_mode = true; - self.accel = 0.0; - self.brake = 0.0; - self.tsms = false; - self.speed = 0.0; - self.publish_carstate(); - self.publish_pedals(); - self.publish_faults(); - } - - fn menu_increment(&mut self) { - let next = self.nero_index + 1; - self.nero_index = if next >= MAX_NERO_STATES { 0 } else { next }; - self.sim - .publish("VCU/CarState/nero_index", f64::from(self.nero_index)); - } - - fn set_pedals(&mut self, accel: f64, brake: f64) { - self.accel = accel.clamp(0.0, 1.0); - self.brake = brake.clamp(0.0, 1.0); - self.publish_pedals(); - } - - fn set_tsms(&mut self, closed: bool) { - self.tsms = closed; - self.sim.publish("VCU/CarState/tsms", bit(closed)); - } - - /// Try to enter the highlighted drive mode. Mirrors - /// `transition_functional_state`: PIT / PERFORMANCE / EFFICIENCY require - /// brake pressed + shutdown closed; REVERSE is gateless. Returns whether a - /// transition happened. - fn menu_select(&mut self) -> bool { - let target = match self.nero_index { - x if x == NeroMenu::Pit as u8 => Some(FuncState::FPit), - x if x == NeroMenu::Performance as u8 => Some(FuncState::FPerformance), - x if x == NeroMenu::Efficiency as u8 => Some(FuncState::FEfficiency), - x if x == NeroMenu::Reverse as u8 => Some(FuncState::FReverse), - _ => None, - }; - let Some(target) = target else { - return false; - }; - // REVERSE is gateless; the others need brake + shutdown closed. - if target != FuncState::FReverse && (self.brake < PEDAL_BRAKE_THRESH || !self.tsms) { - return false; - } - self.functional = target; - self.home_mode = false; - self.sim.publish("VCU/CarState/home_mode", 0.0); - self.sim.publish( - "VCU/CarState/functional_state", - f64::from(self.functional as u8), - ); - self.sim.publish( - "VCU/CarState/not_in_reverse", - bit(self.functional != FuncState::FReverse), - ); - true - } -} - -// --- scenarios ------------------------------------------------------------- - -/// S1: boot — claim every VCU topic, confirm the registry marks them all -/// `stream`-owned, then publish the initial frame (each publish must be -/// accepted). -#[test] -fn s1_boot_claims_all_topics_and_publishes_initial_frame() { - let mut vcu = VcuMock::new(StreamHarness::spawn()); - vcu.claim_all(); - - let owners: HashMap<String, String> = vcu - .sim - .status() - .into_iter() - .map(|o| { - ( - o["topic"].as_str().unwrap_or_default().to_string(), - o["owner"].as_str().unwrap_or_default().to_string(), - ) - }) - .collect(); - for topic in VcuMock::owned_topics() { - assert_eq!( - owners.get(&topic).map(String::as_str), - Some("stream"), - "claim missing for {topic}" - ); - } - - // A full boot frame publishes cleanly (publish() panics on any RPC error). - vcu.boot(); -} - -/// S3: entering PIT is gated on brake pressed + shutdown closed. Mirrors -/// `transition_functional_state`. Verifiable without a broker because the gate -/// lives in the mock's state machine. -#[test] -fn s3_enter_pit_is_gated_on_brake_and_shutdown() { - let mut vcu = VcuMock::new(StreamHarness::spawn()); - vcu.claim_all(); - - vcu.menu_increment(); // OFF -> PIT - assert_eq!(vcu.nero_index, NeroMenu::Pit as u8); - - // No brake, shutdown open: rejected, no transition. - assert!( - !vcu.menu_select(), - "PIT must be refused without brake + shutdown" - ); - assert_eq!(vcu.functional, FuncState::Ready); - - // Press brake and close shutdown: now it enters. - vcu.set_pedals(0.0, 0.30); - vcu.set_tsms(true); - assert!( - vcu.menu_select(), - "PIT should enter once gated conditions met" - ); - assert_eq!(vcu.functional, FuncState::FPit); - assert!(!vcu.home_mode, "entering a drive mode leaves home_mode"); -} - -/// S4b: ownership isolation. With the autonomous heartbeat running, a claimed -/// topic is `stream`-owned (so `auto_may_publish` is false and the heartbeat -/// yields), a silenced topic rejects even driver publishes, and releasing -/// restores them. The end-to-end "heartbeat didn't republish" observation -/// needs a live broker (Siren) and is out of scope here. -#[test] -fn s4b_ownership_isolation_through_claim_silence_release() { - let mut vcu = VcuMock::new(StreamHarness::spawn_with_auto()); - let topic = "VCU/CarState/torque_limit_percentage"; - - vcu.sim.claim(topic); - assert_eq!( - vcu.sim.owner_of(topic).as_deref(), - Some("stream"), - "claim must mark the topic stream-owned so autonomous yields" - ); - assert!( - vcu.sim.publish(topic, 0.42)["ts_us"].as_u64().unwrap_or(0) > 0, - "driver publish accepted while claimed" - ); - - vcu.sim.silence(topic); - assert_eq!( - vcu.sim.publish(topic, 0.99)["skipped"].as_str(), - Some("silenced"), - "silenced topics reject even the driver" - ); - - vcu.sim.release(topic); - assert_eq!( - vcu.sim.owner_of(topic), - None, - "release returns the topic to auto (no override)" - ); - assert!( - vcu.sim.publish(topic, 0.5)["ts_us"].as_u64().unwrap_or(0) > 0, - "driver publish accepted again after release" - ); -} diff --git a/calypso-sim/tests/stream.rs b/calypso-sim/tests/stream.rs new file mode 100644 index 0000000..410d584 --- /dev/null +++ b/calypso-sim/tests/stream.rs @@ -0,0 +1,247 @@ +//! Integration coverage for `calypso-sim --stream`: spawns the real binary and +//! drives its JSON-RPC-over-stdio protocol. +//! +//! No broker required. `AsyncClient::publish` only enqueues, and the sim's +//! eventloop poller retries a missing broker instead of dropping the queue (see +//! `modes::poll_eventloop`), so every `publish` still returns a `ts_us`, and +//! ownership is answered from the JSON-RPC responses. Observing the actual bytes +//! on the wire needs a live broker (Siren, in the Docker compose stack) and is +//! intentionally out of scope — see `README.md`. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use serde_json::{Value, json}; + +/// A live `calypso-sim --stream` child process plus its stdio handles. +struct Sim { + child: Child, + stdin: ChildStdin, + stdout: BufReader<ChildStdout>, + next_id: i64, +} + +impl Sim { + /// Spawn the sim in stream mode with the autonomous heartbeat off. + fn spawn() -> Self { + Self::spawn_inner(false) + } + + /// Spawn with `--auto` so the heartbeat runs alongside the stream driver. + fn spawn_with_auto() -> Self { + Self::spawn_inner(true) + } + + fn spawn_inner(with_auto: bool) -> Self { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_calypso-sim")); + // A closed port: no broker is needed, and this keeps the sim off any + // real broker a developer happens to be running. + cmd.arg("-u").arg("127.0.0.1:47654").arg("--stream"); + if with_auto { + cmd.arg("--auto"); + } + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn().expect("failed to spawn calypso-sim binary"); + let stdin = child.stdin.take().expect("child stdin"); + let stdout = BufReader::new(child.stdout.take().expect("child stdout")); + + // Drain stderr on a background thread so the child never blocks on a full + // pipe (it logs an MQTT connection error ~every 500ms against the closed + // broker). The thread exits on EOF when the child is killed. + let stderr = child.stderr.take().expect("child stderr"); + std::thread::spawn(move || { + let mut reader = BufReader::new(stderr); + let mut line = String::new(); + while reader.read_line(&mut line).is_ok_and(|n| n > 0) { + line.clear(); + } + }); + + Sim { + child, + stdin, + stdout, + next_id: 1, + } + } + + /// Send a request object (an `id` is injected) and return the full response. + fn raw(&mut self, mut request: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + request["id"] = json!(id); + + let mut line = serde_json::to_string(&request).expect("serialize request"); + line.push('\n'); + self.stdin + .write_all(line.as_bytes()) + .expect("write request"); + self.stdin.flush().expect("flush request"); + + let mut response = String::new(); + let n = self.stdout.read_line(&mut response).expect("read response"); + assert!(n > 0, "sim closed stdout before responding"); + serde_json::from_str(response.trim()) + .unwrap_or_else(|e| panic!("non-JSON response {response:?}: {e}")) + } + + /// Call `method` with `params`: `Ok(result)` or `Err((code, message))`. + fn call(&mut self, method: &str, params: Value) -> Result<Value, (i64, String)> { + let mut request = json!({"jsonrpc": "2.0", "method": method}); + request["params"] = params; // moves `params` into the request + let resp = self.raw(request); + if let Some(err) = resp.get("error").filter(|e| !e.is_null()) { + return Err(( + err["code"].as_i64().unwrap_or(0), + err["message"].as_str().unwrap_or_default().to_string(), + )); + } + Ok(resp.get("result").cloned().unwrap_or(Value::Null)) + } + + /// Call `method`, panicking on any JSON-RPC error (success path). + fn ok(&mut self, method: &str, params: Value) -> Value { + self.call(method, params) + .unwrap_or_else(|(code, msg)| panic!("{method} failed: [{code}] {msg}")) + } + + /// Owner string for `topic` per `status`, or `None` when it is still `auto`. + fn owner_of(&mut self, topic: &str) -> Option<String> { + self.ok("status", json!({}))["overrides"] + .as_array() + .into_iter() + .flatten() + .find(|o| o["topic"] == json!(topic)) + .map(|o| o["owner"].as_str().unwrap_or_default().to_string()) + } +} + +impl Drop for Sim { + fn drop(&mut self) { + // Kill and reap so no sim process (or its stderr thread) outlives the test. + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn ping_responds_ok() { + let mut sim = Sim::spawn(); + assert_eq!(sim.ok("ping", json!({}))["ok"].as_bool(), Some(true)); +} + +#[test] +fn list_topics_is_nonempty_and_well_formed() { + let mut sim = Sim::spawn(); + let result = sim.ok("list_topics", json!({})); + let topics = result["topics"].as_array().expect("topics array"); + assert!( + !topics.is_empty(), + "the spec should yield simulatable topics" + ); + for t in topics { + assert!( + t["name"].as_str().is_some(), + "topic missing string name: {t}" + ); + assert!(t.get("unit").is_some(), "topic missing unit: {t}"); + } +} + +#[test] +fn publish_returns_a_timestamp() { + let mut sim = Sim::spawn(); + let result = sim.ok( + "publish", + json!({"topic": "VCU/CarState/speed", "value": 12.5}), + ); + assert!( + result["ts_us"].as_u64().unwrap_or(0) > 0, + "publish should echo a microsecond timestamp, got {result}" + ); +} + +#[test] +fn publish_requires_exactly_one_of_value_or_values() { + let mut sim = Sim::spawn(); + // Neither present -> invalid params. + let (code, _) = sim + .call("publish", json!({"topic": "T"})) + .expect_err("publish with no value/values must error"); + assert_eq!(code, -32602, "expected Invalid params"); + // Both present -> invalid params. + let (code, _) = sim + .call( + "publish", + json!({"topic": "T", "value": 1.0, "values": [1.0, 2.0]}), + ) + .expect_err("value + values together must error"); + assert_eq!(code, -32602); +} + +#[test] +fn unknown_method_is_method_not_found() { + let mut sim = Sim::spawn(); + let (code, _) = sim + .call("frobnicate", json!({})) + .expect_err("unknown method must error"); + assert_eq!(code, -32601); +} + +#[test] +fn wrong_jsonrpc_version_is_invalid_request() { + let mut sim = Sim::spawn(); + let resp = sim.raw(json!({"jsonrpc": "2.1", "method": "ping", "params": {}})); + assert_eq!( + resp["error"]["code"].as_i64(), + Some(-32600), + "jsonrpc != 2.0 must be rejected, got {resp}" + ); +} + +#[test] +fn ownership_isolation_through_claim_silence_release() { + // With the autonomous heartbeat running: a claimed topic is `stream`-owned + // (so the heartbeat yields), a silenced topic rejects even the driver, and + // release returns it to `auto`. + let mut sim = Sim::spawn_with_auto(); + let topic = "VCU/CarState/torque_limit_percentage"; + + sim.ok("claim", json!({"topic": topic})); + assert_eq!( + sim.owner_of(topic).as_deref(), + Some("stream"), + "claim must mark the topic stream-owned" + ); + assert!( + sim.ok("publish", json!({"topic": topic, "value": 0.42}))["ts_us"] + .as_u64() + .unwrap_or(0) + > 0, + "driver publish accepted while claimed" + ); + + sim.ok("silence", json!({"topic": topic})); + assert_eq!( + sim.ok("publish", json!({"topic": topic, "value": 0.99}))["skipped"].as_str(), + Some("silenced"), + "silenced topics reject even the driver" + ); + + sim.ok("release", json!({"topic": topic})); + assert_eq!( + sim.owner_of(topic), + None, + "release returns the topic to auto (no override)" + ); + assert!( + sim.ok("publish", json!({"topic": topic, "value": 0.5}))["ts_us"] + .as_u64() + .unwrap_or(0) + > 0, + "driver publish accepted again after release" + ); +} From 562857ce2182aeba93f42ffd77d1ae8f12f1e946 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Tue, 7 Jul 2026 14:52:17 -0400 Subject: [PATCH 16/29] #305 - keep SimValue guard in the sim's vendored copy, leave main crate untouched --- src/simulatable_message.rs | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/simulatable_message.rs b/src/simulatable_message.rs index 9357317..87bb00e 100644 --- a/src/simulatable_message.rs +++ b/src/simulatable_message.rs @@ -8,7 +8,7 @@ use std::time::Instant; /** * A `SimComponent` roughly corresponds to a `NetField` with properties inherited from `CANMsg` */ -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct SimComponent { pub id: String, pub points: Vec<SimPoint>, @@ -23,7 +23,7 @@ pub struct SimComponent { /** * Corresponds to `CANPoint` of a `NetField` */ -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct SimPoint { pub size: usize, pub parse: Option<bool>, @@ -37,7 +37,7 @@ pub struct SimPoint { /** * The mode of simulation and the real-time value of the `CANPoint` */ -#[derive(Debug, Clone)] +#[derive(Debug)] pub enum SimValue { /// Ranged mode where the value is within a min/max range and can include increment parameters. Range { @@ -124,13 +124,7 @@ impl SimValue { current, .. } => { - // Guard a degenerate (min == max) range: `random_range` panics - // on an empty range. - *current = if (*max - *min).abs() < f32::EPSILON { - *min - } else { - rng.random_range(*min..*max) - }; + *current = rng.random_range(*min..*max); if *inc_min != 0.0 { *current = (*current / *inc_min).round() * *inc_min; // Round to nearest inc_min } @@ -139,10 +133,8 @@ impl SimValue { } } SimValue::Discrete { options, current } => { - // `choose` is empty-safe (returns None); direct indexing panics. - if let Some(&(v, _)) = options.choose(&mut rng) { - *current = v; - } + let idx = rng.random_range(0..options.len()); + *current = options[idx].0; } } } From 9522bdcc278c1dd54bc66bc30aa6dae8a46c8080 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Tue, 7 Jul 2026 16:25:47 -0400 Subject: [PATCH 17/29] #305 - consolidate calypso-sim unit tests into src/tests/ and trim to a minimal regression-guarding set (18 -> 7) --- calypso-sim/README.md | 12 ++-- calypso-sim/src/cli.rs | 23 ------- calypso-sim/src/keymap.rs | 113 -------------------------------- calypso-sim/src/main.rs | 3 + calypso-sim/src/publish.rs | 15 ----- calypso-sim/src/registry.rs | 46 ------------- calypso-sim/src/tests.rs | 10 +++ calypso-sim/src/tests/cli.rs | 23 +++++++ calypso-sim/src/tests/keymap.rs | 75 +++++++++++++++++++++ calypso-sim/tests/stream.rs | 30 ++------- 10 files changed, 121 insertions(+), 229 deletions(-) create mode 100644 calypso-sim/src/tests.rs create mode 100644 calypso-sim/src/tests/cli.rs create mode 100644 calypso-sim/src/tests/keymap.rs diff --git a/calypso-sim/README.md b/calypso-sim/README.md index 8b5d959..43a8f57 100644 --- a/calypso-sim/README.md +++ b/calypso-sim/README.md @@ -111,12 +111,12 @@ cargo test | Layer | Where | What it checks | |---|---|---| -| Unit — ownership | `src/registry.rs` | The `auto` / `stream` / `silenced` state machine: a claim makes the heartbeat yield, silence blocks everyone, release restores `auto`. | -| Unit — keymap | `src/keymap.rs` | Increment wrap/saturate semantics (`advance_increment`), the serde `untagged` entry disambiguation (`step` → increment beats `value` → pinned; `sequence` wins), and the load-time rules (unknown topic with no `unit` is dropped; increment start falls back `value` → `min`). | -| Unit — CLI modes | `src/cli.rs` | `run_autonomous` arbitration: heartbeat on by default, off under a foreground mode or `--list-topics`, forced on by explicit `--auto`. | -| Unit — encoding | `src/publish.rs` | `ServerData` round-trips: unit, values, and timestamp survive encode → decode. | -| Integration — stream | `tests/stream.rs` | Spawns the real `calypso-sim --stream` binary and checks the JSON-RPC contract (`ping`, `list_topics`, `publish` + its `-32600` / `-32601` / `-32602` error paths) plus an end-to-end ownership-isolation flow (claim → silence → release with the heartbeat running). | +| Unit — keymap | `src/tests/keymap.rs` | The two fragile pieces of keymap logic: increment emit-then-wrap/saturate (`advance_increment`), and the serde `untagged` shape disambiguation, whose result depends on variant *order* (`step` → increment beats `value` → pinned; `sequence` wins). | +| Unit — CLI modes | `src/tests/cli.rs` | `run_autonomous` arbitration: heartbeat on by default, off under a foreground mode or `--list-topics`, forced on by explicit `--auto`. | +| Integration — stream | `tests/stream.rs` | Spawns the real `calypso-sim --stream` binary and checks the JSON-RPC contract (`list_topics` is non-empty, `publish` requires exactly one of `value`/`values`, malformed requests get `-32601`/`-32600`) plus an end-to-end ownership flow — claim → silence → release with the heartbeat running, which doubles as the regression guard for the `auto`/`stream`/`silenced` arbitration. | -No broker is needed because `publish` only enqueues (the eventloop retries a missing broker rather than dropping the queue), so it still returns a `ts_us`, and ownership arbitration is answered entirely from the JSON-RPC responses. Observing the actual *bytes on the wire* — that a payload reaches a subscriber — needs a live broker, which in practice is **Siren** in the Docker compose stack (see the repo `Dockerfile`); a standalone end-to-end test broker is intentionally out of scope. The behavior such a test would exercise is already covered here: value encoding by the `src/publish.rs` round-trip, and the injection/arbitration logic by the registry, keymap, and CLI unit tests plus the `tests/stream.rs` integration flow. +The suite is deliberately small: each test guards logic a future change could silently break, not code that is obvious by reading it. Unit tests live in `src/tests/` — compiled into the crate under `cfg(test)`, so they reach internals via `use crate::…`; binary-driven tests live in the crate-root `tests/` dir, the only place Cargo sets `CARGO_BIN_EXE_calypso-sim`. + +No broker is needed because `publish` only enqueues (the eventloop retries a missing broker rather than dropping the queue), so it still returns a `ts_us`, and ownership arbitration is answered entirely from the JSON-RPC responses. Observing the actual *bytes on the wire* — that a payload reaches a subscriber — needs a live broker, which in practice is **Siren** in the Docker compose stack (see the repo `Dockerfile`); a standalone end-to-end test broker is intentionally out of scope. CI (`.github/workflows/calypso-sim-ci.yml`) runs the suite on any change under `calypso-sim/**` or its path-dependencies. diff --git a/calypso-sim/src/cli.rs b/calypso-sim/src/cli.rs index 9399fec..ec8a324 100644 --- a/calypso-sim/src/cli.rs +++ b/calypso-sim/src/cli.rs @@ -62,26 +62,3 @@ impl Cli { self.auto || (!any_other_mode && !self.list_topics) } } - -#[cfg(test)] -mod tests { - use super::*; - use clap::Parser; - - fn cli(args: &[&str]) -> Cli { - Cli::try_parse_from(args).expect("valid args") - } - - #[test] - fn run_autonomous_arbitrates_the_heartbeat_against_other_modes() { - // On by default when nothing else is selected. - assert!(cli(&["calypso-sim"]).run_autonomous()); - // A foreground mode or --list-topics turns the heartbeat off... - assert!(!cli(&["calypso-sim", "--stream"]).run_autonomous()); - assert!(!cli(&["calypso-sim", "--key-map", "keys.json"]).run_autonomous()); - assert!(!cli(&["calypso-sim", "--list-topics"]).run_autonomous()); - // ...unless --auto forces it back on alongside that mode. - assert!(cli(&["calypso-sim", "--auto"]).run_autonomous()); - assert!(cli(&["calypso-sim", "--stream", "--auto"]).run_autonomous()); - } -} diff --git a/calypso-sim/src/keymap.rs b/calypso-sim/src/keymap.rs index 017dee9..a7b719e 100644 --- a/calypso-sim/src/keymap.rs +++ b/calypso-sim/src/keymap.rs @@ -374,116 +374,3 @@ async fn log_and_publish( } let _ = std::io::stdout().flush(); } - -#[cfg(test)] -mod tests { - use super::*; - - // --- advance_increment: emit-before-advance + wrap semantics ---------- - // - // Float values are compared through `Vec<f32>` (element-wise `PartialEq`) - // rather than bare `f32 == f32` to stay clear of clippy's pedantic - // `float_cmp`; the values here are exact and deterministic. - - /// Press an increment state `presses` times, collecting the value emitted - /// on each press (increment emits the current value *before* advancing). - fn press_sequence( - start: f32, - step: f32, - min: Option<f32>, - max: Option<f32>, - presses: usize, - ) -> Vec<f32> { - let mut current = start; - (0..presses) - .map(|_| advance_increment(&mut current, step, min, max)) - .collect() - } - - #[test] - fn increment_emits_start_first_then_wraps_at_max() { - // Climb 0,1,2, then wrap past max back to min. - assert_eq!( - press_sequence(0.0, 1.0, Some(0.0), Some(2.0), 5), - vec![0.0, 1.0, 2.0, 0.0, 1.0] - ); - } - - #[test] - fn increment_without_a_min_saturates_at_max() { - // No min to wrap to: once at max it stays there. - assert_eq!( - press_sequence(0.0, 1.0, None, Some(2.0), 5), - vec![0.0, 1.0, 2.0, 2.0, 2.0] - ); - } - - #[test] - fn increment_negative_step_wraps_min_to_max() { - // Counting down past min wraps up to max. - assert_eq!( - press_sequence(2.0, -1.0, Some(0.0), Some(2.0), 5), - vec![2.0, 1.0, 0.0, 2.0, 1.0] - ); - } - - // --- parse_key_map: serde `untagged` disambiguation ------------------- - - #[test] - fn parse_disambiguates_the_four_entry_shapes() { - let map = parse_key_map( - r#"{ - "r": "Some/Topic", - "p": {"topic": "T", "value": 1.0}, - "i": {"topic": "T", "value": 0.0, "step": 1.0}, - "s": {"sequence": [{"topic": "T", "value": 1.0}]} - }"#, - ) - .expect("valid keymap"); - - // Order matters: `step` must win over `value` (Increment before Pinned), - // and `sequence` must be recognized ahead of the object forms. - assert!( - matches!(map.get(&'r'), Some(KeyEntry::TopicOnly(_))), - "bare string should be random-mode" - ); - assert!( - matches!(map.get(&'p'), Some(KeyEntry::Pinned { .. })), - "`value` without `step` should be pinned" - ); - assert!( - matches!(map.get(&'i'), Some(KeyEntry::Increment { .. })), - "`step` should select increment even with `value` present" - ); - assert!( - matches!(map.get(&'s'), Some(KeyEntry::Sequence { .. })), - "`sequence` should select sequence mode" - ); - } - - // --- build_topic_states: load-time skip / start resolution ------------ - - fn build(json: &str) -> HashMap<char, KeyState> { - build_topic_states(parse_key_map(json).expect("valid keymap")) - } - - #[test] - fn unknown_topic_without_a_unit_is_dropped() { - // Pinned to a topic the spec doesn't know, with no `unit` fallback. - let states = build(r#"{"a": {"topic": "Not/A/Real/Topic", "value": 1.0}}"#); - assert!(!states.contains_key(&'a'), "unresolvable unit should skip"); - } - - #[test] - fn increment_start_falls_back_from_value_to_min() { - // No `value`, so the starting point resolves to `min` (the topic is - // kept via the explicit `unit`). - let states = - build(r#"{"a": {"topic": "Not/A/Real/Topic", "step": 1.0, "min": 3.0, "unit": "V"}}"#); - let state = states.get(&'a').expect("explicit unit should keep it"); - match &state.mode { - KeyMode::Increment { current, .. } => assert_eq!(vec![*current], vec![3.0_f32]), - other => panic!("expected Increment, got {other:?}"), - } - } -} diff --git a/calypso-sim/src/main.rs b/calypso-sim/src/main.rs index eca6820..197c331 100644 --- a/calypso-sim/src/main.rs +++ b/calypso-sim/src/main.rs @@ -11,6 +11,9 @@ mod simulatable_message; mod simulate_data; mod warnings; +#[cfg(test)] +mod tests; + use std::process::exit; use std::time::{Duration, SystemTime, UNIX_EPOCH}; diff --git a/calypso-sim/src/publish.rs b/calypso-sim/src/publish.rs index 22fe503..c9e1229 100644 --- a/calypso-sim/src/publish.rs +++ b/calypso-sim/src/publish.rs @@ -44,18 +44,3 @@ pub async fn publish_data( Ok(timestamp) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn encode_round_trips_unit_values_and_timestamp() { - let (bytes, ts) = encode_server_data("mph", &[1.5, -2.0, 3.25]).unwrap(); - let decoded = serverdata::ServerData::parse_from_bytes(&bytes).unwrap(); - assert_eq!(decoded.unit, "mph"); - assert_eq!(decoded.values, vec![1.5, -2.0, 3.25]); - assert_eq!(decoded.time_us, ts); - assert!(ts > 0, "timestamp should be a real epoch time"); - } -} diff --git a/calypso-sim/src/registry.rs b/calypso-sim/src/registry.rs index 8b04742..daf1bb7 100644 --- a/calypso-sim/src/registry.rs +++ b/calypso-sim/src/registry.rs @@ -80,49 +80,3 @@ impl TopicRegistry { entries } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn claim_makes_auto_yield_but_keeps_the_driver() { - let mut reg = TopicRegistry::new(); - // Unmapped topics default to Auto — heartbeat and driver both publish. - assert_eq!(reg.owner("T"), Owner::Auto); - assert!(reg.auto_may_publish("T") && reg.driver_may_publish("T")); - - assert_eq!( - reg.set("T", Owner::Stream), - Owner::Auto, - "prev owner reported" - ); - assert_eq!(reg.owner("T"), Owner::Stream); - assert!( - !reg.auto_may_publish("T"), - "heartbeat yields a claimed topic" - ); - assert!(reg.driver_may_publish("T"), "driver still owns it"); - } - - #[test] - fn silence_blocks_both_auto_and_driver() { - let mut reg = TopicRegistry::new(); - reg.set("T", Owner::Silenced); - assert!(!reg.auto_may_publish("T")); - assert!(!reg.driver_may_publish("T")); - } - - #[test] - fn releasing_to_auto_clears_the_override() { - let mut reg = TopicRegistry::new(); - reg.set("T", Owner::Stream); - assert_eq!( - reg.set("T", Owner::Auto), - Owner::Stream, - "prev owner reported" - ); - assert_eq!(reg.owner("T"), Owner::Auto); - assert!(reg.snapshot().is_empty(), "auto topics carry no override"); - } -} diff --git a/calypso-sim/src/tests.rs b/calypso-sim/src/tests.rs new file mode 100644 index 0000000..df8fe54 --- /dev/null +++ b/calypso-sim/src/tests.rs @@ -0,0 +1,10 @@ +//! Unit tests for calypso-sim, gathered here (compiled only under `cfg(test)` +//! via `mod tests;` in `main.rs`) so they run under `cargo test` with full +//! access to crate internals. Integration tests that drive the real binary +//! live in the crate-root `tests/` directory instead. +//! +//! Kept deliberately small: each test guards a piece of logic that a future +//! refactor could silently break, not code that is obvious by inspection. + +mod cli; +mod keymap; diff --git a/calypso-sim/src/tests/cli.rs b/calypso-sim/src/tests/cli.rs new file mode 100644 index 0000000..ed1f99e --- /dev/null +++ b/calypso-sim/src/tests/cli.rs @@ -0,0 +1,23 @@ +//! CLI mode arbitration. `run_autonomous` decides when the background +//! heartbeat runs; the regression this guards is adding a new foreground mode +//! and forgetting to suppress the heartbeat under it. + +use crate::cli::Cli; +use clap::Parser; + +fn cli(args: &[&str]) -> Cli { + Cli::try_parse_from(args).expect("valid args") +} + +#[test] +fn run_autonomous_arbitrates_the_heartbeat_against_other_modes() { + // On by default when nothing else is selected. + assert!(cli(&["calypso-sim"]).run_autonomous()); + // A foreground mode or --list-topics turns the heartbeat off... + assert!(!cli(&["calypso-sim", "--stream"]).run_autonomous()); + assert!(!cli(&["calypso-sim", "--key-map", "keys.json"]).run_autonomous()); + assert!(!cli(&["calypso-sim", "--list-topics"]).run_autonomous()); + // ...unless --auto forces it back on alongside that mode. + assert!(cli(&["calypso-sim", "--auto"]).run_autonomous()); + assert!(cli(&["calypso-sim", "--stream", "--auto"]).run_autonomous()); +} diff --git a/calypso-sim/src/tests/keymap.rs b/calypso-sim/src/tests/keymap.rs new file mode 100644 index 0000000..01109b6 --- /dev/null +++ b/calypso-sim/src/tests/keymap.rs @@ -0,0 +1,75 @@ +//! Keymap logic that is fragile under refactoring: the increment +//! emit-then-advance arithmetic, and the serde `untagged` shape disambiguation +//! (whose behavior depends on variant *order*, which the type alone doesn't +//! make obvious). + +use crate::keymap::{KeyEntry, advance_increment, parse_key_map}; + +/// Press an increment state `presses` times, collecting the value emitted on +/// each press. Increment emits the current value *before* advancing. +fn press_sequence( + start: f32, + step: f32, + min: Option<f32>, + max: Option<f32>, + presses: usize, +) -> Vec<f32> { + let mut current = start; + (0..presses) + .map(|_| advance_increment(&mut current, step, min, max)) + .collect() +} + +#[test] +fn advance_increment_emits_then_wraps_or_saturates() { + // Exact, deterministic values; compared through `Vec<f32>` (element-wise + // `PartialEq`) to stay clear of clippy's pedantic `float_cmp`. + assert_eq!( + press_sequence(0.0, 1.0, Some(0.0), Some(2.0), 5), + vec![0.0, 1.0, 2.0, 0.0, 1.0], + "with a min, climbing past max wraps back to min" + ); + assert_eq!( + press_sequence(0.0, 1.0, None, Some(2.0), 5), + vec![0.0, 1.0, 2.0, 2.0, 2.0], + "with no min, the value saturates at max" + ); + assert_eq!( + press_sequence(2.0, -1.0, Some(0.0), Some(2.0), 5), + vec![2.0, 1.0, 0.0, 2.0, 1.0], + "a negative step wraps min back up to max" + ); +} + +#[test] +fn parse_disambiguates_the_four_entry_shapes() { + let map = parse_key_map( + r#"{ + "r": "Some/Topic", + "p": {"topic": "T", "value": 1.0}, + "i": {"topic": "T", "value": 0.0, "step": 1.0}, + "s": {"sequence": [{"topic": "T", "value": 1.0}]} + }"#, + ) + .expect("valid keymap"); + + // Order matters in the `untagged` enum: `step` must win over `value` + // (Increment before Pinned), and `sequence` must be recognized ahead of the + // other object forms. Reordering the variants would silently break this. + assert!( + matches!(map.get(&'r'), Some(KeyEntry::TopicOnly(_))), + "bare string should be random-mode" + ); + assert!( + matches!(map.get(&'p'), Some(KeyEntry::Pinned { .. })), + "`value` without `step` should be pinned" + ); + assert!( + matches!(map.get(&'i'), Some(KeyEntry::Increment { .. })), + "`step` should select increment even with `value` present" + ); + assert!( + matches!(map.get(&'s'), Some(KeyEntry::Sequence { .. })), + "`sequence` should select sequence mode" + ); +} diff --git a/calypso-sim/tests/stream.rs b/calypso-sim/tests/stream.rs index 410d584..d1d8b12 100644 --- a/calypso-sim/tests/stream.rs +++ b/calypso-sim/tests/stream.rs @@ -127,12 +127,6 @@ impl Drop for Sim { } } -#[test] -fn ping_responds_ok() { - let mut sim = Sim::spawn(); - assert_eq!(sim.ok("ping", json!({}))["ok"].as_bool(), Some(true)); -} - #[test] fn list_topics_is_nonempty_and_well_formed() { let mut sim = Sim::spawn(); @@ -151,19 +145,6 @@ fn list_topics_is_nonempty_and_well_formed() { } } -#[test] -fn publish_returns_a_timestamp() { - let mut sim = Sim::spawn(); - let result = sim.ok( - "publish", - json!({"topic": "VCU/CarState/speed", "value": 12.5}), - ); - assert!( - result["ts_us"].as_u64().unwrap_or(0) > 0, - "publish should echo a microsecond timestamp, got {result}" - ); -} - #[test] fn publish_requires_exactly_one_of_value_or_values() { let mut sim = Sim::spawn(); @@ -183,17 +164,14 @@ fn publish_requires_exactly_one_of_value_or_values() { } #[test] -fn unknown_method_is_method_not_found() { +fn malformed_requests_are_rejected_with_standard_codes() { let mut sim = Sim::spawn(); + // Unknown method -> Method not found. let (code, _) = sim .call("frobnicate", json!({})) .expect_err("unknown method must error"); - assert_eq!(code, -32601); -} - -#[test] -fn wrong_jsonrpc_version_is_invalid_request() { - let mut sim = Sim::spawn(); + assert_eq!(code, -32601, "unknown method -> method not found"); + // A jsonrpc version other than "2.0" -> Invalid request. let resp = sim.raw(json!({"jsonrpc": "2.1", "method": "ping", "params": {}})); assert_eq!( resp["error"]["code"].as_i64(), From 0814d5e616a4bca0ab9236a91653651e4bb30925 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Tue, 7 Jul 2026 16:31:19 -0400 Subject: [PATCH 18/29] #305 - share serverdata.proto via symlink to the main crate instead of a duplicate copy --- calypso-sim/src/proto/serverdata.proto | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) mode change 100644 => 120000 calypso-sim/src/proto/serverdata.proto diff --git a/calypso-sim/src/proto/serverdata.proto b/calypso-sim/src/proto/serverdata.proto deleted file mode 100644 index e583c38..0000000 --- a/calypso-sim/src/proto/serverdata.proto +++ /dev/null @@ -1,13 +0,0 @@ -syntax = "proto3"; - -package serverdata.v2; - -message ServerData { - // ensure old type is reserved - reserved 1; - reserved "value"; - string unit = 2; - // time since unix epoch in MICROSECONDS - uint64 time_us = 3; - repeated float values = 4; -} diff --git a/calypso-sim/src/proto/serverdata.proto b/calypso-sim/src/proto/serverdata.proto new file mode 120000 index 0000000..455ad00 --- /dev/null +++ b/calypso-sim/src/proto/serverdata.proto @@ -0,0 +1 @@ +../../../src/proto/serverdata.proto \ No newline at end of file From 71db7ec3062e397612d7d5b0aa1857a41b089b9e Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Wed, 8 Jul 2026 13:07:24 -0400 Subject: [PATCH 19/29] #305 - remove the now-dead simulate path from the main calypso crate --- calypso-sim/src/simulatable_message.rs | 8 +- src/lib.rs | 2 - src/simulatable_message.rs | 269 ------------------------- src/simulate_data.rs | 3 - 4 files changed, 5 insertions(+), 277 deletions(-) delete mode 100644 src/simulatable_message.rs delete mode 100644 src/simulate_data.rs diff --git a/calypso-sim/src/simulatable_message.rs b/calypso-sim/src/simulatable_message.rs index cfd3401..c782978 100644 --- a/calypso-sim/src/simulatable_message.rs +++ b/calypso-sim/src/simulatable_message.rs @@ -1,7 +1,9 @@ #![allow(dead_code)] -// Vendored from main calypso (src/simulatable_message.rs). Some struct -// fields are read only by the codegen-expanded `create_simulated_components` -// initializer and not by sim code paths. +// The simulation data model. This lives only in calypso-sim: the main calypso +// decoder never drove the simulate path, so it was removed there when the sim +// was extracted. Some struct fields are read only by the codegen-expanded +// `create_simulated_components` initializer, not by hand-written sim code +// paths — hence the crate-level dead_code allow. use super::data::DecodeData; use rand::prelude::*; diff --git a/src/lib.rs b/src/lib.rs index 0ba8db4..8a7e503 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,5 @@ pub mod decode_data; pub mod encode_data; #[allow(clippy::all, clippy::pedantic)] pub mod proto; -pub mod simulatable_message; -pub mod simulate_data; pub mod imd_poll; diff --git a/src/simulatable_message.rs b/src/simulatable_message.rs deleted file mode 100644 index 87bb00e..0000000 --- a/src/simulatable_message.rs +++ /dev/null @@ -1,269 +0,0 @@ -use super::data::DecodeData; -use rand::prelude::*; -use regex::Regex; -use std::time::Instant; - -/********************* SIMULATE_MESSAGE.H *********************/ - -/** - * A `SimComponent` roughly corresponds to a `NetField` with properties inherited from `CANMsg` - */ -#[derive(Debug)] -pub struct SimComponent { - pub id: String, - pub points: Vec<SimPoint>, - pub points_intopic: Option<Vec<SimPoint>>, - pub unit: String, - pub name: String, - pub last_update: Instant, - pub desc: String, - pub sim_freq: f32, -} - -/** - * Corresponds to `CANPoint` of a `NetField` - */ -#[derive(Debug)] -pub struct SimPoint { - pub size: usize, - pub parse: Option<bool>, - pub signed: Option<bool>, - pub endianness: Option<String>, - pub default: Option<f32>, - pub ieee754_f32: Option<bool>, - pub value: SimValue, -} - -/** - * The mode of simulation and the real-time value of the `CANPoint` - */ -#[derive(Debug)] -pub enum SimValue { - /// Ranged mode where the value is within a min/max range and can include increment parameters. - Range { - min: f32, - max: f32, - inc_min: f32, - inc_max: f32, - round: bool, - current: f32, // current value in range mode - }, - /// Options mode where the value is selected from a set of predefined options. - Discrete { - options: Vec<(f32, f32)>, // List of option pairs. - current: f32, // currently selected option - }, -} - -/********************* SIMULATE_MESSAGE.C *********************/ - -impl SimComponent { - pub fn initialize(&mut self) { - self.points.iter_mut().for_each(SimPoint::initialize); - if let Some(points_intopic) = &mut self.points_intopic { - points_intopic.iter_mut().for_each(SimPoint::initialize); - } - } - - #[must_use] - pub fn should_update(&self) -> bool { - self.last_update.elapsed().as_millis() > self.sim_freq as u128 - } - - #[must_use] - pub fn get_decode_data(&self) -> DecodeData { - let topic_name = topic_values_inject(self); - DecodeData::new( - self.points.iter().map(SimPoint::get_value).collect(), - &topic_name, - &self.unit, - None, - ) - } - - pub fn update(&mut self) { - self.last_update = Instant::now(); - self.points.iter_mut().for_each(SimPoint::update); - if let Some(points_intopic) = &mut self.points_intopic { - points_intopic.iter_mut().for_each(SimPoint::update); - } - } -} - -impl SimPoint { - fn initialize(&mut self) { - match self.default { - Some(default_val) => match &mut self.value { - SimValue::Range { current, .. } | SimValue::Discrete { current, .. } => { - *current = default_val; - } - }, - None => self.value.initialize(), - } - } - - #[must_use] - pub fn get_value(&self) -> f32 { - self.value.get_value() - } - - fn update(&mut self) { - self.value.update(); - } -} - -impl SimValue { - pub fn initialize(&mut self) { - let mut rng = rand::rng(); - match self { - SimValue::Range { - min, - max, - inc_min, - round, - current, - .. - } => { - *current = rng.random_range(*min..*max); - if *inc_min != 0.0 { - *current = (*current / *inc_min).round() * *inc_min; // Round to nearest inc_min - } - if *round { - *current = current.round(); // Round to nearest whole number - } - } - SimValue::Discrete { options, current } => { - let idx = rng.random_range(0..options.len()); - *current = options[idx].0; - } - } - } - - #[must_use] - pub fn get_value(&self) -> f32 { - match self { - SimValue::Range { current, .. } | SimValue::Discrete { current, .. } => *current, - } - } - - /** - * Get a random offset within the range of `sim_inc_min` and `sim_inc_max` with a random sign. - * Use `sim_inc_min` as the offset if `sim_inc_min` == `sim_inc_max`. - * Rounds the offset to the nearest `sim_inc_min` if `sim_inc_min` is not 0. - */ - fn get_rand_offset(inc_min: f32, inc_max: f32) -> f32 { - let mut rng = rand::rng(); - let sign = if rng.random_bool(0.5) { 1.0 } else { -1.0 }; - - let offset: f32 = if (inc_min - inc_max).abs() < 0.0001 { - inc_min - } else { - let rand_offset = rng.random_range(inc_min..inc_max); - if inc_min == 0.0 { - rand_offset - } else { - (rand_offset / inc_min).round() * inc_min - } - }; - offset * sign - } - - fn update(&mut self) { - match self { - SimValue::Range { - min, - max, - inc_min, - inc_max, - round, - current, - } => { - const MAX_ATTEMPTS: u8 = 10; - - let cur = *current; - let min_val = *min; - let max_val = *max; - let inc_min_val = *inc_min; - let inc_max_val = *inc_max; - - // First, call get_rand_offset without partially borrowing each field - // let mut new_value = cur + SimValue::get_rand_offset(inc_min_val, inc_max_val); - let mut new_value = cur + SimValue::get_rand_offset(inc_min_val, inc_max_val); - - let mut attempts = 0; - while (new_value < min_val || new_value > max_val) && attempts < MAX_ATTEMPTS { - new_value = cur + SimValue::get_rand_offset(inc_min_val, inc_max_val); - attempts += 1; - } - - if attempts >= MAX_ATTEMPTS { - return; - } - - if inc_min_val != 0.0 { - new_value = (new_value / inc_min_val).round() * inc_min_val; - } - - if *round { - new_value = new_value.round(); - } - - *current = new_value; - } - SimValue::Discrete { options, current } => { - let mut rng = rand::rng(); - let prob = rng.random_range(0f32..1f32); - let mut new_value = None; - - for i in 0..options.len() { - let prob_floor = if i == 0 { 0f32 } else { options[i - 1].1 }; - let prob_ceiling = options[i].1; - if prob >= prob_floor && prob <= prob_ceiling { - new_value = Some(options[i].0); - break; - } - } - - *current = new_value.unwrap_or(-1f32); - } - } - } -} - -/** - * This helper function takes a `SimComponent`, injects the associated `CANPoint` values into the topic string - * e.g. "Hello/{}/World/{}" -> "Hello/{4}/World{5}" - * - * # Panics - * Panics if Regex compilation fails. - * - */ -#[must_use] -pub fn topic_values_inject(component: &SimComponent) -> String { - if let Some(points_intopic) = &component.points_intopic { - let component_name = &component.name; - // check: placeholder count lines up with in point vector array length - let re = Regex::new(r"\{\}").unwrap(); - if points_intopic.len() != re.find_iter(component_name).count() { - eprintln!( - "[error] in-topic points vector length does not line up with placeholder count" - ); - return component_name.clone(); - } - let in_topic_values: Vec<u32> = points_intopic - .iter() - .map(|p| p.get_value() as u32) - .collect(); - - // Replace {} placeholders with values - let mut value_iter = in_topic_values.iter(); - re.replace_all(component_name, |_: ®ex::Captures| { - value_iter - .next() - .map_or("{}".to_string(), std::string::ToString::to_string) - }) - .into_owned() - } else { - component.name.clone() - } -} diff --git a/src/simulate_data.rs b/src/simulate_data.rs deleted file mode 100644 index 79114a2..0000000 --- a/src/simulate_data.rs +++ /dev/null @@ -1,3 +0,0 @@ -#![allow(clippy::all, clippy::pedantic)] -use daedalus::gen_simulate_data; -gen_simulate_data!(); From a10e81ccf50db4c011e6ffe0f6a12e1446950d2f Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Wed, 8 Jul 2026 13:21:11 -0400 Subject: [PATCH 20/29] #305 - hoist the CAN data model into calypso-cangen so calypso-sim shares it DecodeData/EncodeData/FormatData now live in calypso-cangen; the calypso crate re-exports them through its own data module (macros unchanged) and calypso-sim imports DecodeData directly instead of vendoring a copy. --- calypso-sim/src/main.rs | 1 - calypso-sim/src/simulatable_message.rs | 2 +- .../calypso-cangen}/src/data.rs | 10 +- libs/calypso-cangen/src/lib.rs | 1 + src/data.rs | 131 +----------------- 5 files changed, 14 insertions(+), 131 deletions(-) rename {calypso-sim => libs/calypso-cangen}/src/data.rs (86%) diff --git a/calypso-sim/src/main.rs b/calypso-sim/src/main.rs index 197c331..2253691 100644 --- a/calypso-sim/src/main.rs +++ b/calypso-sim/src/main.rs @@ -1,5 +1,4 @@ mod cli; -mod data; mod keymap; mod modes; #[allow(clippy::all, clippy::pedantic)] diff --git a/calypso-sim/src/simulatable_message.rs b/calypso-sim/src/simulatable_message.rs index c782978..45d6f85 100644 --- a/calypso-sim/src/simulatable_message.rs +++ b/calypso-sim/src/simulatable_message.rs @@ -5,7 +5,7 @@ // `create_simulated_components` initializer, not by hand-written sim code // paths — hence the crate-level dead_code allow. -use super::data::DecodeData; +use calypso_cangen::data::DecodeData; use rand::prelude::*; use regex::Regex; use std::sync::LazyLock; diff --git a/calypso-sim/src/data.rs b/libs/calypso-cangen/src/data.rs similarity index 86% rename from calypso-sim/src/data.rs rename to libs/calypso-cangen/src/data.rs index 0dd54bf..baea9f7 100644 --- a/calypso-sim/src/data.rs +++ b/libs/calypso-cangen/src/data.rs @@ -1,6 +1,10 @@ -#![allow(dead_code)] -// Vendored from main calypso (src/data.rs). Some helpers are unused here; -// keep the file in sync with the upstream copy rather than trimming. +//! Shared CAN data-model types produced by this crate's generated code. +//! +//! `DecodeData` (off-car), `EncodeData` (into-car), and `FormatData` (value +//! transforms) live here so they can be shared: the `calypso` decoder re-exports +//! them through its own `crate::data` module (keeping the decode/encode codegen +//! macros unchanged), and `calypso-sim` — which can't depend on the `calypso` +//! crate, since that doesn't build off-Linux — imports `DecodeData` directly. use std::fmt; diff --git a/libs/calypso-cangen/src/lib.rs b/libs/calypso-cangen/src/lib.rs index 720e8c5..399309d 100644 --- a/libs/calypso-cangen/src/lib.rs +++ b/libs/calypso-cangen/src/lib.rs @@ -2,6 +2,7 @@ pub mod can_gen_decode; pub mod can_gen_encode; pub mod can_gen_simulate; pub mod can_types; +pub mod data; pub mod validate; /** * Path to CAN spec JSON files diff --git a/src/data.rs b/src/data.rs index 06bdc03..de2a07d 100644 --- a/src/data.rs +++ b/src/data.rs @@ -1,127 +1,6 @@ -use std::fmt; +//! CAN data-model types. The definitions live in `calypso-cangen` so `calypso-sim` +//! can share them without depending on this (Linux-only) crate; they are +//! re-exported here so `crate::data::…` and the decode/encode codegen macros are +//! unchanged. -/** - * Wrapper Class for Data coming off the car - */ -pub struct DecodeData { - pub value: Vec<f32>, - pub topic: String, - pub unit: String, - pub clients: Option<Vec<u16>>, -} - -/** - * Implementation for the format of the data for debugging purposes - */ -impl fmt::Display for DecodeData { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Overrides the string representation of the class. - - write!( - f, - "topic: {}, value: {:#?}, unit: {}, clients: {:#?}", - self.topic, self.value, self.unit, self.clients - ) - } -} - -/** - * Implementation fo the `DecodeData` methods - */ -impl DecodeData { - /** - * Constructor - * @param id: the id of the data - * @param value: the value of the data - * @param topic: the topic of the data - * @param clients: additional MQTT clients - */ - #[must_use] - pub fn new(value: Vec<f32>, topic: &str, unit: &str, clients: Option<Vec<u16>>) -> Self { - Self { - value, - topic: topic.to_string(), - unit: unit.to_string(), - clients, - } - } -} - -/** - * Wrapper Class for data going into the car - */ -pub struct EncodeData { - pub value: Vec<u8>, - pub id: u32, - pub is_ext: bool, -} - -/** - * Implementation for the format of the data for debugging purposes - */ -impl fmt::Display for EncodeData { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Overrides the string representation of the class. - - write!( - f, - "{}#{:?} (extended: {})", - self.id, self.value, self.is_ext - ) - } -} - -/** - * Implementation fo the `DecodeData` methods - */ -impl EncodeData { - /** - * Constructor - * @param id: the id of the can message - * @param value: the can message payload - * @param `is_ext`: whether the can message is extended format ID - */ - #[must_use] - pub fn new(id: u32, value: Vec<u8>, is_ext: bool) -> Self { - Self { value, id, is_ext } - } -} - -/** - * Class to contain the data formatting functions - * _d = a func to decode a value - * _e = its counterpart to encode a value for sending on CAN line - */ -pub struct FormatData {} - -impl FormatData { - /* General divide function */ - #[must_use] - pub fn divide_d(value: f32, divisor: f32) -> f32 { - value / divisor - } - #[must_use] - pub fn divide_e(value: f32, multiplicand: f32) -> f32 { - value * multiplicand - } - - /* Energy meter temperature is (degC = raw * 0.5) according to datasheet */ - #[must_use] - pub fn temperature_d(value: f32, _divisor: f32) -> f32 { - value * 0.5 - } - #[must_use] - pub fn temperature_e(value: f32, _multiplicand: f32) -> f32 { - value * 2.0 - } - - /* Energy meter temperature indices are determined by multiplexor signal */ - #[must_use] - pub fn multiply_d(value: f32, multiplicand: f32) -> f32 { - value * multiplicand - } - #[must_use] - pub fn multiply_e(value: f32, divisor: f32) -> f32 { - value / divisor - } -} +pub use calypso_cangen::data::{DecodeData, EncodeData, FormatData}; From 4bfb647c54cc58a77c516a467ac00f2cce35e67b Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Wed, 8 Jul 2026 13:32:35 -0400 Subject: [PATCH 21/29] #305 - rename the sim's "autonomous" mode to "mock" (flag, code, and docs) --auto becomes --mock, Owner::Auto becomes Owner::Mock, and the run_autonomous / modes::autonomous identifiers follow suit. The stream ownership wire string changes from "auto" to "mock" accordingly (the previous_owner / owner fields in claim/release/silence responses). --- calypso-sim/README.md | 20 ++++++------- calypso-sim/src/cli.rs | 20 ++++++------- calypso-sim/src/keymap.rs | 2 +- calypso-sim/src/main.rs | 14 +++++----- calypso-sim/src/modes/interactive.rs | 2 +- .../src/modes/{autonomous.rs => mock.rs} | 12 ++++---- calypso-sim/src/modes/mod.rs | 2 +- calypso-sim/src/modes/stream.rs | 4 +-- calypso-sim/src/raw_mode.rs | 2 +- calypso-sim/src/registry.rs | 28 +++++++++---------- calypso-sim/src/tests/cli.rs | 18 ++++++------ calypso-sim/src/warnings.rs | 2 +- calypso-sim/tests/stream.rs | 22 +++++++-------- 13 files changed, 74 insertions(+), 74 deletions(-) rename calypso-sim/src/modes/{autonomous.rs => mock.rs} (85%) diff --git a/calypso-sim/README.md b/calypso-sim/README.md index 43a8f57..2d42cb1 100644 --- a/calypso-sim/README.md +++ b/calypso-sim/README.md @@ -15,30 +15,30 @@ cargo build --release | Mode | Flag | What it does | |---|---|---| -| **Autonomous** | `--auto` | Heartbeat publishes for every CAN message with a `sim_freq` in the spec, at its configured frequency. Default when no other mode is chosen; defaults OFF when paired with `--key-map`, `--script`, or `--stream`. | +| **Mock** | `--mock` | Heartbeat publishes for every CAN message with a `sim_freq` in the spec, at its configured frequency. Default when no other mode is chosen; defaults OFF when paired with `--key-map`, `--script`, or `--stream`. | | **Interactive** | `--key-map FILE` | Raw-mode terminal — each keypress fires a configured topic. Press `Ctrl+C` to exit. | | **Script** | `--script FILE` (with `--key-map`) | Replay a sequence of keymap keys / `sleep N` lines from a text file, then exit. | | **Stream** | `--stream` | JSON-RPC 2.0 over stdin/stdout — for agent-driven injection. | -Pick at most one foreground mode: `--key-map` (with optional `--script`) or `--stream`. `--auto` may run alongside either as a background heartbeat — set it explicitly to override the default-off behavior in those modes. +Pick at most one foreground mode: `--key-map` (with optional `--script`) or `--stream`. `--mock` may run alongside either as a background heartbeat — set it explicitly to override the default-off behavior in those modes. ## Quick reference ``` cargo run -- --list-topics # enumerate topics, exit -cargo run # autonomous heartbeat +cargo run # mock heartbeat cargo run -- --key-map manual_sim_keymap.example.json -cargo run -- --key-map keys.json --auto # background heartbeat + interactive +cargo run -- --key-map keys.json --mock # background heartbeat + interactive cargo run -- --key-map keys.json --script play.txt cargo run -- --stream # JSON-RPC over stdio cargo run -- -u 10.0.0.5:1883 ... # remote broker ``` -The `--enable-topic <REGEX>` and `--disable-topic <REGEX>` flags filter which topics the autonomous heartbeat publishes (whitelist / blacklist; mutually exclusive). Topics that lack a `sim_freq` in the CAN spec are listed at startup as a `Warning topics (not simulated): ...` line and can only be reached via `--key-map` or `--stream`. +The `--enable-topic <REGEX>` and `--disable-topic <REGEX>` flags filter which topics the mock heartbeat publishes (whitelist / blacklist; mutually exclusive). Topics that lack a `sim_freq` in the CAN spec are listed at startup as a `Warning topics (not simulated): ...` line and can only be reached via `--key-map` or `--stream`. ## Topic-ownership model -Every topic has an owner: `auto` (default — autonomous publishes), `stream` (claimed by stream/keymap), or `silenced` (nobody publishes). The autonomous loop checks ownership before each publish, so claims from `--stream` or keymap modes cleanly override the heartbeat without fighting it. Releasing a topic returns it to `auto`. +Every topic has an owner: `mock` (default — mock publishes), `stream` (claimed by stream/keymap), or `silenced` (nobody publishes). The mock loop checks ownership before each publish, so claims from `--stream` or keymap modes cleanly override the heartbeat without fighting it. Releasing a topic returns it to `mock`. ## Keymap format (`--key-map` and `--script`) @@ -84,7 +84,7 @@ JSON-RPC 2.0 over stdio — one request per line on stdin, one response per line // stdout {"jsonrpc":"2.0","id":1,"result":{"ts_us":1735347123456789}} -{"jsonrpc":"2.0","id":2,"result":{"topic":"...","previous_owner":"auto","owner":"stream"}} +{"jsonrpc":"2.0","id":2,"result":{"topic":"...","previous_owner":"mock","owner":"stream"}} ... ``` @@ -92,7 +92,7 @@ JSON-RPC 2.0 over stdio — one request per line on stdin, one response per line |---|---|---| | `publish` | `{topic, value? \| values?, unit?}` | `{ts_us}` | | `claim` | `{topic}` | `{topic, previous_owner, owner}` | -| `release` | `{topic}` | `{topic, previous_owner, owner: "auto"}` | +| `release` | `{topic}` | `{topic, previous_owner, owner: "mock"}` | | `silence` | `{topic}` | `{topic, previous_owner, owner: "silenced"}` | | `status` | `{}` | `{overrides: [{topic, owner}, ...]}` | | `list_topics` | `{}` | `{topics: [{name, unit}, ...]}` | @@ -112,8 +112,8 @@ cargo test | Layer | Where | What it checks | |---|---|---| | Unit — keymap | `src/tests/keymap.rs` | The two fragile pieces of keymap logic: increment emit-then-wrap/saturate (`advance_increment`), and the serde `untagged` shape disambiguation, whose result depends on variant *order* (`step` → increment beats `value` → pinned; `sequence` wins). | -| Unit — CLI modes | `src/tests/cli.rs` | `run_autonomous` arbitration: heartbeat on by default, off under a foreground mode or `--list-topics`, forced on by explicit `--auto`. | -| Integration — stream | `tests/stream.rs` | Spawns the real `calypso-sim --stream` binary and checks the JSON-RPC contract (`list_topics` is non-empty, `publish` requires exactly one of `value`/`values`, malformed requests get `-32601`/`-32600`) plus an end-to-end ownership flow — claim → silence → release with the heartbeat running, which doubles as the regression guard for the `auto`/`stream`/`silenced` arbitration. | +| Unit — CLI modes | `src/tests/cli.rs` | `run_mock` arbitration: heartbeat on by default, off under a foreground mode or `--list-topics`, forced on by explicit `--mock`. | +| Integration — stream | `tests/stream.rs` | Spawns the real `calypso-sim --stream` binary and checks the JSON-RPC contract (`list_topics` is non-empty, `publish` requires exactly one of `value`/`values`, malformed requests get `-32601`/`-32600`) plus an end-to-end ownership flow — claim → silence → release with the heartbeat running, which doubles as the regression guard for the `mock`/`stream`/`silenced` arbitration. | The suite is deliberately small: each test guards logic a future change could silently break, not code that is obvious by reading it. Unit tests live in `src/tests/` — compiled into the crate under `cfg(test)`, so they reach internals via `use crate::…`; binary-driven tests live in the crate-root `tests/` dir, the only place Cargo sets `CARGO_BIN_EXE_calypso-sim`. diff --git a/calypso-sim/src/cli.rs b/calypso-sim/src/cli.rs index ec8a324..3c6c4c5 100644 --- a/calypso-sim/src/cli.rs +++ b/calypso-sim/src/cli.rs @@ -4,7 +4,7 @@ use clap::Parser; #[command( name = "calypso-sim", version, - about = "MQTT simulation tool for Calypso (autonomous, interactive, scripted, or streamed)" + about = "MQTT simulation tool for Calypso (mock, interactive, scripted, or streamed)" )] pub struct Cli { /// MQTT broker host:port @@ -20,17 +20,17 @@ pub struct Cli { #[arg(long)] pub list_topics: bool, - /// Run the autonomous heartbeat simulator. Default ON when no other mode + /// Run the mock heartbeat simulator. Default ON when no other mode /// is chosen; explicit when paired with --key-map / --script / --stream. #[arg(long)] - pub auto: bool, + pub mock: bool, - /// Disable the autonomous heartbeat for topics matching these regex + /// Disable the mock heartbeat for topics matching these regex /// patterns (blacklist mode) #[arg(long = "disable-topic", conflicts_with = "enable_topic")] pub disable_topic: Vec<String>, - /// Run the autonomous heartbeat ONLY for topics matching these regex + /// Run the mock heartbeat ONLY for topics matching these regex /// patterns (whitelist mode) #[arg(long = "enable-topic", conflicts_with = "disable_topic")] pub enable_topic: Vec<String>, @@ -47,18 +47,18 @@ pub struct Cli { pub script: Option<String>, /// Accept JSON-RPC 2.0 commands on stdin (one per line); replies on - /// stdout. Tracing/diagnostics go to stderr. `--auto` defaults OFF in + /// stdout. Tracing/diagnostics go to stderr. `--mock` defaults OFF in /// this mode unless explicitly set. #[arg(long, conflicts_with = "key_map")] pub stream: bool, } impl Cli { - /// Whether the autonomous heartbeat should run. - /// True if `--auto` was set, OR no other input mode was chosen and + /// Whether the mock heartbeat should run. + /// True if `--mock` was set, OR no other input mode was chosen and /// `--list-topics` wasn't requested. - pub fn run_autonomous(&self) -> bool { + pub fn run_mock(&self) -> bool { let any_other_mode = self.stream || self.key_map.is_some() || self.script.is_some(); - self.auto || (!any_other_mode && !self.list_topics) + self.mock || (!any_other_mode && !self.list_topics) } } diff --git a/calypso-sim/src/keymap.rs b/calypso-sim/src/keymap.rs index a7b719e..b024394 100644 --- a/calypso-sim/src/keymap.rs +++ b/calypso-sim/src/keymap.rs @@ -26,7 +26,7 @@ pub fn load_states(key_map_path: &str) -> Result<HashMap<char, KeyState>, String } /// Claim every topic referenced by `states` for `Owner::Stream` so the -/// autonomous heartbeat (if running) yields ownership. +/// mock heartbeat (if running) yields ownership. pub async fn claim_keymap_topics(states: &HashMap<char, KeyState>, registry: &SharedRegistry) { let mut reg = registry.write().await; for state in states.values() { diff --git a/calypso-sim/src/main.rs b/calypso-sim/src/main.rs index 2253691..c4a6ed3 100644 --- a/calypso-sim/src/main.rs +++ b/calypso-sim/src/main.rs @@ -48,16 +48,16 @@ async fn main() { let registry = TopicRegistry::shared(); - let auto_handle = if cli.run_autonomous() { + let mock_handle = if cli.run_mock() { // Validate the enable/disable regex patterns up front so a bad pattern // fails fast with a non-zero exit instead of silently disabling the - // entire autonomous heartbeat inside the spawned task. - let filter = modes::autonomous::FilterMode::build(&cli.enable_topic, &cli.disable_topic) + // entire mock heartbeat inside the spawned task. + let filter = modes::mock::FilterMode::build(&cli.enable_topic, &cli.disable_topic) .unwrap_or_else(|err| { eprintln!("Error: {err}"); exit(1); }); - Some(tokio::spawn(modes::autonomous::run( + Some(tokio::spawn(modes::mock::run( token.clone(), client.clone(), registry.clone(), @@ -77,10 +77,10 @@ async fn main() { tokio::time::sleep(Duration::from_millis(50)).await; token.cancel(); - if let Some(h) = auto_handle + if let Some(h) = mock_handle && let Err(e) = h.await { - tracing::error!("autonomous task panicked: {e}"); + tracing::error!("mock task panicked: {e}"); } if let Err(e) = poll_handle.await { tracing::error!("MQTT eventloop task panicked: {e}"); @@ -117,7 +117,7 @@ async fn run_foreground( ) .await } else { - // Pure --auto: wait for SIGINT, then exit. + // Pure --mock: wait for SIGINT, then exit. tokio::signal::ctrl_c() .await .map_err(|e| format!("ctrl+c handler failed: {e}")) diff --git a/calypso-sim/src/modes/interactive.rs b/calypso-sim/src/modes/interactive.rs index afb7377..62f7b62 100644 --- a/calypso-sim/src/modes/interactive.rs +++ b/calypso-sim/src/modes/interactive.rs @@ -14,7 +14,7 @@ use crate::raw_mode::{RawModeGuard, line_end}; use crate::registry::SharedRegistry; /// Run the interactive raw-mode keypress loop. Claims every keymap topic in -/// the registry so the autonomous loop (if running) yields ownership. +/// the registry so the mock loop (if running) yields ownership. pub async fn run( token: CancellationToken, client: AsyncClient, diff --git a/calypso-sim/src/modes/autonomous.rs b/calypso-sim/src/modes/mock.rs similarity index 85% rename from calypso-sim/src/modes/autonomous.rs rename to calypso-sim/src/modes/mock.rs index 1911bb3..84081dc 100644 --- a/calypso-sim/src/modes/autonomous.rs +++ b/calypso-sim/src/modes/mock.rs @@ -44,7 +44,7 @@ fn compile_patterns(patterns: &[String]) -> Result<Vec<Regex>, String> { } /// Background task: every 5ms, walk the simulated components and publish any -/// that are due (per `sim_freq`) AND owned by `Owner::Auto` in the registry. +/// that are due (per `sim_freq`) AND owned by `Owner::Mock` in the registry. /// /// Components owned by `Stream` or `Silenced` are skipped without advancing /// internal state, so they pick up where they would have been on `release`. @@ -60,9 +60,9 @@ pub async fn run( .collect(); if components.is_empty() { - info!("Autonomous: no components match the filter; nothing to simulate."); + info!("Mock: no components match the filter; nothing to simulate."); } else { - info!("Autonomous: simulating {} components", components.len()); + info!("Mock: simulating {} components", components.len()); } let mut interval = tokio::time::interval(Duration::from_millis(5)); @@ -70,7 +70,7 @@ pub async fn run( loop { tokio::select! { () = token.cancelled() => { - debug!("Autonomous: shutting down."); + debug!("Mock: shutting down."); break; } _ = interval.tick() => { @@ -78,13 +78,13 @@ pub async fn run( if !component.should_update() { continue; } - if !registry.read().await.auto_may_publish(&component.name) { + if !registry.read().await.mock_may_publish(&component.name) { continue; } component.update(); let data = component.get_decode_data(); if let Err(e) = publish_data(&client, &data.topic, &data.unit, &data.value).await { - warn!("Autonomous publish failed for {}: {e}", data.topic); + warn!("Mock publish failed for {}: {e}", data.topic); } } } diff --git a/calypso-sim/src/modes/mod.rs b/calypso-sim/src/modes/mod.rs index 563ceda..28a9722 100644 --- a/calypso-sim/src/modes/mod.rs +++ b/calypso-sim/src/modes/mod.rs @@ -1,6 +1,6 @@ pub mod auto_script; -pub mod autonomous; pub mod interactive; +pub mod mock; pub mod stream; use std::time::Duration; diff --git a/calypso-sim/src/modes/stream.rs b/calypso-sim/src/modes/stream.rs index bcc5a21..970d94f 100644 --- a/calypso-sim/src/modes/stream.rs +++ b/calypso-sim/src/modes/stream.rs @@ -14,7 +14,7 @@ use crate::registry::{Owner, SharedRegistry}; /// Methods: /// * `publish` — `{topic, value | values, unit?}` → `{ts_us}` /// * `claim` — `{topic}` → `{previous_owner, owner}` -/// * `release` — `{topic}` → `{previous_owner, owner}` (sets owner=auto) +/// * `release` — `{topic}` → `{previous_owner, owner}` (sets owner=mock) /// * `silence` — `{topic}` → `{previous_owner, owner}` /// * `status` — `{}` → `{overrides: [{topic, owner}, ...]}` /// * `list_topics` — `{}` → `{topics: [{name, unit}, ...]}` @@ -86,7 +86,7 @@ async fn handle_line(line: &str, client: &AsyncClient, registry: &SharedRegistry match request.method.as_str() { "publish" => handle_publish(id, request.params, client, registry).await, "claim" => handle_set(id, request.params, registry, Owner::Stream).await, - "release" => handle_set(id, request.params, registry, Owner::Auto).await, + "release" => handle_set(id, request.params, registry, Owner::Mock).await, "silence" => handle_set(id, request.params, registry, Owner::Silenced).await, "status" => handle_status(id, registry).await, "list_topics" => handle_list_topics(id), diff --git a/calypso-sim/src/raw_mode.rs b/calypso-sim/src/raw_mode.rs index bf13433..2bc3661 100644 --- a/calypso-sim/src/raw_mode.rs +++ b/calypso-sim/src/raw_mode.rs @@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; /// In raw mode the tty driver does not translate `\n` to `\r\n`, so we must -/// emit `\r\n` ourselves. In cooked mode (script / autonomous / stream) a +/// emit `\r\n` ourselves. In cooked mode (script / mock / stream) a /// literal `\r` renders as staircase output / `^M`, so we must emit `\n`. static RAW_MODE_ACTIVE: AtomicBool = AtomicBool::new(false); diff --git a/calypso-sim/src/registry.rs b/calypso-sim/src/registry.rs index daf1bb7..8ad4cc3 100644 --- a/calypso-sim/src/registry.rs +++ b/calypso-sim/src/registry.rs @@ -5,18 +5,18 @@ use tokio::sync::RwLock; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Owner { - /// Default state — autonomous heartbeat publishes this topic. - Auto, - /// Claimed by a streaming/keymap client; autonomous skips. + /// Default state — mock heartbeat publishes this topic. + Mock, + /// Claimed by a streaming/keymap client; mock skips. Stream, - /// Nobody publishes; both autonomous and stream skip. + /// Nobody publishes; both mock and stream skip. Silenced, } impl Owner { pub fn as_str(self) -> &'static str { match self { - Owner::Auto => "auto", + Owner::Mock => "mock", Owner::Stream => "stream", Owner::Silenced => "silenced", } @@ -25,7 +25,7 @@ impl Owner { pub type SharedRegistry = Arc<RwLock<TopicRegistry>>; -/// Per-topic ownership. Topics not in the map default to `Owner::Auto`. +/// Per-topic ownership. Topics not in the map default to `Owner::Mock`. #[derive(Debug, Default)] pub struct TopicRegistry { overrides: HashMap<String, Owner>, @@ -41,14 +41,14 @@ impl TopicRegistry { } pub fn owner(&self, topic: &str) -> Owner { - self.overrides.get(topic).copied().unwrap_or(Owner::Auto) + self.overrides.get(topic).copied().unwrap_or(Owner::Mock) } - /// Whether the autonomous heartbeat may publish `topic`. The heartbeat - /// only drives a topic while it is still `Auto`-owned; once a stream or + /// Whether the mock heartbeat may publish `topic`. The heartbeat + /// only drives a topic while it is still `Mock`-owned; once a stream or /// keymap driver claims or silences it, the heartbeat yields. - pub fn auto_may_publish(&self, topic: &str) -> bool { - self.owner(topic) == Owner::Auto + pub fn mock_may_publish(&self, topic: &str) -> bool { + self.owner(topic) == Owner::Mock } /// Whether a stream/keymap driver may publish `topic`. Drivers may publish @@ -57,11 +57,11 @@ impl TopicRegistry { self.owner(topic) != Owner::Silenced } - /// Set ownership; returns the previous owner. Setting back to `Auto` + /// Set ownership; returns the previous owner. Setting back to `Mock` /// removes the override entirely. pub fn set(&mut self, topic: &str, owner: Owner) -> Owner { let prev = self.owner(topic); - if owner == Owner::Auto { + if owner == Owner::Mock { self.overrides.remove(topic); } else { self.overrides.insert(topic.to_string(), owner); @@ -69,7 +69,7 @@ impl TopicRegistry { prev } - /// Snapshot of all non-`Auto` topic overrides, sorted by topic name. + /// Snapshot of all non-`Mock` topic overrides, sorted by topic name. pub fn snapshot(&self) -> Vec<(String, Owner)> { let mut entries: Vec<_> = self .overrides diff --git a/calypso-sim/src/tests/cli.rs b/calypso-sim/src/tests/cli.rs index ed1f99e..b9f653a 100644 --- a/calypso-sim/src/tests/cli.rs +++ b/calypso-sim/src/tests/cli.rs @@ -1,4 +1,4 @@ -//! CLI mode arbitration. `run_autonomous` decides when the background +//! CLI mode arbitration. `run_mock` decides when the background //! heartbeat runs; the regression this guards is adding a new foreground mode //! and forgetting to suppress the heartbeat under it. @@ -10,14 +10,14 @@ fn cli(args: &[&str]) -> Cli { } #[test] -fn run_autonomous_arbitrates_the_heartbeat_against_other_modes() { +fn run_mock_arbitrates_the_heartbeat_against_other_modes() { // On by default when nothing else is selected. - assert!(cli(&["calypso-sim"]).run_autonomous()); + assert!(cli(&["calypso-sim"]).run_mock()); // A foreground mode or --list-topics turns the heartbeat off... - assert!(!cli(&["calypso-sim", "--stream"]).run_autonomous()); - assert!(!cli(&["calypso-sim", "--key-map", "keys.json"]).run_autonomous()); - assert!(!cli(&["calypso-sim", "--list-topics"]).run_autonomous()); - // ...unless --auto forces it back on alongside that mode. - assert!(cli(&["calypso-sim", "--auto"]).run_autonomous()); - assert!(cli(&["calypso-sim", "--stream", "--auto"]).run_autonomous()); + assert!(!cli(&["calypso-sim", "--stream"]).run_mock()); + assert!(!cli(&["calypso-sim", "--key-map", "keys.json"]).run_mock()); + assert!(!cli(&["calypso-sim", "--list-topics"]).run_mock()); + // ...unless --mock forces it back on alongside that mode. + assert!(cli(&["calypso-sim", "--mock"]).run_mock()); + assert!(cli(&["calypso-sim", "--stream", "--mock"]).run_mock()); } diff --git a/calypso-sim/src/warnings.rs b/calypso-sim/src/warnings.rs index d5f1e58..b7e65d8 100644 --- a/calypso-sim/src/warnings.rs +++ b/calypso-sim/src/warnings.rs @@ -2,7 +2,7 @@ use calypso_cangen::CANGEN_SPEC_PATH; use calypso_cangen::can_types::OdysseyMsg; /// Print a comma-separated list of CAN message topics that have no -/// `sim_freq` in the spec — these are invisible to the autonomous simulator +/// `sim_freq` in the spec — these are invisible to the mock simulator /// and can only be published via `--key-map` / `--script` / `--stream`. /// /// Resolves the spec path relative to the current working directory; emits diff --git a/calypso-sim/tests/stream.rs b/calypso-sim/tests/stream.rs index d1d8b12..6a68b96 100644 --- a/calypso-sim/tests/stream.rs +++ b/calypso-sim/tests/stream.rs @@ -22,23 +22,23 @@ struct Sim { } impl Sim { - /// Spawn the sim in stream mode with the autonomous heartbeat off. + /// Spawn the sim in stream mode with the mock heartbeat off. fn spawn() -> Self { Self::spawn_inner(false) } - /// Spawn with `--auto` so the heartbeat runs alongside the stream driver. - fn spawn_with_auto() -> Self { + /// Spawn with `--mock` so the heartbeat runs alongside the stream driver. + fn spawn_with_mock() -> Self { Self::spawn_inner(true) } - fn spawn_inner(with_auto: bool) -> Self { + fn spawn_inner(with_mock: bool) -> Self { let mut cmd = Command::new(env!("CARGO_BIN_EXE_calypso-sim")); // A closed port: no broker is needed, and this keeps the sim off any // real broker a developer happens to be running. cmd.arg("-u").arg("127.0.0.1:47654").arg("--stream"); - if with_auto { - cmd.arg("--auto"); + if with_mock { + cmd.arg("--mock"); } cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -108,7 +108,7 @@ impl Sim { .unwrap_or_else(|(code, msg)| panic!("{method} failed: [{code}] {msg}")) } - /// Owner string for `topic` per `status`, or `None` when it is still `auto`. + /// Owner string for `topic` per `status`, or `None` when it is still `mock`. fn owner_of(&mut self, topic: &str) -> Option<String> { self.ok("status", json!({}))["overrides"] .as_array() @@ -182,10 +182,10 @@ fn malformed_requests_are_rejected_with_standard_codes() { #[test] fn ownership_isolation_through_claim_silence_release() { - // With the autonomous heartbeat running: a claimed topic is `stream`-owned + // With the mock heartbeat running: a claimed topic is `stream`-owned // (so the heartbeat yields), a silenced topic rejects even the driver, and - // release returns it to `auto`. - let mut sim = Sim::spawn_with_auto(); + // release returns it to `mock`. + let mut sim = Sim::spawn_with_mock(); let topic = "VCU/CarState/torque_limit_percentage"; sim.ok("claim", json!({"topic": topic})); @@ -213,7 +213,7 @@ fn ownership_isolation_through_claim_silence_release() { assert_eq!( sim.owner_of(topic), None, - "release returns the topic to auto (no override)" + "release returns the topic to mock (no override)" ); assert!( sim.ok("publish", json!({"topic": topic, "value": 0.5}))["ts_us"] From dee9877dfaee1d7798cfc9156635d9ee2390a9ca Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Wed, 8 Jul 2026 14:38:51 -0400 Subject: [PATCH 22/29] #305 - fix calypso-sim fmt CI step and harden stream JSON-RPC handling - Fmt CI step: drop `--all` so cargo-fmt stays scoped to the sim's detached workspace instead of walking path deps up into the main calypso workspace (whose generated `src/proto` never exists in the sim CI checkout). This was failing the calypso-sim CI deterministically at the Fmt step. - Stream mode: a request missing `method` is now an Invalid Request (-32600) rather than a parse error (-32700), matching JSON-RPC 2.0. - Docs: note that `publish` on a silenced topic returns `{skipped: "silenced"}`. --- .github/workflows/calypso-sim-ci.yml | 7 ++++++- calypso-sim/README.md | 2 +- calypso-sim/src/modes/stream.rs | 11 +++++++++-- calypso-sim/tests/stream.rs | 7 +++++++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/calypso-sim-ci.yml b/.github/workflows/calypso-sim-ci.yml index 737968e..293532b 100644 --- a/.github/workflows/calypso-sim-ci.yml +++ b/.github/workflows/calypso-sim-ci.yml @@ -37,6 +37,11 @@ jobs: - name: Test run: cargo test --verbose - name: Fmt - run: cargo fmt --all --check + # NOT `--all`: cargo-fmt with `--all` walks this crate's path deps + # (../libs/*) up into the main `calypso` workspace and tries to format + # its members — including `src/lib.rs`, whose `mod proto` resolves to a + # generated file the sim CI never builds. Plain `cargo fmt` stays scoped + # to calypso-sim's own module tree. + run: cargo fmt --check - name: Clippy run: cargo clippy --verbose --all -- -D warnings diff --git a/calypso-sim/README.md b/calypso-sim/README.md index 2d42cb1..a73b0d5 100644 --- a/calypso-sim/README.md +++ b/calypso-sim/README.md @@ -90,7 +90,7 @@ JSON-RPC 2.0 over stdio — one request per line on stdin, one response per line | Method | Params | Result | |---|---|---| -| `publish` | `{topic, value? \| values?, unit?}` | `{ts_us}` | +| `publish` | `{topic, value? \| values?, unit?}` | `{ts_us}`, or `{skipped: "silenced"}` on a silenced topic | | `claim` | `{topic}` | `{topic, previous_owner, owner}` | | `release` | `{topic}` | `{topic, previous_owner, owner: "mock"}` | | `silence` | `{topic}` | `{topic, previous_owner, owner: "silenced"}` | diff --git a/calypso-sim/src/modes/stream.rs b/calypso-sim/src/modes/stream.rs index 970d94f..e659a99 100644 --- a/calypso-sim/src/modes/stream.rs +++ b/calypso-sim/src/modes/stream.rs @@ -55,7 +55,8 @@ struct Request { jsonrpc: Option<String>, #[serde(default)] id: Option<Value>, - method: String, + #[serde(default)] + method: Option<String>, #[serde(default)] params: Value, } @@ -83,7 +84,13 @@ async fn handle_line(line: &str, client: &AsyncClient, registry: &SharedRegistry } let id = request.id.unwrap_or(Value::Null); - match request.method.as_str() { + // A request with no `method` is well-formed JSON but not a valid JSON-RPC + // call, so it is an Invalid Request (-32600), not a parse error (-32700). + let Some(method) = request.method else { + return error(id, ERR_INVALID_REQUEST, "missing `method`"); + }; + + match method.as_str() { "publish" => handle_publish(id, request.params, client, registry).await, "claim" => handle_set(id, request.params, registry, Owner::Stream).await, "release" => handle_set(id, request.params, registry, Owner::Mock).await, diff --git a/calypso-sim/tests/stream.rs b/calypso-sim/tests/stream.rs index 6a68b96..246e55c 100644 --- a/calypso-sim/tests/stream.rs +++ b/calypso-sim/tests/stream.rs @@ -178,6 +178,13 @@ fn malformed_requests_are_rejected_with_standard_codes() { Some(-32600), "jsonrpc != 2.0 must be rejected, got {resp}" ); + // A request missing `method` is valid JSON but not a valid JSON-RPC call. + let resp = sim.raw(json!({"jsonrpc": "2.0", "params": {}})); + assert_eq!( + resp["error"]["code"].as_i64(), + Some(-32600), + "missing method must be rejected as invalid request, got {resp}" + ); } #[test] From 53eadd8c5ceb71872131ba211b4cdf4bc826d4b9 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Wed, 8 Jul 2026 14:52:52 -0400 Subject: [PATCH 23/29] #305 - fix map_unwrap_or clippy lints exposed once the sim CI reached Clippy The calypso-sim CI previously died at the Fmt step, so its Clippy step never ran. With Fmt fixed, Clippy flags two `.map(f).unwrap_or(a)` chains on `Result` (main.rs, publish.rs) under the CI toolchain; rewrite them as `.map_or(a, f)`. --- calypso-sim/src/main.rs | 3 +-- calypso-sim/src/publish.rs | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/calypso-sim/src/main.rs b/calypso-sim/src/main.rs index c4a6ed3..c784674 100644 --- a/calypso-sim/src/main.rs +++ b/calypso-sim/src/main.rs @@ -160,8 +160,7 @@ fn connect_mqtt(host_url: &str) -> Result<(AsyncClient, EventLoop), String> { "Calypso-Sim-{}", SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) + .map_or(0, |d| d.as_millis()) ); let mut mqtt_opts = MqttOptions::new(client_id, host, port); diff --git a/calypso-sim/src/publish.rs b/calypso-sim/src/publish.rs index c9e1229..8c148a7 100644 --- a/calypso-sim/src/publish.rs +++ b/calypso-sim/src/publish.rs @@ -10,10 +10,7 @@ use rumqttc::v5::mqttbytes::QoS; /// that timestamp. Split out from [`publish_data`] so the encoding can be unit /// tested without a broker or client. fn encode_server_data(unit: &str, values: &[f32]) -> Result<(Vec<u8>, u64), String> { - let timestamp = UNIX_EPOCH - .elapsed() - .map(|d| d.as_micros() as u64) - .unwrap_or(0); + let timestamp = UNIX_EPOCH.elapsed().map_or(0, |d| d.as_micros() as u64); let mut payload = serverdata::ServerData::new(); payload.unit = unit.to_string(); From f30d88217b199c5314a3feea603b859dd5db335b Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Sun, 12 Jul 2026 18:07:07 -0400 Subject: [PATCH 24/29] #305 - unify calypso-sim keymap + script into one recursive scenario model An action is a named list of steps (publish | sleep | invoke another action). Interactive binds an action to a key; --play runs one by name (replacing --script); actions compose by invoking each other, so scenarios are one self-contained file. Removes the four keymap entry shapes, the parallel KeyEntry/KeyMode enums + increment cursor, and the separate script parser. --- calypso-sim/README.md | 67 +-- calypso-sim/manual_sim_buttons.keymap.json | 52 +- calypso-sim/manual_sim_keymap.example.json | 7 +- calypso-sim/src/cli.rs | 20 +- calypso-sim/src/keymap.rs | 521 ++++++++------------- calypso-sim/src/keymap/model.rs | 57 +++ calypso-sim/src/main.rs | 8 +- calypso-sim/src/modes/auto_script.rs | 64 --- calypso-sim/src/modes/interactive.rs | 82 +--- calypso-sim/src/modes/mod.rs | 2 +- calypso-sim/src/modes/replay.rs | 32 ++ calypso-sim/src/tests/cli.rs | 1 + calypso-sim/src/tests/keymap.rs | 124 ++--- calypso-sim/src/warnings.rs | 2 +- 14 files changed, 460 insertions(+), 579 deletions(-) create mode 100644 calypso-sim/src/keymap/model.rs delete mode 100644 calypso-sim/src/modes/auto_script.rs create mode 100644 calypso-sim/src/modes/replay.rs diff --git a/calypso-sim/README.md b/calypso-sim/README.md index a73b0d5..a4f8912 100644 --- a/calypso-sim/README.md +++ b/calypso-sim/README.md @@ -15,23 +15,23 @@ cargo build --release | Mode | Flag | What it does | |---|---|---| -| **Mock** | `--mock` | Heartbeat publishes for every CAN message with a `sim_freq` in the spec, at its configured frequency. Default when no other mode is chosen; defaults OFF when paired with `--key-map`, `--script`, or `--stream`. | -| **Interactive** | `--key-map FILE` | Raw-mode terminal — each keypress fires a configured topic. Press `Ctrl+C` to exit. | -| **Script** | `--script FILE` (with `--key-map`) | Replay a sequence of keymap keys / `sleep N` lines from a text file, then exit. | +| **Mock** | `--mock` | Heartbeat publishes for every CAN message with a `sim_freq` in the spec, at its configured frequency. Default when no other mode is chosen; defaults OFF when paired with `--key-map` or `--stream`. | +| **Interactive** | `--key-map FILE` | Raw-mode terminal — each keypress fires its bound action. Press `Ctrl+C` to exit. | +| **Replay** | `--play ACTION` (with `--key-map`) | Run one named action from the scenario file to completion (following invokes and sleeps), then exit. | | **Stream** | `--stream` | JSON-RPC 2.0 over stdin/stdout — for agent-driven injection. | -Pick at most one foreground mode: `--key-map` (with optional `--script`) or `--stream`. `--mock` may run alongside either as a background heartbeat — set it explicitly to override the default-off behavior in those modes. +Pick at most one foreground mode: `--key-map` (interactive, or replay with `--play ACTION`) or `--stream`. `--mock` may run alongside either as a background heartbeat — set it explicitly to override the default-off behavior in those modes. ## Quick reference ``` -cargo run -- --list-topics # enumerate topics, exit -cargo run # mock heartbeat -cargo run -- --key-map manual_sim_keymap.example.json -cargo run -- --key-map keys.json --mock # background heartbeat + interactive -cargo run -- --key-map keys.json --script play.txt -cargo run -- --stream # JSON-RPC over stdio -cargo run -- -u 10.0.0.5:1883 ... # remote broker +cargo run -- --list-topics # enumerate topics, exit +cargo run # mock heartbeat +cargo run -- --key-map manual_sim_buttons.keymap.json # interactive +cargo run -- --key-map manual_sim_buttons.keymap.json --mock # + background heartbeat +cargo run -- --key-map manual_sim_buttons.keymap.json --play demo # replay the "demo" action +cargo run -- --stream # JSON-RPC over stdio +cargo run -- -u 10.0.0.5:1883 ... # remote broker ``` The `--enable-topic <REGEX>` and `--disable-topic <REGEX>` flags filter which topics the mock heartbeat publishes (whitelist / blacklist; mutually exclusive). Topics that lack a `sim_freq` in the CAN spec are listed at startup as a `Warning topics (not simulated): ...` line and can only be reached via `--key-map` or `--stream`. @@ -40,36 +40,39 @@ The `--enable-topic <REGEX>` and `--disable-topic <REGEX>` flags filter which to Every topic has an owner: `mock` (default — mock publishes), `stream` (claimed by stream/keymap), or `silenced` (nobody publishes). The mock loop checks ownership before each publish, so claims from `--stream` or keymap modes cleanly override the heartbeat without fighting it. Releasing a topic returns it to `mock`. -## Keymap format (`--key-map` and `--script`) +## Scenario file (`--key-map` and `--play`) -A keymap is a JSON object mapping single-character keys to one of four entry shapes: +A scenario is a JSON object mapping action names to **actions**. An action is an ordered list of **steps**, optionally bound to a keyboard `key` and given a `desc`: ```json { - "v": "BMS/Pack/Voltage", - "p": {"topic": "VCU/CarState/home_mode", "value": 1, "desc": "home pulse"}, - "n": {"topic": "VCU/CarState/nero_index", "value": 0, "step": 1, "max": 5, "desc": "cycle nero"}, - "w": { - "desc": "wrap menu", - "sequence": [ - {"topic": "VCU/CarState/nero_index", "value": 0}, - {"topic": "VCU/CarState/home_mode", "value": 1, "delay_ms": 10}, - {"topic": "VCU/CarState/home_mode", "value": 0, "delay_ms": 100} + "enter": { "key": "e", "steps": [{"topic": "Wheel/Buttons/button_id", "value": 5}] }, + "home": { + "key": "h", + "desc": "home pulse", + "steps": [ + {"topic": "VCU/CarState/home_mode", "value": 1}, + {"sleep_ms": 10}, + {"topic": "VCU/CarState/home_mode", "value": 0} ] - } + }, + "menu_wrap": { "key": "w", "steps": ["enter", "home"] }, + "demo": { "desc": "run via --play demo", "steps": ["menu_wrap", {"sleep_ms": 500}, "menu_wrap"] } } ``` -| Form | What it does | -|---|---| -| Bare topic string | **Random** — publishes a fresh randomized value within the topic's sim bounds. Entries whose topic isn't in the spec are warned about on stderr and skipped at load. | -| Object with `value` | **Pinned** — publishes that exact number every keypress. `unit` is required for unknown topics. | -| Object with `step` | **Increment** — emits the starting value first, then advances by `step` each keypress. Start is `value` if set, else `min`, else `0`. `min`/`max` wrap independently when supplied. | -| Object with `sequence` | **Sequence** — publishes a scripted series of `(topic, value)` pairs with optional per-step `delay_ms`. | +Each **step** is one of three shapes, disambiguated purely by form (so there is no order-dependent parsing): -Every object form accepts an optional `desc` for the startup listing and inline log line. +| Step | Shape | What it does | +|---|---|---| +| **Publish** | `{"topic": …, "value": N}` or `{"topic": …, "values": [...]}` | Publish to a topic. Exactly one of `value` / `values`; optional `unit`. | +| **Sleep** | `{"sleep_ms": N}` | Wait N milliseconds before the next step. | +| **Invoke** | `"other_action"` (bare string) | Run another action's steps here — the reuse / composition primitive. | + +- **Interactive** (`--key-map FILE`): each action with a `key` fires on that keypress. +- **Replay** (`--key-map FILE --play ACTION`): run `ACTION` to completion — following its invokes and `sleep_ms` waits — then exit. A replay program is just an action, so there is no separate script file. -Script files (`--script`) contain one command per line: a single character (fires that key) or `sleep <ms>`. Blank lines and `#` comments are ignored. +The scenario is validated at load: every publish sets exactly one of `value` / `values`, every invoke names an action that exists, the invoke graph must be acyclic (so replays always terminate), and no two actions may claim the same `key`. ## Stream mode protocol (`--stream`) @@ -111,7 +114,7 @@ cargo test | Layer | Where | What it checks | |---|---|---| -| Unit — keymap | `src/tests/keymap.rs` | The two fragile pieces of keymap logic: increment emit-then-wrap/saturate (`advance_increment`), and the serde `untagged` shape disambiguation, whose result depends on variant *order* (`step` → increment beats `value` → pinned; `sequence` wins). | +| Unit — scenario | `src/tests/keymap.rs` | The fragile scenario logic: the serde `untagged` step-shape disambiguation (invoke / publish / sleep, by shape not order), and load-time validation — unknown or cyclic invokes are rejected, and publishes must set exactly one of `value` / `values`. | | Unit — CLI modes | `src/tests/cli.rs` | `run_mock` arbitration: heartbeat on by default, off under a foreground mode or `--list-topics`, forced on by explicit `--mock`. | | Integration — stream | `tests/stream.rs` | Spawns the real `calypso-sim --stream` binary and checks the JSON-RPC contract (`list_topics` is non-empty, `publish` requires exactly one of `value`/`values`, malformed requests get `-32601`/`-32600`) plus an end-to-end ownership flow — claim → silence → release with the heartbeat running, which doubles as the regression guard for the `mock`/`stream`/`silenced` arbitration. | diff --git a/calypso-sim/manual_sim_buttons.keymap.json b/calypso-sim/manual_sim_buttons.keymap.json index 7a07b8e..881ea6f 100644 --- a/calypso-sim/manual_sim_buttons.keymap.json +++ b/calypso-sim/manual_sim_buttons.keymap.json @@ -1,30 +1,32 @@ { - "h": { - "desc": "home / esc (button_id=0 + Cerberus home_mode pulse)", - "sequence": [ - {"topic": "Wheel/Buttons/button_id", "value": 0}, - {"topic": "VCU/CarState/home_mode", "value": 1, "delay_ms": 10}, - {"topic": "VCU/CarState/home_mode", "value": 0, "delay_ms": 100} + "home": { + "key": "h", + "desc": "home / esc (button 0 + Cerberus home_mode pulse)", + "steps": [ + { "topic": "Wheel/Buttons/button_id", "value": 0 }, + { "topic": "VCU/CarState/home_mode", "value": 1 }, + { "sleep_ms": 10 }, + { "topic": "VCU/CarState/home_mode", "value": 0 }, + { "sleep_ms": 100 } ] }, - "l": {"topic": "Wheel/Buttons/button_id", "value": 1, "unit": "", "desc": "left"}, - "2": {"topic": "Wheel/Buttons/button_id", "value": 2, "unit": "", "desc": "launch control toggle"}, - "j": {"topic": "Wheel/Buttons/button_id", "value": 3, "unit": "", "desc": "up (regen)"}, - "k": {"topic": "Wheel/Buttons/button_id", "value": 4, "unit": "", "desc": "down (regen)"}, - "e": {"topic": "Wheel/Buttons/button_id", "value": 5, "unit": "", "desc": "enter"}, - "r": {"topic": "Wheel/Buttons/button_id", "value": 6, "unit": "", "desc": "right"}, - "7": {"topic": "Wheel/Buttons/button_id", "value": 7, "unit": "", "desc": "traction control toggle"}, - "8": {"topic": "Wheel/Buttons/button_id", "value": 8, "unit": "", "desc": "up (torque)"}, - "9": {"topic": "Wheel/Buttons/button_id", "value": 9, "unit": "", "desc": "down (torque)"}, - "m": {"topic": "VCU/CarState/home_mode", "value": 0, "desc": "clear home_mode"}, - "n": {"topic": "VCU/CarState/nero_index", "value": 0, "desc": "reset nero_index"}, - "d": {"topic": "VCU/CarState/not_in_reverse", "value": 1, "desc": "forward drive"}, - "w": { - "desc": "force menu wrap (nero_index=0 + home_mode pulse)", - "sequence": [ - {"topic": "VCU/CarState/nero_index", "value": 0}, - {"topic": "VCU/CarState/home_mode", "value": 1, "delay_ms": 10}, - {"topic": "VCU/CarState/home_mode", "value": 0, "delay_ms": 50} - ] + "launch_toggle": { "key": "2", "desc": "launch control toggle", "steps": [{ "topic": "Wheel/Buttons/button_id", "value": 2 }] }, + "regen_up": { "key": "j", "desc": "up (regen)", "steps": [{ "topic": "Wheel/Buttons/button_id", "value": 3 }] }, + "regen_down": { "key": "k", "desc": "down (regen)", "steps": [{ "topic": "Wheel/Buttons/button_id", "value": 4 }] }, + "enter": { "key": "e", "desc": "enter", "steps": [{ "topic": "Wheel/Buttons/button_id", "value": 5 }] }, + "right": { "key": "r", "desc": "right", "steps": [{ "topic": "Wheel/Buttons/button_id", "value": 6 }] }, + + "reset_nero": { "key": "n", "desc": "reset nero_index", "steps": [{ "topic": "VCU/CarState/nero_index", "value": 0 }] }, + "forward_drive": { "key": "d", "desc": "forward drive", "steps": [{ "topic": "VCU/CarState/not_in_reverse", "value": 1 }] }, + + "menu_wrap": { + "key": "w", + "desc": "force menu wrap (reset nero_index, then home pulse)", + "steps": ["reset_nero", "home"] + }, + + "demo": { + "desc": "scripted demo: run via --play demo", + "steps": ["forward_drive", { "sleep_ms": 500 }, "menu_wrap", { "sleep_ms": 500 }, "enter"] } } diff --git a/calypso-sim/manual_sim_keymap.example.json b/calypso-sim/manual_sim_keymap.example.json index f7bcd2b..d83bb26 100644 --- a/calypso-sim/manual_sim_keymap.example.json +++ b/calypso-sim/manual_sim_keymap.example.json @@ -1,6 +1,5 @@ { - "v": "BMS/Pack/Voltage", - "c": "BMS/Pack/Current", - "s": "BMS/Pack/SOC", - "h": "BMS/Pack/Health" + "voltage": { "key": "v", "steps": [{ "topic": "BMS/Pack/Voltage", "value": 400, "unit": "V" }] }, + "current": { "key": "c", "steps": [{ "topic": "BMS/Pack/Current", "value": 10, "unit": "A" }] }, + "soc": { "key": "s", "steps": [{ "topic": "BMS/Pack/SOC", "value": 80, "unit": "%" }] } } diff --git a/calypso-sim/src/cli.rs b/calypso-sim/src/cli.rs index 3c6c4c5..6b6acfc 100644 --- a/calypso-sim/src/cli.rs +++ b/calypso-sim/src/cli.rs @@ -4,7 +4,7 @@ use clap::Parser; #[command( name = "calypso-sim", version, - about = "MQTT simulation tool for Calypso (mock, interactive, scripted, or streamed)" + about = "MQTT simulation tool for Calypso (mock, interactive, replay, or streamed)" )] pub struct Cli { /// MQTT broker host:port @@ -21,7 +21,7 @@ pub struct Cli { pub list_topics: bool, /// Run the mock heartbeat simulator. Default ON when no other mode - /// is chosen; explicit when paired with --key-map / --script / --stream. + /// is chosen; explicit when paired with --key-map or --stream. #[arg(long)] pub mock: bool, @@ -35,16 +35,16 @@ pub struct Cli { #[arg(long = "enable-topic", conflicts_with = "disable_topic")] pub enable_topic: Vec<String>, - /// Path to a JSON keymap file; runs the interactive raw-mode keypress - /// injector. Press Ctrl+C to exit. + /// Path to a JSON scenario file; runs the interactive raw-mode keypress + /// injector, firing each action on its bound `key`. Press Ctrl+C to exit. #[arg(short = 'k', long, value_name = "FILE")] pub key_map: Option<String>, - /// Run a scripted sequence of keymap keys then exit. Each line is either - /// a single character (fires that key) or `sleep <ms>` (waits). Blank - /// lines and lines starting with `#` are ignored. Requires `--key-map`. - #[arg(long, value_name = "FILE", requires = "key_map")] - pub script: Option<String>, + /// Run one named action from the `--key-map` scenario file, then exit + /// (deterministic replay). The action's steps run in order, including + /// nested invokes and `sleep_ms` waits. Requires `--key-map`. + #[arg(long, value_name = "ACTION", requires = "key_map")] + pub play: Option<String>, /// Accept JSON-RPC 2.0 commands on stdin (one per line); replies on /// stdout. Tracing/diagnostics go to stderr. `--mock` defaults OFF in @@ -58,7 +58,7 @@ impl Cli { /// True if `--mock` was set, OR no other input mode was chosen and /// `--list-topics` wasn't requested. pub fn run_mock(&self) -> bool { - let any_other_mode = self.stream || self.key_map.is_some() || self.script.is_some(); + let any_other_mode = self.stream || self.key_map.is_some() || self.play.is_some(); self.mock || (!any_other_mode && !self.list_topics) } } diff --git a/calypso-sim/src/keymap.rs b/calypso-sim/src/keymap.rs index b024394..8c33440 100644 --- a/calypso-sim/src/keymap.rs +++ b/calypso-sim/src/keymap.rs @@ -1,376 +1,267 @@ -use std::collections::HashMap; +//! Loading, validating, and running scenarios. The plain data types live in +//! the [`model`] submodule and are re-exported here, so every existing +//! `crate::keymap::…` path keeps working. + +mod model; +pub use model::{Scenario, Step}; + +use std::collections::{BTreeSet, HashMap}; use std::io::Write; use std::time::Duration; -use crate::simulatable_message::{SimComponent, SimValue}; -use crate::simulate_data::create_simulated_components; use rumqttc::v5::AsyncClient; -use serde::Deserialize; use crate::publish::publish_data; use crate::raw_mode::line_end; use crate::registry::{Owner, SharedRegistry}; -/// Build the topic states from a keymap file, erroring if the resulting set -/// is empty. -pub fn load_states(key_map_path: &str) -> Result<HashMap<char, KeyState>, String> { - let key_map = load_key_map(key_map_path)?; - if key_map.is_empty() { - return Err("Key map is empty".into()); - } - let states = build_topic_states(key_map); - if states.is_empty() { - return Err("No matching topics found for any key mapping".into()); - } - Ok(states) +/// Parse a scenario from JSON. Does not validate — call [`validate`] (or use +/// [`load_scenario`], which does both) before running it. +pub fn parse_scenario(content: &str) -> Result<Scenario, String> { + serde_json::from_str(content).map_err(|e| format!("Invalid scenario JSON: {e}")) } -/// Claim every topic referenced by `states` for `Owner::Stream` so the -/// mock heartbeat (if running) yields ownership. -pub async fn claim_keymap_topics(states: &HashMap<char, KeyState>, registry: &SharedRegistry) { - let mut reg = registry.write().await; - for state in states.values() { - match &state.mode { - KeyMode::Sequence { steps } => { - for step in steps { - reg.set(&step.topic, Owner::Stream); +/// Read a scenario file from `path`, parse it, and validate it. +pub fn load_scenario(path: &str) -> Result<Scenario, String> { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read scenario file '{path}': {e}"))?; + let scenario = parse_scenario(&content)?; + validate(&scenario)?; + Ok(scenario) +} + +/// Validate a scenario up front so a bad file fails fast with a clear message +/// instead of misbehaving mid-run: +/// * the scenario is non-empty, +/// * every publish step sets exactly one of `value` / `values`, +/// * every invoke step names an action that exists, and +/// * the invoke graph is acyclic, so every action terminates. +pub fn validate(scenario: &Scenario) -> Result<(), String> { + if scenario.is_empty() { + return Err("Scenario is empty".into()); + } + for (name, action) in scenario { + for step in &action.steps { + match step { + Step::Invoke(target) if !scenario.contains_key(target) => { + return Err(format!("action '{name}' invokes unknown action '{target}'")); } - } - _ => { - reg.set(&state.topic, Owner::Stream); + Step::Publish { + topic, + value, + values, + .. + } => check_publish(name, topic, value.as_ref(), values.as_deref())?, + Step::Invoke(_) | Step::Sleep { .. } => {} } } } -} - -/// A keymap entry. Four forms: -/// * Bare topic string — random value within sim bounds (requires the topic -/// to be in the auto-generated simulated-components list). -/// * Object with `value` — pins that exact number on every keypress. -/// `unit` is required for topics not in the generated simulated-components -/// list. -/// * Object with `step` — increment mode. Publishes `value` (or `min`, or 0) -/// on first press, then advances by `step` each press, wrapping -/// independently when each bound is supplied. As with pinned mode, a known -/// topic uses the sim component's unit; the `unit` field applies only to -/// topics not in the generated simulated-components list. -/// * Object with `sequence` — publishes a scripted series of (topic, value) -/// pairs on each keypress, with optional per-step `delay_ms` before publish. -/// -/// Every object form also accepts an optional `desc` string that is shown in -/// the startup listing and inline with each publish log line. -#[derive(Debug, Deserialize)] -#[serde(untagged)] -pub enum KeyEntry { - TopicOnly(String), - /// Sequence is distinguished by the `sequence` field — check first. - Sequence { - sequence: Vec<SequenceStep>, - #[serde(default)] - desc: Option<String>, - }, - /// Checked before Pinned: if `step` is present, this is increment mode. - Increment { - topic: String, - value: Option<f32>, - step: f32, - min: Option<f32>, - max: Option<f32>, - unit: Option<String>, - #[serde(default)] - desc: Option<String>, - }, - Pinned { - topic: String, - value: f32, - unit: Option<String>, - #[serde(default)] - desc: Option<String>, - }, -} - -#[derive(Debug, Deserialize, Clone)] -pub struct SequenceStep { - pub topic: String, - pub value: f32, - pub unit: Option<String>, - /// Milliseconds to wait *before* publishing this step. Defaults to 0. - #[serde(default)] - pub delay_ms: u64, -} - -#[derive(Debug, Clone)] -pub enum KeyMode { - Random, - Pinned { - value: f32, - }, - Increment { - current: f32, - step: f32, - min: Option<f32>, - max: Option<f32>, - }, - Sequence { - steps: Vec<SequenceStep>, - }, -} - -#[derive(Debug, Clone)] -pub struct KeyState { - pub topic: String, - pub unit: String, - pub component: Option<SimComponent>, - pub mode: KeyMode, - pub desc: Option<String>, -} - -/// Format `" [unit]"` suffix, or empty string when the unit is empty/missing. -pub fn unit_suffix(unit: &str) -> String { - if unit.is_empty() { - String::new() - } else { - format!(" [{unit}]") + for name in scenario.keys() { + detect_cycle(scenario, name, &mut Vec::new())?; } + Ok(()) } -/// Format `" — desc"` suffix, or empty string when desc is missing/empty. -pub fn desc_suffix(desc: Option<&str>) -> String { - desc.filter(|d| !d.is_empty()) - .map(|d| format!(" — {d}")) - .unwrap_or_default() +fn check_publish( + action: &str, + topic: &str, + value: Option<&f32>, + values: Option<&[f32]>, +) -> Result<(), String> { + match (value, values) { + (Some(_), Some(_)) => Err(format!( + "action '{action}': step for '{topic}' sets both `value` and `values`" + )), + (None, None) => Err(format!( + "action '{action}': step for '{topic}' sets neither `value` nor `values`" + )), + (None, Some([])) => Err(format!( + "action '{action}': step for '{topic}' has empty `values`" + )), + _ => Ok(()), + } } -pub fn parse_key_map(content: &str) -> Result<HashMap<char, KeyEntry>, String> { - let raw: HashMap<String, KeyEntry> = - serde_json::from_str(content).map_err(|e| format!("Invalid key map JSON: {e}"))?; - raw.into_iter() - .map(|(key_str, entry)| { - let mut chars = key_str.chars(); - let (Some(ch), None) = (chars.next(), chars.next()) else { - return Err(format!( - "Key mapping keys must be single characters, got: '{key_str}'" - )); - }; - Ok((ch, entry)) - }) - .collect() +/// Depth-first search tracking the current invoke path; a name already on the +/// path is a cycle. Runs before any action executes, so [`flatten`] can then +/// recurse without a visited-set. +fn detect_cycle(scenario: &Scenario, name: &str, path: &mut Vec<String>) -> Result<(), String> { + if path.iter().any(|n| n == name) { + path.push(name.to_string()); + return Err(format!("cyclic action invocation: {}", path.join(" -> "))); + } + path.push(name.to_string()); + if let Some(action) = scenario.get(name) { + for step in &action.steps { + if let Step::Invoke(target) = step { + detect_cycle(scenario, target, path)?; + } + } + } + path.pop(); + Ok(()) } -pub fn load_key_map(path: &str) -> Result<HashMap<char, KeyEntry>, String> { - let content = std::fs::read_to_string(path) - .map_err(|e| format!("Failed to read key map file '{path}': {e}"))?; - parse_key_map(&content) +/// A flattened, executable step: invokes have been resolved away, leaving only +/// publishes and waits. +enum Prim { + Publish { + topic: String, + values: Vec<f32>, + unit: String, + }, + Sleep(u64), } -pub fn build_topic_states(key_map: HashMap<char, KeyEntry>) -> HashMap<char, KeyState> { - let components = create_simulated_components(); - let mut result = HashMap::new(); - for (key, entry) in key_map { - let (topic, mode, unit_override, desc) = match entry { - KeyEntry::TopicOnly(t) => (t, KeyMode::Random, None, None), - KeyEntry::Pinned { - topic, - value, - unit, - desc, - } => (topic, KeyMode::Pinned { value }, unit, desc), - KeyEntry::Increment { +/// Expand `name` into a linear list of [`Prim`]s, inlining every invoked +/// action. Safe against infinite recursion because [`validate`] has already +/// proven the graph acyclic and every invoke target present. +fn flatten(scenario: &Scenario, name: &str, out: &mut Vec<Prim>) { + let Some(action) = scenario.get(name) else { + return; + }; + for step in &action.steps { + match step { + Step::Invoke(target) => flatten(scenario, target, out), + Step::Publish { topic, value, - step, - min, - max, + values, unit, - desc, - } => { - let start = value.or(min).unwrap_or(0.0); - ( - topic, - KeyMode::Increment { - current: start, - step, - min, - max, - }, - unit, - desc, - ) - } - KeyEntry::Sequence { sequence, desc } => { - if sequence.is_empty() { - eprintln!("Warning: sequence for key '{key}' is empty — skipping"); - continue; - } - let summary_topic = format!("<sequence of {} steps>", sequence.len()); - ( - summary_topic, - KeyMode::Sequence { steps: sequence }, - None, - desc, - ) - } - }; - - let component = components.iter().find(|c| c.name == topic).cloned(); - - if matches!(mode, KeyMode::Random) && component.is_none() { - eprintln!( - "Warning: random-mode key '{key}' maps to topic '{topic}' which is \ - not in the generated sim components — skipping" - ); - continue; + } => out.push(Prim::Publish { + topic: topic.clone(), + values: values + .clone() + .or_else(|| value.map(|v| vec![v])) + .unwrap_or_default(), + unit: unit.clone().unwrap_or_default(), + }), + Step::Sleep { sleep_ms } => out.push(Prim::Sleep(*sleep_ms)), } - - let unit = match component.as_ref().map(|c| c.unit.clone()).or(unit_override) { - Some(u) => u, - // Sequence mode publishes per-step topics with their own units; - // the top-level unit is unused, so missing it is fine. - None if matches!(mode, KeyMode::Sequence { .. }) => String::new(), - None => { - eprintln!( - "Warning: key '{key}' maps to unknown topic '{topic}' \ - with no `unit` provided — skipping" - ); - continue; - } - }; - - result.insert( - key, - KeyState { - topic, - unit, - component, - mode, - desc, - }, - ); } - result } -/// Generate a fresh random value within each point's defined bounds. Delegates -/// to `SimValue::initialize` (which ignores any `default` and always -/// randomizes), then clamps `Range` values back into `[min, max]` in case the -/// inc/round snapping pushed them just outside. -pub fn randomize_component(component: &mut SimComponent) { - for point in &mut component.points { - point.value.initialize(); - if let SimValue::Range { - min, max, current, .. - } = &mut point.value +/// Every topic `name` publishes to, following invokes. Used to claim ownership +/// so the mock heartbeat yields those topics. +#[must_use] +pub fn collect_topics(scenario: &Scenario, name: &str) -> BTreeSet<String> { + let mut prims = Vec::new(); + flatten(scenario, name, &mut prims); + prims + .into_iter() + .filter_map(|p| match p { + Prim::Publish { topic, .. } => Some(topic), + Prim::Sleep(_) => None, + }) + .collect() +} + +/// The `key -> action name` bindings for interactive mode, erroring if two +/// actions claim the same key. +pub fn key_bindings(scenario: &Scenario) -> Result<HashMap<char, String>, String> { + let mut map = HashMap::new(); + for (name, action) in scenario { + if let Some(key) = action.key + && let Some(prev) = map.insert(key, name.clone()) { - *current = current.clamp(*min, *max); + return Err(format!( + "key '{key}' is bound to both '{prev}' and '{name}'" + )); } } + Ok(map) } -/// Advance an increment-mode state and return the value to publish *before* -/// the advance (so the first press emits the starting value). -pub fn advance_increment(current: &mut f32, step: f32, min: Option<f32>, max: Option<f32>) -> f32 { - let emitted = *current; - let mut next = *current + step; - if let Some(hi) = max - && next > hi - { - next = min.unwrap_or(hi); - } - if let Some(lo) = min - && next < lo - { - next = max.unwrap_or(lo); +/// Claim every topic reachable from `action_names` for [`Owner::Stream`], so a +/// running mock heartbeat yields those topics to this driver. +pub async fn claim_topics<'a>( + scenario: &Scenario, + action_names: impl IntoIterator<Item = &'a str>, + registry: &SharedRegistry, +) { + let mut topics = BTreeSet::new(); + for name in action_names { + topics.extend(collect_topics(scenario, name)); } - *current = next; - emitted -} - -/// Resolve the (topic, unit, values) for single-shot modes. Sequence mode -/// returns `None` and is handled by the caller. -fn resolve_single_publish(state: &mut KeyState) -> Option<(String, String, Vec<f32>)> { - match &mut state.mode { - KeyMode::Random => { - let component = state.component.as_mut()?; - randomize_component(component); - let data = component.get_decode_data(); - Some((data.topic, data.unit, data.value)) - } - KeyMode::Pinned { value } => Some((state.topic.clone(), state.unit.clone(), vec![*value])), - KeyMode::Increment { - current, - step, - min, - max, - } => { - let v = advance_increment(current, *step, *min, *max); - Some((state.topic.clone(), state.unit.clone(), vec![v])) - } - KeyMode::Sequence { .. } => None, + let mut reg = registry.write().await; + for topic in &topics { + reg.set(topic, Owner::Stream); } } -/// Resolve the value(s) for this keypress and publish to the broker. -/// Sequence mode walks the scripted steps with per-step delays; other modes -/// emit a single message. Logs each publish to stdout. -/// -/// Topics owned by `Silenced` in the registry are skipped silently. -pub async fn publish_injection( - ch: char, - state: &mut KeyState, +/// Run `name`'s steps in order: publishes go to the broker (skipping any topic +/// the registry has silenced), sleeps wait, invokes are inlined. Logs each +/// publish to stdout. +pub async fn run_action( + scenario: &Scenario, + name: &str, client: &AsyncClient, registry: &SharedRegistry, ) { - if let KeyMode::Sequence { steps } = &state.mode { - if let Some(desc) = state.desc.as_deref() { - let nl = line_end(); - print!("[{ch}] {desc}{nl}"); - let _ = std::io::stdout().flush(); - } - let steps = steps.clone(); - for step in steps { - if !registry.read().await.driver_may_publish(&step.topic) { - continue; - } - if step.delay_ms > 0 { - tokio::time::sleep(Duration::from_millis(step.delay_ms)).await; - } - let unit = step.unit.clone().unwrap_or_default(); - log_and_publish(ch, &step.topic, &unit, &[step.value], None, client).await; - } - return; + if let Some(desc) = scenario.get(name).and_then(|a| a.desc.as_deref()) { + print!("[{name}] {desc}{}", line_end()); + let _ = std::io::stdout().flush(); } - if !registry.read().await.driver_may_publish(&state.topic) { - return; - } - - if let Some((topic, unit, values)) = resolve_single_publish(state) { - log_and_publish(ch, &topic, &unit, &values, state.desc.as_deref(), client).await; + let mut prims = Vec::new(); + flatten(scenario, name, &mut prims); + for prim in prims { + match prim { + Prim::Sleep(ms) if ms > 0 => tokio::time::sleep(Duration::from_millis(ms)).await, + Prim::Sleep(_) => {} + Prim::Publish { + topic, + values, + unit, + } => { + if registry.read().await.driver_may_publish(&topic) { + log_and_publish(name, &topic, &unit, &values, client).await; + } + } + } } } async fn log_and_publish( - ch: char, + action: &str, topic: &str, unit: &str, values: &[f32], - desc: Option<&str>, client: &AsyncClient, ) { let nl = line_end(); - let desc_s = desc_suffix(desc); - + let unit_s = if unit.is_empty() { + String::new() + } else { + format!(" {unit}") + }; match publish_data(client, topic, unit, values).await { Ok(_) => { - let values_str: Vec<String> = values.iter().map(|v| format!("{v:.2}")).collect(); - print!( - "[{ch}] {topic} = [{}] {unit}{desc_s}{nl}", - values_str.join(", ") - ); - } - Err(e) => { - print!("[{ch}] error publishing {topic}: {e}{nl}"); + let vals: Vec<String> = values.iter().map(|v| format!("{v:.2}")).collect(); + print!("[{action}] {topic} = [{}]{unit_s}{nl}", vals.join(", ")); } + Err(e) => print!("[{action}] error publishing {topic}: {e}{nl}"), } let _ = std::io::stdout().flush(); } + +/// Print the interactive key listing: each key-bound action with its step +/// count and description. +pub fn print_listing(scenario: &Scenario, keys: &HashMap<char, String>) { + println!("Key bindings:"); + let mut bound: Vec<(char, &String)> = keys.iter().map(|(k, name)| (*k, name)).collect(); + bound.sort_unstable_by_key(|(k, _)| *k); + for (key, name) in bound { + let action = &scenario[name]; + let desc = action + .desc + .as_deref() + .filter(|d| !d.is_empty()) + .map(|d| format!(" — {d}")) + .unwrap_or_default(); + let n = action.steps.len(); + let plural = if n == 1 { "" } else { "s" }; + println!(" {key} → {name} ({n} step{plural}){desc}"); + } + println!(); +} diff --git a/calypso-sim/src/keymap/model.rs b/calypso-sim/src/keymap/model.rs new file mode 100644 index 0000000..406edc9 --- /dev/null +++ b/calypso-sim/src/keymap/model.rs @@ -0,0 +1,57 @@ +//! The scenario data model — plain deserialized types, no logic. The functions +//! that load, validate, and run these live in the parent `keymap` module. + +use std::collections::HashMap; + +use serde::Deserialize; + +/// A scenario file: a set of named [`Action`]s keyed by name. +/// +/// A scenario drives the interactive (`--key-map`) and replay (`--play`) modes. +/// There is no separate "script" concept: a replay program is itself just an +/// [`Action`] whose steps run in order. +pub type Scenario = HashMap<String, Action>; + +/// One named command: an ordered list of [`Step`]s, optionally bound to a +/// single keyboard `key` for interactive mode. +#[derive(Debug, Clone, Deserialize)] +pub struct Action { + /// Keyboard key that fires this action in interactive mode. Actions with + /// no `key` are still reachable via `--play` or another action's invoke + /// step, so pure replay programs need no key. + #[serde(default)] + pub key: Option<char>, + + /// Human-readable label shown in the interactive listing and in the log + /// line printed when the action fires. + #[serde(default)] + pub desc: Option<String>, + + /// The steps run, in order, each time the action fires. + pub steps: Vec<Step>, +} + +/// One step of an [`Action`]. The three forms are disambiguated purely by +/// shape — a bare string, an object with `topic`, or an object with +/// `sleep_ms` — so there is no order-dependent matching to get wrong. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Step { + /// Run another action by name — the composition/reuse primitive. + Invoke(String), + + /// Publish to `topic`. Exactly one of `value` (scalar) or `values` (array) + /// must be set; `unit` defaults to empty. This is validated at load. + Publish { + topic: String, + #[serde(default)] + value: Option<f32>, + #[serde(default)] + values: Option<Vec<f32>>, + #[serde(default)] + unit: Option<String>, + }, + + /// Wait `sleep_ms` milliseconds before the next step. + Sleep { sleep_ms: u64 }, +} diff --git a/calypso-sim/src/main.rs b/calypso-sim/src/main.rs index c784674..3f7b129 100644 --- a/calypso-sim/src/main.rs +++ b/calypso-sim/src/main.rs @@ -100,14 +100,14 @@ async fn run_foreground( ) -> Result<(), String> { if cli.stream { modes::stream::run(token.clone(), client.clone(), registry.clone()).await - } else if let Some(script_path) = &cli.script { - // clap enforces `--script requires --key-map` (see cli.rs), so a missing + } else if let Some(action) = &cli.play { + // clap enforces `--play requires --key-map` (see cli.rs), so a missing // key map here is an impossible state, not a reachable runtime error. let key_map_path = cli .key_map .as_deref() - .expect("clap enforces --script requires --key-map"); - modes::auto_script::run(client.clone(), key_map_path, script_path, registry.clone()).await + .expect("clap enforces --play requires --key-map"); + modes::replay::run(client.clone(), key_map_path, action, registry.clone()).await } else if let Some(key_map_path) = &cli.key_map { modes::interactive::run( token.clone(), diff --git a/calypso-sim/src/modes/auto_script.rs b/calypso-sim/src/modes/auto_script.rs deleted file mode 100644 index 10cd7b4..0000000 --- a/calypso-sim/src/modes/auto_script.rs +++ /dev/null @@ -1,64 +0,0 @@ -use std::time::Duration; - -use rumqttc::v5::AsyncClient; - -use crate::keymap::{claim_keymap_topics, load_states, publish_injection}; -use crate::registry::SharedRegistry; - -/// Run a scripted sequence from a text file, then return. Each line is -/// either a single character (fires that key from the keymap) or -/// `sleep <ms>`. Blank lines and lines starting with `#` are ignored. -pub async fn run( - client: AsyncClient, - key_map_path: &str, - script_path: &str, - registry: SharedRegistry, -) -> Result<(), String> { - let mut states = load_states(key_map_path)?; - claim_keymap_topics(&states, ®istry).await; - - let script = std::fs::read_to_string(script_path) - .map_err(|e| format!("Failed to read script '{script_path}': {e}"))?; - - println!("Running scripted sequence from {script_path} ..."); - println!(); - - for (lineno, raw_line) in script.lines().enumerate() { - let line = raw_line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - if let Some(rest) = line.strip_prefix("sleep ") { - // Deterministic replay: a malformed directive aborts the run rather - // than silently skipping it and throwing off downstream timing. - let ms: u64 = rest.trim().parse().map_err(|_| { - format!( - "script: line {}: bad sleep arg '{}'", - lineno + 1, - rest.trim() - ) - })?; - tokio::time::sleep(Duration::from_millis(ms)).await; - continue; - } - let mut chars = line.chars(); - let Some(ch) = chars.next() else { continue }; - if chars.next().is_some() { - return Err(format!( - "script: line {}: expected single char or 'sleep N', got '{line}'", - lineno + 1 - )); - } - let Some(state) = states.get_mut(&ch) else { - return Err(format!( - "script: line {}: no binding for key '{ch}'", - lineno + 1 - )); - }; - publish_injection(ch, state, &client, ®istry).await; - } - - // Allow broker time to flush. - tokio::time::sleep(Duration::from_millis(100)).await; - Ok(()) -} diff --git a/calypso-sim/src/modes/interactive.rs b/calypso-sim/src/modes/interactive.rs index 62f7b62..3585689 100644 --- a/calypso-sim/src/modes/interactive.rs +++ b/calypso-sim/src/modes/interactive.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::io::Write; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; @@ -6,25 +5,29 @@ use futures_util::StreamExt; use rumqttc::v5::AsyncClient; use tokio_util::sync::CancellationToken; -use crate::keymap::{ - KeyMode, KeyState, claim_keymap_topics, desc_suffix, load_states, publish_injection, - unit_suffix, -}; +use crate::keymap::{claim_topics, key_bindings, load_scenario, print_listing, run_action}; use crate::raw_mode::{RawModeGuard, line_end}; use crate::registry::SharedRegistry; -/// Run the interactive raw-mode keypress loop. Claims every keymap topic in -/// the registry so the mock loop (if running) yields ownership. +/// Run the interactive raw-mode keypress loop. Each key-bound action fires on +/// its key; the action's topics are claimed in the registry so the mock loop +/// (if running) yields ownership. pub async fn run( token: CancellationToken, client: AsyncClient, - key_map_path: &str, + scenario_path: &str, registry: SharedRegistry, ) -> Result<(), String> { - let mut states = load_states(key_map_path)?; - claim_keymap_topics(&states, ®istry).await; + let scenario = load_scenario(scenario_path)?; + let keys = key_bindings(&scenario)?; + if keys.is_empty() { + return Err( + "Scenario has no key-bound actions; add a `key` to an action, or use --play".into(), + ); + } + claim_topics(&scenario, keys.values().map(String::as_str), ®istry).await; - print_listing(&states); + print_listing(&scenario, &keys); println!("Press mapped keys to inject. Ctrl+C to exit."); println!(); @@ -47,8 +50,8 @@ pub async fn run( kind: KeyEventKind::Press, .. }))) => { - if let Some(state) = states.get_mut(&ch) { - publish_injection(ch, state, &client, ®istry).await; + if let Some(name) = keys.get(&ch) { + run_action(&scenario, name, &client, ®istry).await; } } Some(Err(e)) => { @@ -67,56 +70,3 @@ pub async fn run( println!("Shutting down..."); Ok(()) } - -fn print_listing(states: &HashMap<char, KeyState>) { - println!("Key Mappings:"); - let mut sorted_keys: Vec<char> = states.keys().copied().collect(); - sorted_keys.sort_unstable(); - for key in &sorted_keys { - let state = &states[key]; - let unit_s = unit_suffix(&state.unit); - let desc_s = desc_suffix(state.desc.as_deref()); - match &state.mode { - KeyMode::Random => { - println!(" {key} → {} (random){unit_s}{desc_s}", state.topic); - } - KeyMode::Pinned { value } => { - println!(" {key} → {} = {value}{unit_s}{desc_s}", state.topic); - } - KeyMode::Increment { - current, - step, - min, - max, - } => { - let bounds = match (min, max) { - (Some(lo), Some(hi)) => format!(" in [{lo}, {hi}]"), - (Some(lo), None) => format!(" ≥ {lo}"), - (None, Some(hi)) => format!(" ≤ {hi}"), - (None, None) => String::new(), - }; - println!( - " {key} → {} starting {current} step {step}{bounds}{unit_s}{desc_s}", - state.topic - ); - } - KeyMode::Sequence { steps } => { - println!(" {key} → sequence ({} steps){desc_s}:", steps.len()); - for step in steps { - let delay = if step.delay_ms > 0 { - format!(" +{}ms", step.delay_ms) - } else { - String::new() - }; - let step_unit_s = unit_suffix(step.unit.as_deref().unwrap_or("")); - println!( - " {topic} = {value}{step_unit_s}{delay}", - topic = step.topic, - value = step.value - ); - } - } - } - } - println!(); -} diff --git a/calypso-sim/src/modes/mod.rs b/calypso-sim/src/modes/mod.rs index 28a9722..031f620 100644 --- a/calypso-sim/src/modes/mod.rs +++ b/calypso-sim/src/modes/mod.rs @@ -1,6 +1,6 @@ -pub mod auto_script; pub mod interactive; pub mod mock; +pub mod replay; pub mod stream; use std::time::Duration; diff --git a/calypso-sim/src/modes/replay.rs b/calypso-sim/src/modes/replay.rs new file mode 100644 index 0000000..9606c10 --- /dev/null +++ b/calypso-sim/src/modes/replay.rs @@ -0,0 +1,32 @@ +use std::time::Duration; + +use rumqttc::v5::AsyncClient; + +use crate::keymap::{claim_topics, load_scenario, run_action}; +use crate::registry::SharedRegistry; + +/// Deterministic replay (`--play <action>`): load the scenario, run one named +/// action to completion — following its invokes and `sleep_ms` waits — then +/// return. Claims the action's topics first so a running mock heartbeat yields. +pub async fn run( + client: AsyncClient, + scenario_path: &str, + action: &str, + registry: SharedRegistry, +) -> Result<(), String> { + let scenario = load_scenario(scenario_path)?; + if !scenario.contains_key(action) { + return Err(format!("no action named '{action}' in {scenario_path}")); + } + + claim_topics(&scenario, std::iter::once(action), ®istry).await; + + println!("Playing action '{action}' from {scenario_path} ..."); + println!(); + + run_action(&scenario, action, &client, ®istry).await; + + // Allow the broker time to flush the last publishes before we return. + tokio::time::sleep(Duration::from_millis(100)).await; + Ok(()) +} diff --git a/calypso-sim/src/tests/cli.rs b/calypso-sim/src/tests/cli.rs index b9f653a..e7358c4 100644 --- a/calypso-sim/src/tests/cli.rs +++ b/calypso-sim/src/tests/cli.rs @@ -16,6 +16,7 @@ fn run_mock_arbitrates_the_heartbeat_against_other_modes() { // A foreground mode or --list-topics turns the heartbeat off... assert!(!cli(&["calypso-sim", "--stream"]).run_mock()); assert!(!cli(&["calypso-sim", "--key-map", "keys.json"]).run_mock()); + assert!(!cli(&["calypso-sim", "--key-map", "keys.json", "--play", "demo"]).run_mock()); assert!(!cli(&["calypso-sim", "--list-topics"]).run_mock()); // ...unless --mock forces it back on alongside that mode. assert!(cli(&["calypso-sim", "--mock"]).run_mock()); diff --git a/calypso-sim/src/tests/keymap.rs b/calypso-sim/src/tests/keymap.rs index 01109b6..191f56c 100644 --- a/calypso-sim/src/tests/keymap.rs +++ b/calypso-sim/src/tests/keymap.rs @@ -1,75 +1,85 @@ -//! Keymap logic that is fragile under refactoring: the increment -//! emit-then-advance arithmetic, and the serde `untagged` shape disambiguation -//! (whose behavior depends on variant *order*, which the type alone doesn't -//! make obvious). +//! Scenario logic that a refactor could silently break: the `untagged` step +//! shape disambiguation, and the load-time validation (unknown/looping invokes, +//! publish value-arity) that keeps replays terminating and well-formed. -use crate::keymap::{KeyEntry, advance_increment, parse_key_map}; - -/// Press an increment state `presses` times, collecting the value emitted on -/// each press. Increment emits the current value *before* advancing. -fn press_sequence( - start: f32, - step: f32, - min: Option<f32>, - max: Option<f32>, - presses: usize, -) -> Vec<f32> { - let mut current = start; - (0..presses) - .map(|_| advance_increment(&mut current, step, min, max)) - .collect() -} +use crate::keymap::{Step, collect_topics, parse_scenario, validate}; #[test] -fn advance_increment_emits_then_wraps_or_saturates() { - // Exact, deterministic values; compared through `Vec<f32>` (element-wise - // `PartialEq`) to stay clear of clippy's pedantic `float_cmp`. - assert_eq!( - press_sequence(0.0, 1.0, Some(0.0), Some(2.0), 5), - vec![0.0, 1.0, 2.0, 0.0, 1.0], - "with a min, climbing past max wraps back to min" - ); - assert_eq!( - press_sequence(0.0, 1.0, None, Some(2.0), 5), - vec![0.0, 1.0, 2.0, 2.0, 2.0], - "with no min, the value saturates at max" +fn step_shapes_are_unambiguous() { + let scenario = parse_scenario( + r#"{ + "a": { "steps": ["other", {"topic": "T", "value": 1.0}, {"sleep_ms": 50}] }, + "other": { "steps": [{"topic": "U", "value": 0.0}] } + }"#, + ) + .expect("valid scenario"); + + // A bare string is an invoke, an object with `topic` is a publish, and an + // object with `sleep_ms` is a sleep — distinguished by shape, so unlike the + // old keymap enum this does not depend on variant order. + let steps = &scenario["a"].steps; + assert!(matches!(steps[0], Step::Invoke(_)), "bare string -> invoke"); + assert!( + matches!(steps[1], Step::Publish { .. }), + "object with topic -> publish" ); - assert_eq!( - press_sequence(2.0, -1.0, Some(0.0), Some(2.0), 5), - vec![2.0, 1.0, 0.0, 2.0, 1.0], - "a negative step wraps min back up to max" + assert!( + matches!(steps[2], Step::Sleep { .. }), + "object with sleep_ms -> sleep" ); } #[test] -fn parse_disambiguates_the_four_entry_shapes() { - let map = parse_key_map( - r#"{ - "r": "Some/Topic", - "p": {"topic": "T", "value": 1.0}, - "i": {"topic": "T", "value": 0.0, "step": 1.0}, - "s": {"sequence": [{"topic": "T", "value": 1.0}]} - }"#, - ) - .expect("valid keymap"); +fn validate_rejects_unknown_invoke_and_cycles() { + let unknown = parse_scenario(r#"{ "a": { "steps": ["ghost"] } }"#).unwrap(); + assert!( + validate(&unknown).is_err(), + "invoking a missing action must fail" + ); - // Order matters in the `untagged` enum: `step` must win over `value` - // (Increment before Pinned), and `sequence` must be recognized ahead of the - // other object forms. Reordering the variants would silently break this. + let self_cycle = parse_scenario(r#"{ "a": { "steps": ["a"] } }"#).unwrap(); assert!( - matches!(map.get(&'r'), Some(KeyEntry::TopicOnly(_))), - "bare string should be random-mode" + validate(&self_cycle).is_err(), + "a self-invoking action is a cycle" ); + + let indirect = parse_scenario(r#"{ "a": {"steps":["b"]}, "b": {"steps":["a"]} }"#).unwrap(); + assert!(validate(&indirect).is_err(), "a -> b -> a is a cycle"); +} + +#[test] +fn validate_requires_exactly_one_of_value_or_values() { + let both = + parse_scenario(r#"{ "a": {"steps":[{"topic":"T","value":1.0,"values":[1.0]}]} }"#).unwrap(); assert!( - matches!(map.get(&'p'), Some(KeyEntry::Pinned { .. })), - "`value` without `step` should be pinned" + validate(&both).is_err(), + "value + values together must fail" ); + + let neither = parse_scenario(r#"{ "a": {"steps":[{"topic":"T"}]} }"#).unwrap(); assert!( - matches!(map.get(&'i'), Some(KeyEntry::Increment { .. })), - "`step` should select increment even with `value` present" + validate(&neither).is_err(), + "neither value nor values must fail" ); + + let ok = parse_scenario(r#"{ "a": {"steps":[{"topic":"T","values":[1.0,2.0]}]} }"#).unwrap(); + assert!(validate(&ok).is_ok(), "values alone is valid"); +} + +#[test] +fn collect_topics_follows_invokes() { + let scenario = parse_scenario( + r#"{ + "big": { "steps": ["small", {"topic": "A", "value": 1.0}] }, + "small": { "steps": [{"topic": "B", "value": 1.0}, {"sleep_ms": 5}] } + }"#, + ) + .unwrap(); + validate(&scenario).unwrap(); + + let topics = collect_topics(&scenario, "big"); assert!( - matches!(map.get(&'s'), Some(KeyEntry::Sequence { .. })), - "`sequence` should select sequence mode" + topics.contains("A") && topics.contains("B"), + "topics must be gathered through invoked actions, got {topics:?}" ); } diff --git a/calypso-sim/src/warnings.rs b/calypso-sim/src/warnings.rs index b7e65d8..191b786 100644 --- a/calypso-sim/src/warnings.rs +++ b/calypso-sim/src/warnings.rs @@ -3,7 +3,7 @@ use calypso_cangen::can_types::OdysseyMsg; /// Print a comma-separated list of CAN message topics that have no /// `sim_freq` in the spec — these are invisible to the mock simulator -/// and can only be published via `--key-map` / `--script` / `--stream`. +/// and can only be published via `--key-map` / `--play` / `--stream`. /// /// Resolves the spec path relative to the current working directory; emits /// nothing if the spec dir is missing. From 6cd7d5401c79a5d868c08be70e20b1099c40937e Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Sun, 12 Jul 2026 20:30:59 -0400 Subject: [PATCH 25/29] #305 - replace calypso-sim runtime topic-ownership with a static startup partition Ownership is now resolved once at load instead of negotiated at runtime: the driver's topics (a scenario's, or none for --stream) are removed from the mock heartbeat's set up front and the split is printed, so the two publish disjoint topics with no locking. Deletes the registry (Owner enum / RwLock / per-publish predicates), the keymap lazy-claim, and the stream claim/release/silence/status RPCs -- the stream protocol is now publish/list_topics/ping, and a stream driver reserves topics with --disable-topic. Also adds a scenario example to the keymap model docs. --- calypso-sim/README.md | 28 +++---- calypso-sim/src/keymap.rs | 71 ++++++------------ calypso-sim/src/keymap/model.rs | 25 +++++++ calypso-sim/src/main.rs | 57 +++++++------- calypso-sim/src/modes/interactive.rs | 15 ++-- calypso-sim/src/modes/mock.rs | 64 ++-------------- calypso-sim/src/modes/replay.rs | 25 ++----- calypso-sim/src/modes/stream.rs | 64 +++------------- calypso-sim/src/ownership.rs | 106 +++++++++++++++++++++++++++ calypso-sim/src/registry.rs | 82 --------------------- calypso-sim/src/tests/keymap.rs | 25 +++++-- calypso-sim/tests/stream.rs | 82 +++------------------ 12 files changed, 265 insertions(+), 379 deletions(-) create mode 100644 calypso-sim/src/ownership.rs delete mode 100644 calypso-sim/src/registry.rs diff --git a/calypso-sim/README.md b/calypso-sim/README.md index a4f8912..3846bb2 100644 --- a/calypso-sim/README.md +++ b/calypso-sim/README.md @@ -36,9 +36,15 @@ cargo run -- -u 10.0.0.5:1883 ... # remote broke The `--enable-topic <REGEX>` and `--disable-topic <REGEX>` flags filter which topics the mock heartbeat publishes (whitelist / blacklist; mutually exclusive). Topics that lack a `sim_freq` in the CAN spec are listed at startup as a `Warning topics (not simulated): ...` line and can only be reached via `--key-map` or `--stream`. -## Topic-ownership model +## Ownership (static partition) -Every topic has an owner: `mock` (default — mock publishes), `stream` (claimed by stream/keymap), or `silenced` (nobody publishes). The mock loop checks ownership before each publish, so claims from `--stream` or keymap modes cleanly override the heartbeat without fighting it. Releasing a topic returns it to `mock`. +The mock heartbeat and a foreground driver (interactive / replay / stream) can both publish, so they must never target the same topic. Rather than negotiate at runtime, ownership is a **partition resolved once at startup**: any topic the driver owns is removed from the heartbeat's set up front, so the two publish disjoint topics by construction. The split is printed before anything publishes (`Ownership: mock heartbeat drives N topic(s); …`). + +- **`--key-map` / `--play`:** the driver owns every topic its scenario publishes (known at load); the heartbeat cedes them automatically. +- **`--stream`:** nothing is auto-reserved (its topics aren't known up front) — carve topics out of the heartbeat explicitly with `--disable-topic`. +- **Pure `--mock`:** the heartbeat owns every topic its filter allows. + +There is no runtime claim/release/silence. To keep the heartbeat off a topic, use `--disable-topic` (or `--enable-topic` to whitelist). ## Scenario file (`--key-map` and `--play`) @@ -81,26 +87,22 @@ JSON-RPC 2.0 over stdio — one request per line on stdin, one response per line ```jsonc // stdin {"jsonrpc":"2.0","id":1,"method":"publish","params":{"topic":"Wheel/Buttons/button_id","value":5}} -{"jsonrpc":"2.0","id":2,"method":"claim","params":{"topic":"VCU/CarState/home_mode"}} -{"jsonrpc":"2.0","id":3,"method":"publish","params":{"topic":"VCU/CarState/home_mode","value":1}} -{"jsonrpc":"2.0","id":4,"method":"release","params":{"topic":"VCU/CarState/home_mode"}} +{"jsonrpc":"2.0","id":2,"method":"publish","params":{"topic":"VCU/CarState/home_mode","values":[1,0]}} // stdout {"jsonrpc":"2.0","id":1,"result":{"ts_us":1735347123456789}} -{"jsonrpc":"2.0","id":2,"result":{"topic":"...","previous_owner":"mock","owner":"stream"}} +{"jsonrpc":"2.0","id":2,"result":{"ts_us":1735347123456999}} ... ``` | Method | Params | Result | |---|---|---| -| `publish` | `{topic, value? \| values?, unit?}` | `{ts_us}`, or `{skipped: "silenced"}` on a silenced topic | -| `claim` | `{topic}` | `{topic, previous_owner, owner}` | -| `release` | `{topic}` | `{topic, previous_owner, owner: "mock"}` | -| `silence` | `{topic}` | `{topic, previous_owner, owner: "silenced"}` | -| `status` | `{}` | `{overrides: [{topic, owner}, ...]}` | +| `publish` | `{topic, value? \| values?, unit?}` | `{ts_us}` | | `list_topics` | `{}` | `{topics: [{name, unit}, ...]}` | | `ping` | `{}` | `{ok: true}` | +Ownership is a startup partition, not a runtime negotiation, so there are no `claim`/`release`/`silence` methods — reserve a stream driver's topics from the heartbeat with `--disable-topic`. + Errors follow JSON-RPC 2.0 (`{error: {code, message}}`) with the standard codes: `-32700` (parse), `-32600` (invalid request), `-32601` (method not found), `-32602` (invalid params), and `-32603` (internal). ## Testing @@ -116,10 +118,10 @@ cargo test |---|---|---| | Unit — scenario | `src/tests/keymap.rs` | The fragile scenario logic: the serde `untagged` step-shape disambiguation (invoke / publish / sleep, by shape not order), and load-time validation — unknown or cyclic invokes are rejected, and publishes must set exactly one of `value` / `values`. | | Unit — CLI modes | `src/tests/cli.rs` | `run_mock` arbitration: heartbeat on by default, off under a foreground mode or `--list-topics`, forced on by explicit `--mock`. | -| Integration — stream | `tests/stream.rs` | Spawns the real `calypso-sim --stream` binary and checks the JSON-RPC contract (`list_topics` is non-empty, `publish` requires exactly one of `value`/`values`, malformed requests get `-32601`/`-32600`) plus an end-to-end ownership flow — claim → silence → release with the heartbeat running, which doubles as the regression guard for the `mock`/`stream`/`silenced` arbitration. | +| Integration — stream | `tests/stream.rs` | Spawns the real `calypso-sim --stream` binary and checks the JSON-RPC contract: `list_topics` is non-empty and well-formed, `publish` requires exactly one of `value`/`values` (and a well-formed one returns a `ts_us`), and malformed requests get `-32601`/`-32600`. | The suite is deliberately small: each test guards logic a future change could silently break, not code that is obvious by reading it. Unit tests live in `src/tests/` — compiled into the crate under `cfg(test)`, so they reach internals via `use crate::…`; binary-driven tests live in the crate-root `tests/` dir, the only place Cargo sets `CARGO_BIN_EXE_calypso-sim`. -No broker is needed because `publish` only enqueues (the eventloop retries a missing broker rather than dropping the queue), so it still returns a `ts_us`, and ownership arbitration is answered entirely from the JSON-RPC responses. Observing the actual *bytes on the wire* — that a payload reaches a subscriber — needs a live broker, which in practice is **Siren** in the Docker compose stack (see the repo `Dockerfile`); a standalone end-to-end test broker is intentionally out of scope. +No broker is needed because `publish` only enqueues (the eventloop retries a missing broker rather than dropping the queue), so it still returns a `ts_us`. Observing the actual *bytes on the wire* — that a payload reaches a subscriber — needs a live broker, which in practice is **Siren** in the Docker compose stack (see the repo `Dockerfile`); a standalone end-to-end test broker is intentionally out of scope. CI (`.github/workflows/calypso-sim-ci.yml`) runs the suite on any change under `calypso-sim/**` or its path-dependencies. diff --git a/calypso-sim/src/keymap.rs b/calypso-sim/src/keymap.rs index 8c33440..d9f7e5c 100644 --- a/calypso-sim/src/keymap.rs +++ b/calypso-sim/src/keymap.rs @@ -13,7 +13,6 @@ use rumqttc::v5::AsyncClient; use crate::publish::publish_data; use crate::raw_mode::line_end; -use crate::registry::{Owner, SharedRegistry}; /// Parse a scenario from JSON. Does not validate — call [`validate`] (or use /// [`load_scenario`], which does both) before running it. @@ -103,8 +102,9 @@ fn detect_cycle(scenario: &Scenario, name: &str, path: &mut Vec<String>) -> Resu } /// A flattened, executable step: invokes have been resolved away, leaving only -/// publishes and waits. -enum Prim { +/// publishes and waits. `pub(crate)` only so the flatten test can inspect it. +#[derive(Debug)] +pub(crate) enum Prim { Publish { topic: String, values: Vec<f32>, @@ -116,7 +116,7 @@ enum Prim { /// Expand `name` into a linear list of [`Prim`]s, inlining every invoked /// action. Safe against infinite recursion because [`validate`] has already /// proven the graph acyclic and every invoke target present. -fn flatten(scenario: &Scenario, name: &str, out: &mut Vec<Prim>) { +pub(crate) fn flatten(scenario: &Scenario, name: &str, out: &mut Vec<Prim>) { let Some(action) = scenario.get(name) else { return; }; @@ -141,21 +141,6 @@ fn flatten(scenario: &Scenario, name: &str, out: &mut Vec<Prim>) { } } -/// Every topic `name` publishes to, following invokes. Used to claim ownership -/// so the mock heartbeat yields those topics. -#[must_use] -pub fn collect_topics(scenario: &Scenario, name: &str) -> BTreeSet<String> { - let mut prims = Vec::new(); - flatten(scenario, name, &mut prims); - prims - .into_iter() - .filter_map(|p| match p { - Prim::Publish { topic, .. } => Some(topic), - Prim::Sleep(_) => None, - }) - .collect() -} - /// The `key -> action name` bindings for interactive mode, erroring if two /// actions claim the same key. pub fn key_bindings(scenario: &Scenario) -> Result<HashMap<char, String>, String> { @@ -172,32 +157,28 @@ pub fn key_bindings(scenario: &Scenario) -> Result<HashMap<char, String>, String Ok(map) } -/// Claim every topic reachable from `action_names` for [`Owner::Stream`], so a -/// running mock heartbeat yields those topics to this driver. -pub async fn claim_topics<'a>( - scenario: &Scenario, - action_names: impl IntoIterator<Item = &'a str>, - registry: &SharedRegistry, -) { - let mut topics = BTreeSet::new(); - for name in action_names { - topics.extend(collect_topics(scenario, name)); - } - let mut reg = registry.write().await; - for topic in &topics { - reg.set(topic, Owner::Stream); +/// Every topic the scenario can publish, following invokes across all actions. +/// Resolved once at startup so the mock heartbeat can cede these topics to the +/// keymap/replay driver (see [`crate::ownership`]). +#[must_use] +pub fn scenario_topics(scenario: &Scenario) -> BTreeSet<String> { + let mut prims = Vec::new(); + for name in scenario.keys() { + flatten(scenario, name, &mut prims); } + prims + .into_iter() + .filter_map(|p| match p { + Prim::Publish { topic, .. } => Some(topic), + Prim::Sleep(_) => None, + }) + .collect() } -/// Run `name`'s steps in order: publishes go to the broker (skipping any topic -/// the registry has silenced), sleeps wait, invokes are inlined. Logs each -/// publish to stdout. -pub async fn run_action( - scenario: &Scenario, - name: &str, - client: &AsyncClient, - registry: &SharedRegistry, -) { +/// Run `name`'s steps in order: publishes go to the broker, sleeps wait, invokes +/// are inlined. Logs each publish to stdout. No ownership check — the heartbeat +/// has already ceded this driver's topics up front (see [`scenario_topics`]). +pub async fn run_action(scenario: &Scenario, name: &str, client: &AsyncClient) { if let Some(desc) = scenario.get(name).and_then(|a| a.desc.as_deref()) { print!("[{name}] {desc}{}", line_end()); let _ = std::io::stdout().flush(); @@ -213,11 +194,7 @@ pub async fn run_action( topic, values, unit, - } => { - if registry.read().await.driver_may_publish(&topic) { - log_and_publish(name, &topic, &unit, &values, client).await; - } - } + } => log_and_publish(name, &topic, &unit, &values, client).await, } } } diff --git a/calypso-sim/src/keymap/model.rs b/calypso-sim/src/keymap/model.rs index 406edc9..33600e3 100644 --- a/calypso-sim/src/keymap/model.rs +++ b/calypso-sim/src/keymap/model.rs @@ -1,5 +1,30 @@ //! The scenario data model — plain deserialized types, no logic. The functions //! that load, validate, and run these live in the parent `keymap` module. +//! +//! A scenario is a JSON object mapping action names to [`Action`]s. Each action +//! is an ordered list of [`Step`]s, optionally bound to a keyboard `key` and +//! given a `desc`: +//! +//! ```json +//! { +//! "enter": { "key": "e", "steps": [{"topic": "Wheel/Buttons/button_id", "value": 5}] }, +//! "home": { +//! "key": "h", +//! "desc": "home pulse", +//! "steps": [ +//! {"topic": "VCU/CarState/home_mode", "value": 1}, +//! {"sleep_ms": 10}, +//! {"topic": "VCU/CarState/home_mode", "value": 0} +//! ] +//! }, +//! "menu_wrap": { "key": "w", "steps": ["enter", "home"] }, +//! "demo": { "desc": "run via --play demo", "steps": ["menu_wrap", {"sleep_ms": 500}, "menu_wrap"] } +//! } +//! ``` +//! +//! Here `enter` and `home` are leaf actions (publish/sleep steps), `menu_wrap` +//! reuses them via bare-string invoke steps, and `demo` is a keyless replay +//! program run with `--play demo`. See `README.md` for the full format. use std::collections::HashMap; diff --git a/calypso-sim/src/main.rs b/calypso-sim/src/main.rs index 3f7b129..989c6be 100644 --- a/calypso-sim/src/main.rs +++ b/calypso-sim/src/main.rs @@ -1,11 +1,11 @@ mod cli; mod keymap; mod modes; +mod ownership; #[allow(clippy::all, clippy::pedantic)] mod proto; mod publish; mod raw_mode; -mod registry; mod simulatable_message; mod simulate_data; mod warnings; @@ -24,7 +24,6 @@ use tracing::level_filters::LevelFilter; use tracing_subscriber::{EnvFilter, fmt::format::FmtSpan}; use cli::Cli; -use registry::TopicRegistry; #[tokio::main] async fn main() { @@ -38,6 +37,17 @@ async fn main() { warnings::print_unsimulated(); + // Load the scenario once, up front (for --key-map / --play). This fails fast + // on a bad file, and lets us reserve the scenario's topics from the mock + // heartbeat before anything publishes (ownership is a startup partition, not + // a runtime negotiation — see `ownership`). + let scenario = cli.key_map.as_deref().map(|path| { + keymap::load_scenario(path).unwrap_or_else(|err| { + eprintln!("Error: {err}"); + exit(1); + }) + }); + let (client, eventloop) = connect_mqtt(&cli.siren_host_url).unwrap_or_else(|err| { eprintln!("Error: {err}"); exit(1); @@ -46,28 +56,33 @@ async fn main() { let token = CancellationToken::new(); let poll_handle = tokio::spawn(modes::poll_eventloop(token.clone(), eventloop)); - let registry = TopicRegistry::shared(); - let mock_handle = if cli.run_mock() { // Validate the enable/disable regex patterns up front so a bad pattern // fails fast with a non-zero exit instead of silently disabling the // entire mock heartbeat inside the spawned task. - let filter = modes::mock::FilterMode::build(&cli.enable_topic, &cli.disable_topic) + let filter = ownership::FilterMode::build(&cli.enable_topic, &cli.disable_topic) .unwrap_or_else(|err| { eprintln!("Error: {err}"); exit(1); }); + // Reserve the driver's topics (a scenario's, if any) from the heartbeat, + // then print the resulting split so it's clear who drives what. + let driver_owned = scenario + .as_ref() + .map(keymap::scenario_topics) + .unwrap_or_default(); + let partition = ownership::Partition::resolve(&filter, driver_owned); + partition.print_summary(); Some(tokio::spawn(modes::mock::run( token.clone(), client.clone(), - registry.clone(), - filter, + partition.heartbeat, ))) } else { None }; - let foreground = run_foreground(&cli, &token, &client, ®istry).await; + let foreground = run_foreground(&cli, &token, &client, scenario).await; // Let the MQTT eventloop drain any just-enqueued publishes before we cancel // it. `AsyncClient::publish` only enqueues; the eventloop's `poll()` is what @@ -96,26 +111,18 @@ async fn run_foreground( cli: &Cli, token: &CancellationToken, client: &AsyncClient, - registry: ®istry::SharedRegistry, + scenario: Option<keymap::Scenario>, ) -> Result<(), String> { if cli.stream { - modes::stream::run(token.clone(), client.clone(), registry.clone()).await + modes::stream::run(token.clone(), client.clone()).await } else if let Some(action) = &cli.play { - // clap enforces `--play requires --key-map` (see cli.rs), so a missing - // key map here is an impossible state, not a reachable runtime error. - let key_map_path = cli - .key_map - .as_deref() - .expect("clap enforces --play requires --key-map"); - modes::replay::run(client.clone(), key_map_path, action, registry.clone()).await - } else if let Some(key_map_path) = &cli.key_map { - modes::interactive::run( - token.clone(), - client.clone(), - key_map_path, - registry.clone(), - ) - .await + // clap enforces `--play requires --key-map`, so `main` has loaded the + // scenario; a missing one here is an impossible state, not a runtime error. + let scenario = scenario.expect("clap enforces --play requires --key-map"); + modes::replay::run(client.clone(), scenario, action).await + } else if cli.key_map.is_some() { + let scenario = scenario.expect("--key-map implies main loaded the scenario"); + modes::interactive::run(token.clone(), client.clone(), scenario).await } else { // Pure --mock: wait for SIGINT, then exit. tokio::signal::ctrl_c() diff --git a/calypso-sim/src/modes/interactive.rs b/calypso-sim/src/modes/interactive.rs index 3585689..ecc4cd5 100644 --- a/calypso-sim/src/modes/interactive.rs +++ b/calypso-sim/src/modes/interactive.rs @@ -5,28 +5,23 @@ use futures_util::StreamExt; use rumqttc::v5::AsyncClient; use tokio_util::sync::CancellationToken; -use crate::keymap::{claim_topics, key_bindings, load_scenario, print_listing, run_action}; +use crate::keymap::{Scenario, key_bindings, print_listing, run_action}; use crate::raw_mode::{RawModeGuard, line_end}; -use crate::registry::SharedRegistry; /// Run the interactive raw-mode keypress loop. Each key-bound action fires on -/// its key; the action's topics are claimed in the registry so the mock loop -/// (if running) yields ownership. +/// its key. The heartbeat (if running) has already ceded this scenario's topics +/// up front, so keypresses never fight it (see [`crate::ownership`]). pub async fn run( token: CancellationToken, client: AsyncClient, - scenario_path: &str, - registry: SharedRegistry, + scenario: Scenario, ) -> Result<(), String> { - let scenario = load_scenario(scenario_path)?; let keys = key_bindings(&scenario)?; if keys.is_empty() { return Err( "Scenario has no key-bound actions; add a `key` to an action, or use --play".into(), ); } - claim_topics(&scenario, keys.values().map(String::as_str), ®istry).await; - print_listing(&scenario, &keys); println!("Press mapped keys to inject. Ctrl+C to exit."); println!(); @@ -51,7 +46,7 @@ pub async fn run( .. }))) => { if let Some(name) = keys.get(&ch) { - run_action(&scenario, name, &client, ®istry).await; + run_action(&scenario, name, &client).await; } } Some(Err(e)) => { diff --git a/calypso-sim/src/modes/mock.rs b/calypso-sim/src/modes/mock.rs index 84081dc..8c3583a 100644 --- a/calypso-sim/src/modes/mock.rs +++ b/calypso-sim/src/modes/mock.rs @@ -1,66 +1,21 @@ use std::time::Duration; -use crate::simulate_data::create_simulated_components; -use regex::Regex; use rumqttc::v5::AsyncClient; use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; use crate::publish::publish_data; -use crate::registry::SharedRegistry; +use crate::simulatable_message::SimComponent; -#[derive(Debug)] -pub(crate) enum FilterMode { - Disabled, - Blacklist(Vec<Regex>), - Whitelist(Vec<Regex>), -} - -impl FilterMode { - pub(crate) fn build(enable: &[String], disable: &[String]) -> Result<Self, String> { - if !disable.is_empty() { - Ok(Self::Blacklist(compile_patterns(disable)?)) - } else if !enable.is_empty() { - Ok(Self::Whitelist(compile_patterns(enable)?)) - } else { - Ok(Self::Disabled) - } - } - - fn allows(&self, topic: &str) -> bool { - match self { - FilterMode::Disabled => true, - FilterMode::Blacklist(p) => !p.iter().any(|re| re.is_match(topic)), - FilterMode::Whitelist(p) => p.iter().any(|re| re.is_match(topic)), - } - } -} - -fn compile_patterns(patterns: &[String]) -> Result<Vec<Regex>, String> { - patterns - .iter() - .map(|p| Regex::new(p).map_err(|e| format!("Invalid regex '{p}': {e}"))) - .collect() -} - -/// Background task: every 5ms, walk the simulated components and publish any -/// that are due (per `sim_freq`) AND owned by `Owner::Mock` in the registry. +/// Background task: every 5ms, walk `components` and publish any that are due +/// (per `sim_freq`). /// -/// Components owned by `Stream` or `Silenced` are skipped without advancing -/// internal state, so they pick up where they would have been on `release`. -pub async fn run( - token: CancellationToken, - client: AsyncClient, - registry: SharedRegistry, - filter: FilterMode, -) { - let mut components: Vec<_> = create_simulated_components() - .into_iter() - .filter(|c| filter.allows(&c.name)) - .collect(); - +/// `components` is already the heartbeat's share of the topic space — the +/// startup [`crate::ownership::Partition`] has removed anything a driver owns — +/// so there is no per-publish ownership check here. +pub async fn run(token: CancellationToken, client: AsyncClient, mut components: Vec<SimComponent>) { if components.is_empty() { - info!("Mock: no components match the filter; nothing to simulate."); + info!("Mock: no components to simulate."); } else { info!("Mock: simulating {} components", components.len()); } @@ -78,9 +33,6 @@ pub async fn run( if !component.should_update() { continue; } - if !registry.read().await.mock_may_publish(&component.name) { - continue; - } component.update(); let data = component.get_decode_data(); if let Err(e) = publish_data(&client, &data.topic, &data.unit, &data.value).await { diff --git a/calypso-sim/src/modes/replay.rs b/calypso-sim/src/modes/replay.rs index 9606c10..9053f8e 100644 --- a/calypso-sim/src/modes/replay.rs +++ b/calypso-sim/src/modes/replay.rs @@ -2,29 +2,20 @@ use std::time::Duration; use rumqttc::v5::AsyncClient; -use crate::keymap::{claim_topics, load_scenario, run_action}; -use crate::registry::SharedRegistry; +use crate::keymap::{Scenario, run_action}; -/// Deterministic replay (`--play <action>`): load the scenario, run one named -/// action to completion — following its invokes and `sleep_ms` waits — then -/// return. Claims the action's topics first so a running mock heartbeat yields. -pub async fn run( - client: AsyncClient, - scenario_path: &str, - action: &str, - registry: SharedRegistry, -) -> Result<(), String> { - let scenario = load_scenario(scenario_path)?; +/// Deterministic replay (`--play <action>`): run one named action to completion +/// — following its invokes and `sleep_ms` waits — then return. The heartbeat (if +/// running) has already ceded the scenario's topics (see [`crate::ownership`]). +pub async fn run(client: AsyncClient, scenario: Scenario, action: &str) -> Result<(), String> { if !scenario.contains_key(action) { - return Err(format!("no action named '{action}' in {scenario_path}")); + return Err(format!("no action named '{action}' in the scenario")); } - claim_topics(&scenario, std::iter::once(action), ®istry).await; - - println!("Playing action '{action}' from {scenario_path} ..."); + println!("Playing action '{action}' ..."); println!(); - run_action(&scenario, action, &client, ®istry).await; + run_action(&scenario, action, &client).await; // Allow the broker time to flush the last publishes before we return. tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/calypso-sim/src/modes/stream.rs b/calypso-sim/src/modes/stream.rs index e659a99..b384d7e 100644 --- a/calypso-sim/src/modes/stream.rs +++ b/calypso-sim/src/modes/stream.rs @@ -6,24 +6,20 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio_util::sync::CancellationToken; use crate::publish::publish_data; -use crate::registry::{Owner, SharedRegistry}; /// JSON-RPC 2.0 over stdio. Reads one request per line from stdin, writes /// one response per line to stdout. Diagnostics go to stderr. /// +/// Ownership is a startup partition (see [`crate::ownership`]), not a runtime +/// negotiation, so there are no claim/release/silence methods — a stream driver +/// carves its topics out of the mock heartbeat with `--disable-topic`, then just +/// publishes. +/// /// Methods: /// * `publish` — `{topic, value | values, unit?}` → `{ts_us}` -/// * `claim` — `{topic}` → `{previous_owner, owner}` -/// * `release` — `{topic}` → `{previous_owner, owner}` (sets owner=mock) -/// * `silence` — `{topic}` → `{previous_owner, owner}` -/// * `status` — `{}` → `{overrides: [{topic, owner}, ...]}` /// * `list_topics` — `{}` → `{topics: [{name, unit}, ...]}` /// * `ping` — `{}` → `{ok: true}` -pub async fn run( - token: CancellationToken, - client: AsyncClient, - registry: SharedRegistry, -) -> Result<(), String> { +pub async fn run(token: CancellationToken, client: AsyncClient) -> Result<(), String> { let stdin = tokio::io::stdin(); let mut reader = BufReader::new(stdin).lines(); @@ -35,7 +31,7 @@ pub async fn run( if line.trim().is_empty() { continue; } - let resp = handle_line(&line, &client, ®istry).await; + let resp = handle_line(&line, &client).await; write_line(&resp).await; } Ok(None) => break, // stdin closed @@ -67,7 +63,7 @@ const ERR_METHOD_NOT_FOUND: i32 = -32601; const ERR_INVALID_PARAMS: i32 = -32602; const ERR_INTERNAL: i32 = -32603; -async fn handle_line(line: &str, client: &AsyncClient, registry: &SharedRegistry) -> Value { +async fn handle_line(line: &str, client: &AsyncClient) -> Value { let request: Request = match serde_json::from_str(line) { Ok(r) => r, Err(e) => return error(Value::Null, ERR_PARSE, &format!("Parse error: {e}")), @@ -91,11 +87,7 @@ async fn handle_line(line: &str, client: &AsyncClient, registry: &SharedRegistry }; match method.as_str() { - "publish" => handle_publish(id, request.params, client, registry).await, - "claim" => handle_set(id, request.params, registry, Owner::Stream).await, - "release" => handle_set(id, request.params, registry, Owner::Mock).await, - "silence" => handle_set(id, request.params, registry, Owner::Silenced).await, - "status" => handle_status(id, registry).await, + "publish" => handle_publish(id, request.params, client).await, "list_topics" => handle_list_topics(id), "ping" => ok(id, json!({"ok": true})), other => error( @@ -117,12 +109,7 @@ struct PublishParams { unit: Option<String>, } -async fn handle_publish( - id: Value, - params: Value, - client: &AsyncClient, - registry: &SharedRegistry, -) -> Value { +async fn handle_publish(id: Value, params: Value, client: &AsyncClient) -> Value { let p: PublishParams = match serde_json::from_value(params) { Ok(v) => v, Err(e) => return error(id, ERR_INVALID_PARAMS, &format!("Invalid params: {e}")), @@ -142,10 +129,6 @@ async fn handle_publish( _ => return error(id, ERR_INVALID_PARAMS, "missing `value` or `values`"), }; - if !registry.read().await.driver_may_publish(&p.topic) { - return ok(id, json!({"skipped": "silenced"})); - } - let unit = p.unit.unwrap_or_default(); match publish_data(client, &p.topic, &unit, &values).await { Ok(ts_us) => ok(id, json!({"ts_us": ts_us})), @@ -153,33 +136,6 @@ async fn handle_publish( } } -#[derive(Deserialize)] -struct TopicParam { - topic: String, -} - -async fn handle_set(id: Value, params: Value, registry: &SharedRegistry, owner: Owner) -> Value { - let p: TopicParam = match serde_json::from_value(params) { - Ok(v) => v, - Err(e) => return error(id, ERR_INVALID_PARAMS, &format!("Invalid params: {e}")), - }; - - let prev = registry.write().await.set(&p.topic, owner); - ok( - id, - json!({"topic": p.topic, "previous_owner": prev.as_str(), "owner": owner.as_str()}), - ) -} - -async fn handle_status(id: Value, registry: &SharedRegistry) -> Value { - let snap = registry.read().await.snapshot(); - let entries: Vec<_> = snap - .into_iter() - .map(|(t, o)| json!({"topic": t, "owner": o.as_str()})) - .collect(); - ok(id, json!({"overrides": entries})) -} - fn handle_list_topics(id: Value) -> Value { let components = create_simulated_components(); let topics: Vec<_> = components diff --git a/calypso-sim/src/ownership.rs b/calypso-sim/src/ownership.rs new file mode 100644 index 0000000..d7d1abc --- /dev/null +++ b/calypso-sim/src/ownership.rs @@ -0,0 +1,106 @@ +//! Who owns what — resolved once, at startup, and never renegotiated. +//! +//! The mock heartbeat and the foreground driver (interactive / replay / stream) +//! can both publish, but they must never fight over the same topic. Rather than +//! arbitrate at runtime, we partition topics up front: any topic the driver owns +//! is simply removed from the heartbeat's set here, so the two publish disjoint +//! sets by construction. +//! +//! A driver's owned topics are: +//! * `--key-map` / `--play`: the topics its scenario publishes (known at load); +//! * `--stream`: nothing is auto-reserved (its topics aren't known up front) — +//! carve topics out of the heartbeat explicitly with `--disable-topic`. + +use std::collections::BTreeSet; + +use regex::Regex; + +use crate::simulatable_message::SimComponent; +use crate::simulate_data::create_simulated_components; + +/// Which topics the mock heartbeat is allowed to publish, from the +/// `--enable-topic` / `--disable-topic` flags (mutually exclusive). +#[derive(Debug)] +pub enum FilterMode { + /// No filter — every simulatable topic is allowed. + Disabled, + /// Publish everything *except* topics matching these patterns. + Blacklist(Vec<Regex>), + /// Publish *only* topics matching these patterns. + Whitelist(Vec<Regex>), +} + +impl FilterMode { + /// Build from the raw CLI patterns, compiling them so a bad regex fails + /// fast at startup instead of silently disabling the heartbeat. + pub fn build(enable: &[String], disable: &[String]) -> Result<Self, String> { + if !disable.is_empty() { + Ok(Self::Blacklist(compile_patterns(disable)?)) + } else if !enable.is_empty() { + Ok(Self::Whitelist(compile_patterns(enable)?)) + } else { + Ok(Self::Disabled) + } + } + + fn allows(&self, topic: &str) -> bool { + match self { + FilterMode::Disabled => true, + FilterMode::Blacklist(p) => !p.iter().any(|re| re.is_match(topic)), + FilterMode::Whitelist(p) => p.iter().any(|re| re.is_match(topic)), + } + } +} + +fn compile_patterns(patterns: &[String]) -> Result<Vec<Regex>, String> { + patterns + .iter() + .map(|p| Regex::new(p).map_err(|e| format!("Invalid regex '{p}': {e}"))) + .collect() +} + +/// The startup topic partition: the components the mock heartbeat will drive, +/// and the topics reserved for the driver (which the heartbeat skips). +pub struct Partition { + /// Components the mock heartbeat will publish. + pub heartbeat: Vec<SimComponent>, + /// Topics reserved for the foreground driver; the heartbeat is off on these. + pub driver_owned: BTreeSet<String>, +} + +impl Partition { + /// Resolve the partition: the heartbeat drives every simulatable topic the + /// `filter` allows and the driver does not own. Driver ownership wins, so + /// the two sides are always disjoint. + #[must_use] + pub fn resolve(filter: &FilterMode, driver_owned: BTreeSet<String>) -> Self { + let heartbeat = create_simulated_components() + .into_iter() + .filter(|c| filter.allows(&c.name) && !driver_owned.contains(&c.name)) + .collect(); + Self { + heartbeat, + driver_owned, + } + } + + /// Print the split to stderr so it is clear, before anything publishes, + /// exactly what the heartbeat drives and what it has ceded to the driver. + pub fn print_summary(&self) { + if self.driver_owned.is_empty() { + eprintln!( + "Ownership: mock heartbeat drives {} topic(s); none reserved for a driver.", + self.heartbeat.len() + ); + } else { + let reserved: Vec<&str> = self.driver_owned.iter().map(String::as_str).collect(); + eprintln!( + "Ownership: mock heartbeat drives {} topic(s); {} reserved for the driver \ + (heartbeat off): {}", + self.heartbeat.len(), + reserved.len(), + reserved.join(", ") + ); + } + } +} diff --git a/calypso-sim/src/registry.rs b/calypso-sim/src/registry.rs deleted file mode 100644 index 8ad4cc3..0000000 --- a/calypso-sim/src/registry.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use tokio::sync::RwLock; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Owner { - /// Default state — mock heartbeat publishes this topic. - Mock, - /// Claimed by a streaming/keymap client; mock skips. - Stream, - /// Nobody publishes; both mock and stream skip. - Silenced, -} - -impl Owner { - pub fn as_str(self) -> &'static str { - match self { - Owner::Mock => "mock", - Owner::Stream => "stream", - Owner::Silenced => "silenced", - } - } -} - -pub type SharedRegistry = Arc<RwLock<TopicRegistry>>; - -/// Per-topic ownership. Topics not in the map default to `Owner::Mock`. -#[derive(Debug, Default)] -pub struct TopicRegistry { - overrides: HashMap<String, Owner>, -} - -impl TopicRegistry { - pub fn new() -> Self { - Self::default() - } - - pub fn shared() -> SharedRegistry { - Arc::new(RwLock::new(Self::new())) - } - - pub fn owner(&self, topic: &str) -> Owner { - self.overrides.get(topic).copied().unwrap_or(Owner::Mock) - } - - /// Whether the mock heartbeat may publish `topic`. The heartbeat - /// only drives a topic while it is still `Mock`-owned; once a stream or - /// keymap driver claims or silences it, the heartbeat yields. - pub fn mock_may_publish(&self, topic: &str) -> bool { - self.owner(topic) == Owner::Mock - } - - /// Whether a stream/keymap driver may publish `topic`. Drivers may publish - /// anything except a topic that has been explicitly `Silenced`. - pub fn driver_may_publish(&self, topic: &str) -> bool { - self.owner(topic) != Owner::Silenced - } - - /// Set ownership; returns the previous owner. Setting back to `Mock` - /// removes the override entirely. - pub fn set(&mut self, topic: &str, owner: Owner) -> Owner { - let prev = self.owner(topic); - if owner == Owner::Mock { - self.overrides.remove(topic); - } else { - self.overrides.insert(topic.to_string(), owner); - } - prev - } - - /// Snapshot of all non-`Mock` topic overrides, sorted by topic name. - pub fn snapshot(&self) -> Vec<(String, Owner)> { - let mut entries: Vec<_> = self - .overrides - .iter() - .map(|(k, v)| (k.clone(), *v)) - .collect(); - entries.sort_by(|a, b| a.0.cmp(&b.0)); - entries - } -} diff --git a/calypso-sim/src/tests/keymap.rs b/calypso-sim/src/tests/keymap.rs index 191f56c..b3d5fe2 100644 --- a/calypso-sim/src/tests/keymap.rs +++ b/calypso-sim/src/tests/keymap.rs @@ -2,7 +2,7 @@ //! shape disambiguation, and the load-time validation (unknown/looping invokes, //! publish value-arity) that keeps replays terminating and well-formed. -use crate::keymap::{Step, collect_topics, parse_scenario, validate}; +use crate::keymap::{Prim, Step, flatten, parse_scenario, validate}; #[test] fn step_shapes_are_unambiguous() { @@ -67,7 +67,7 @@ fn validate_requires_exactly_one_of_value_or_values() { } #[test] -fn collect_topics_follows_invokes() { +fn flatten_inlines_invoked_actions() { let scenario = parse_scenario( r#"{ "big": { "steps": ["small", {"topic": "A", "value": 1.0}] }, @@ -77,9 +77,24 @@ fn collect_topics_follows_invokes() { .unwrap(); validate(&scenario).unwrap(); - let topics = collect_topics(&scenario, "big"); + // Flattening "big" must inline "small"'s steps in place, so a replay runs + // an invoked action's publishes and sleeps, not just the invoker's own. + let mut prims = Vec::new(); + flatten(&scenario, "big", &mut prims); + let topics: Vec<&str> = prims + .iter() + .filter_map(|p| match p { + Prim::Publish { topic, .. } => Some(topic.as_str()), + Prim::Sleep(_) => None, + }) + .collect(); + assert_eq!( + topics, + ["B", "A"], + "invoked action's publish must be inlined before the invoker's own" + ); assert!( - topics.contains("A") && topics.contains("B"), - "topics must be gathered through invoked actions, got {topics:?}" + prims.iter().any(|p| matches!(p, Prim::Sleep(5))), + "invoked action's sleep must be inlined too, got {prims:?}" ); } diff --git a/calypso-sim/tests/stream.rs b/calypso-sim/tests/stream.rs index 246e55c..aa20748 100644 --- a/calypso-sim/tests/stream.rs +++ b/calypso-sim/tests/stream.rs @@ -3,10 +3,9 @@ //! //! No broker required. `AsyncClient::publish` only enqueues, and the sim's //! eventloop poller retries a missing broker instead of dropping the queue (see -//! `modes::poll_eventloop`), so every `publish` still returns a `ts_us`, and -//! ownership is answered from the JSON-RPC responses. Observing the actual bytes -//! on the wire needs a live broker (Siren, in the Docker compose stack) and is -//! intentionally out of scope — see `README.md`. +//! `modes::poll_eventloop`), so every `publish` still returns a `ts_us`. +//! Observing the actual bytes on the wire needs a live broker (Siren, in the +//! Docker compose stack) and is intentionally out of scope — see `README.md`. use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; @@ -24,22 +23,10 @@ struct Sim { impl Sim { /// Spawn the sim in stream mode with the mock heartbeat off. fn spawn() -> Self { - Self::spawn_inner(false) - } - - /// Spawn with `--mock` so the heartbeat runs alongside the stream driver. - fn spawn_with_mock() -> Self { - Self::spawn_inner(true) - } - - fn spawn_inner(with_mock: bool) -> Self { let mut cmd = Command::new(env!("CARGO_BIN_EXE_calypso-sim")); // A closed port: no broker is needed, and this keeps the sim off any // real broker a developer happens to be running. cmd.arg("-u").arg("127.0.0.1:47654").arg("--stream"); - if with_mock { - cmd.arg("--mock"); - } cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -107,16 +94,6 @@ impl Sim { self.call(method, params) .unwrap_or_else(|(code, msg)| panic!("{method} failed: [{code}] {msg}")) } - - /// Owner string for `topic` per `status`, or `None` when it is still `mock`. - fn owner_of(&mut self, topic: &str) -> Option<String> { - self.ok("status", json!({}))["overrides"] - .as_array() - .into_iter() - .flatten() - .find(|o| o["topic"] == json!(topic)) - .map(|o| o["owner"].as_str().unwrap_or_default().to_string()) - } } impl Drop for Sim { @@ -161,6 +138,15 @@ fn publish_requires_exactly_one_of_value_or_values() { ) .expect_err("value + values together must error"); assert_eq!(code, -32602); + // Exactly one -> accepted, returns a timestamp (publish only enqueues, so no + // broker is needed for this to succeed). + assert!( + sim.ok("publish", json!({"topic": "T", "value": 1.0}))["ts_us"] + .as_u64() + .unwrap_or(0) + > 0, + "a well-formed publish must return a ts_us" + ); } #[test] @@ -186,47 +172,3 @@ fn malformed_requests_are_rejected_with_standard_codes() { "missing method must be rejected as invalid request, got {resp}" ); } - -#[test] -fn ownership_isolation_through_claim_silence_release() { - // With the mock heartbeat running: a claimed topic is `stream`-owned - // (so the heartbeat yields), a silenced topic rejects even the driver, and - // release returns it to `mock`. - let mut sim = Sim::spawn_with_mock(); - let topic = "VCU/CarState/torque_limit_percentage"; - - sim.ok("claim", json!({"topic": topic})); - assert_eq!( - sim.owner_of(topic).as_deref(), - Some("stream"), - "claim must mark the topic stream-owned" - ); - assert!( - sim.ok("publish", json!({"topic": topic, "value": 0.42}))["ts_us"] - .as_u64() - .unwrap_or(0) - > 0, - "driver publish accepted while claimed" - ); - - sim.ok("silence", json!({"topic": topic})); - assert_eq!( - sim.ok("publish", json!({"topic": topic, "value": 0.99}))["skipped"].as_str(), - Some("silenced"), - "silenced topics reject even the driver" - ); - - sim.ok("release", json!({"topic": topic})); - assert_eq!( - sim.owner_of(topic), - None, - "release returns the topic to mock (no override)" - ); - assert!( - sim.ok("publish", json!({"topic": topic, "value": 0.5}))["ts_us"] - .as_u64() - .unwrap_or(0) - > 0, - "driver publish accepted again after release" - ); -} From 2ce2324b1077a4da5006ecc50e7bd21fc6afa30d Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Sun, 12 Jul 2026 21:26:48 -0400 Subject: [PATCH 26/29] #305 - harden sim range guards + validation, simplify topic derivation Review + /simplify follow-ups on the static-ownership work: - simulatable_message: guard inverted (min > max / inc_min > inc_max) sim ranges, not just degenerate (min == max), so a malformed spec can't panic random_range on a backwards range. update() already clamps via get_rand_offset. - keymap: move the duplicate-key check into validate() so it runs at load for --play too (not just interactive mode), and add a regression test. - stream: distinguish empty `values` from missing value/values in the JSON-RPC error message. - keymap: derive scenario_topics by unioning each action's own publish steps instead of flattening invokes -- equivalent (every invoke target is a top-level action), and drops a throwaway Vec<Prim> alloc and the acyclic precondition. - stream: cache the list_topics (name, unit) pairs in a LazyLock instead of rebuilding every component (with per-point RNG init) on each request. --- calypso-sim/src/keymap.rs | 29 +++++++++++++++----------- calypso-sim/src/modes/stream.rs | 21 ++++++++++++++----- calypso-sim/src/simulatable_message.rs | 19 ++++++++++------- calypso-sim/src/tests/keymap.rs | 15 +++++++++++++ 4 files changed, 59 insertions(+), 25 deletions(-) diff --git a/calypso-sim/src/keymap.rs b/calypso-sim/src/keymap.rs index d9f7e5c..3bf982a 100644 --- a/calypso-sim/src/keymap.rs +++ b/calypso-sim/src/keymap.rs @@ -33,8 +33,9 @@ pub fn load_scenario(path: &str) -> Result<Scenario, String> { /// instead of misbehaving mid-run: /// * the scenario is non-empty, /// * every publish step sets exactly one of `value` / `values`, -/// * every invoke step names an action that exists, and -/// * the invoke graph is acyclic, so every action terminates. +/// * every invoke step names an action that exists, +/// * the invoke graph is acyclic, so every action terminates, and +/// * no two actions bind the same interactive `key`. pub fn validate(scenario: &Scenario) -> Result<(), String> { if scenario.is_empty() { return Err("Scenario is empty".into()); @@ -58,6 +59,9 @@ pub fn validate(scenario: &Scenario) -> Result<(), String> { for name in scenario.keys() { detect_cycle(scenario, name, &mut Vec::new())?; } + // Reject two actions binding the same interactive key. Irrelevant to + // `--play`, but keeps "well-formed" a single notion decided at load time. + key_bindings(scenario)?; Ok(()) } @@ -157,20 +161,21 @@ pub fn key_bindings(scenario: &Scenario) -> Result<HashMap<char, String>, String Ok(map) } -/// Every topic the scenario can publish, following invokes across all actions. +/// Every topic the scenario can publish, across all actions. Every invoke target +/// is itself a top-level action, so unioning each action's own publish steps +/// already covers everything reachable — no need to follow invokes (or require a +/// validated/acyclic scenario) here. +/// /// Resolved once at startup so the mock heartbeat can cede these topics to the /// keymap/replay driver (see [`crate::ownership`]). #[must_use] pub fn scenario_topics(scenario: &Scenario) -> BTreeSet<String> { - let mut prims = Vec::new(); - for name in scenario.keys() { - flatten(scenario, name, &mut prims); - } - prims - .into_iter() - .filter_map(|p| match p { - Prim::Publish { topic, .. } => Some(topic), - Prim::Sleep(_) => None, + scenario + .values() + .flat_map(|action| &action.steps) + .filter_map(|step| match step { + Step::Publish { topic, .. } => Some(topic.clone()), + Step::Invoke(_) | Step::Sleep { .. } => None, }) .collect() } diff --git a/calypso-sim/src/modes/stream.rs b/calypso-sim/src/modes/stream.rs index b384d7e..068d231 100644 --- a/calypso-sim/src/modes/stream.rs +++ b/calypso-sim/src/modes/stream.rs @@ -1,3 +1,5 @@ +use std::sync::LazyLock; + use crate::simulate_data::create_simulated_components; use rumqttc::v5::AsyncClient; use serde::Deserialize; @@ -125,8 +127,8 @@ async fn handle_publish(id: Value, params: Value, client: &AsyncClient) -> Value } (Some(v), None) => vec![v], (None, Some(vs)) if !vs.is_empty() => vs, - // None/None or None/Some(empty) - _ => return error(id, ERR_INVALID_PARAMS, "missing `value` or `values`"), + (None, Some(_)) => return error(id, ERR_INVALID_PARAMS, "`values` must be non-empty"), + (None, None) => return error(id, ERR_INVALID_PARAMS, "missing `value` or `values`"), }; let unit = p.unit.unwrap_or_default(); @@ -136,11 +138,20 @@ async fn handle_publish(id: Value, params: Value, client: &AsyncClient) -> Value } } +/// Topic (name, unit) pairs for `list_topics`, computed once. Building the full +/// component set runs each component's RNG initializer — wasted work for the +/// static name/unit returned here — so cache it rather than rebuilding per call. +static TOPICS: LazyLock<Vec<(String, String)>> = LazyLock::new(|| { + create_simulated_components() + .into_iter() + .map(|c| (c.name, c.unit)) + .collect() +}); + fn handle_list_topics(id: Value) -> Value { - let components = create_simulated_components(); - let topics: Vec<_> = components + let topics: Vec<Value> = TOPICS .iter() - .map(|c| json!({"name": c.name, "unit": c.unit})) + .map(|(name, unit)| json!({"name": name, "unit": unit})) .collect(); ok(id, json!({"topics": topics})) } diff --git a/calypso-sim/src/simulatable_message.rs b/calypso-sim/src/simulatable_message.rs index 45d6f85..3951074 100644 --- a/calypso-sim/src/simulatable_message.rs +++ b/calypso-sim/src/simulatable_message.rs @@ -132,12 +132,13 @@ impl SimValue { current, .. } => { - // Guard a degenerate (min == max) range: `random_range` panics - // on an empty range. Mirrors `keymap::randomize_component`. - *current = if (*max - *min).abs() < f32::EPSILON { - *min - } else { + // Guard a degenerate (min == max) or inverted (min > max) range: + // `random_range` panics on an empty or backwards range, and the + // spec validator does not reject either. + *current = if *max - *min > f32::EPSILON { rng.random_range(*min..*max) + } else { + *min }; if *inc_min != 0.0 { *current = (*current / *inc_min).round() * *inc_min; // Round to nearest inc_min @@ -171,15 +172,17 @@ impl SimValue { let mut rng = rand::rng(); let sign = if rng.random_bool(0.5) { 1.0 } else { -1.0 }; - let offset: f32 = if (inc_min - inc_max).abs() < 0.0001 { - inc_min - } else { + let offset: f32 = if inc_max - inc_min > 0.0001 { let rand_offset = rng.random_range(inc_min..inc_max); if inc_min == 0.0 { rand_offset } else { (rand_offset / inc_min).round() * inc_min } + } else { + // Degenerate (inc_min == inc_max) or inverted range: use inc_min + // instead of sampling an empty/backwards range (which panics). + inc_min }; offset * sign } diff --git a/calypso-sim/src/tests/keymap.rs b/calypso-sim/src/tests/keymap.rs index b3d5fe2..e98823b 100644 --- a/calypso-sim/src/tests/keymap.rs +++ b/calypso-sim/src/tests/keymap.rs @@ -47,6 +47,21 @@ fn validate_rejects_unknown_invoke_and_cycles() { assert!(validate(&indirect).is_err(), "a -> b -> a is a cycle"); } +#[test] +fn validate_rejects_duplicate_keys() { + let dup = parse_scenario( + r#"{ "a": {"key":"x","steps":[{"topic":"T","value":1.0}]}, + "b": {"key":"x","steps":[{"topic":"U","value":2.0}]} }"#, + ) + .unwrap(); + // Two actions on the same key is rejected at load, not just in interactive + // mode, so the invariant holds for `--play` too. + assert!( + validate(&dup).is_err(), + "two actions bound to the same key must fail validation" + ); +} + #[test] fn validate_requires_exactly_one_of_value_or_values() { let both = From c3158922be4b3f61bd25ecb95cb2ae4c514c50aa Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Sun, 12 Jul 2026 22:13:50 -0400 Subject: [PATCH 27/29] #305 - consolidate calypso-sim publish value-policy + SimValue range math into shared helpers - Add `publish::resolve_values` as the single "exactly one of value/values, non-empty -> Vec<f32>" policy; scenario validation, replay flattening, and the stream RPC all call it (drops the bespoke `check_publish` and stream's duplicate match arm). - Add `SimValue::{quantize, sample_range_or_low}`; initialize/update/ get_rand_offset now share the snap-to-increment/round and the range-sample panic-guard instead of each repeating them. - Drop redundant `#[serde(default)]` on Option fields (serde already maps a missing Option to None), a stale cross-file comment, dead commented-out code, C-port section banners, and an over-long doc example. --- calypso-sim/src/cli.rs | 2 - calypso-sim/src/keymap.rs | 31 ++------- calypso-sim/src/keymap/model.rs | 27 ++------ calypso-sim/src/main.rs | 3 +- calypso-sim/src/modes/stream.rs | 17 ++--- calypso-sim/src/publish.rs | 15 ++++ calypso-sim/src/simulatable_message.rs | 96 ++++++++++---------------- 7 files changed, 66 insertions(+), 125 deletions(-) diff --git a/calypso-sim/src/cli.rs b/calypso-sim/src/cli.rs index 6b6acfc..91a4e67 100644 --- a/calypso-sim/src/cli.rs +++ b/calypso-sim/src/cli.rs @@ -55,8 +55,6 @@ pub struct Cli { impl Cli { /// Whether the mock heartbeat should run. - /// True if `--mock` was set, OR no other input mode was chosen and - /// `--list-topics` wasn't requested. pub fn run_mock(&self) -> bool { let any_other_mode = self.stream || self.key_map.is_some() || self.play.is_some(); self.mock || (!any_other_mode && !self.list_topics) diff --git a/calypso-sim/src/keymap.rs b/calypso-sim/src/keymap.rs index 3bf982a..f788398 100644 --- a/calypso-sim/src/keymap.rs +++ b/calypso-sim/src/keymap.rs @@ -11,7 +11,7 @@ use std::time::Duration; use rumqttc::v5::AsyncClient; -use crate::publish::publish_data; +use crate::publish::{publish_data, resolve_values}; use crate::raw_mode::line_end; /// Parse a scenario from JSON. Does not validate — call [`validate`] (or use @@ -51,7 +51,9 @@ pub fn validate(scenario: &Scenario) -> Result<(), String> { value, values, .. - } => check_publish(name, topic, value.as_ref(), values.as_deref())?, + } => resolve_values(*value, values.clone()) + .map(|_| ()) + .map_err(|e| format!("action '{name}': step for '{topic}': {e}"))?, Step::Invoke(_) | Step::Sleep { .. } => {} } } @@ -65,26 +67,6 @@ pub fn validate(scenario: &Scenario) -> Result<(), String> { Ok(()) } -fn check_publish( - action: &str, - topic: &str, - value: Option<&f32>, - values: Option<&[f32]>, -) -> Result<(), String> { - match (value, values) { - (Some(_), Some(_)) => Err(format!( - "action '{action}': step for '{topic}' sets both `value` and `values`" - )), - (None, None) => Err(format!( - "action '{action}': step for '{topic}' sets neither `value` nor `values`" - )), - (None, Some([])) => Err(format!( - "action '{action}': step for '{topic}' has empty `values`" - )), - _ => Ok(()), - } -} - /// Depth-first search tracking the current invoke path; a name already on the /// path is a cycle. Runs before any action executes, so [`flatten`] can then /// recurse without a visited-set. @@ -134,10 +116,7 @@ pub(crate) fn flatten(scenario: &Scenario, name: &str, out: &mut Vec<Prim>) { unit, } => out.push(Prim::Publish { topic: topic.clone(), - values: values - .clone() - .or_else(|| value.map(|v| vec![v])) - .unwrap_or_default(), + values: resolve_values(*value, values.clone()).unwrap_or_default(), unit: unit.clone().unwrap_or_default(), }), Step::Sleep { sleep_ms } => out.push(Prim::Sleep(*sleep_ms)), diff --git a/calypso-sim/src/keymap/model.rs b/calypso-sim/src/keymap/model.rs index 33600e3..fdc489d 100644 --- a/calypso-sim/src/keymap/model.rs +++ b/calypso-sim/src/keymap/model.rs @@ -3,28 +3,18 @@ //! //! A scenario is a JSON object mapping action names to [`Action`]s. Each action //! is an ordered list of [`Step`]s, optionally bound to a keyboard `key` and -//! given a `desc`: +//! given a `desc`. A step publishes to a topic, sleeps, or invokes another +//! action by name: //! //! ```json //! { -//! "enter": { "key": "e", "steps": [{"topic": "Wheel/Buttons/button_id", "value": 5}] }, -//! "home": { -//! "key": "h", -//! "desc": "home pulse", -//! "steps": [ -//! {"topic": "VCU/CarState/home_mode", "value": 1}, -//! {"sleep_ms": 10}, -//! {"topic": "VCU/CarState/home_mode", "value": 0} -//! ] -//! }, -//! "menu_wrap": { "key": "w", "steps": ["enter", "home"] }, -//! "demo": { "desc": "run via --play demo", "steps": ["menu_wrap", {"sleep_ms": 500}, "menu_wrap"] } +//! "home": { "key": "h", "steps": [{"topic": "VCU/CarState/home_mode", "value": 1}] }, +//! "combo": { "steps": ["home", {"sleep_ms": 10}, "home"] } //! } //! ``` //! -//! Here `enter` and `home` are leaf actions (publish/sleep steps), `menu_wrap` -//! reuses them via bare-string invoke steps, and `demo` is a keyless replay -//! program run with `--play demo`. See `README.md` for the full format. +//! Here `home` is a leaf action bound to a key, and `combo` reuses it via a +//! bare-string invoke step. See `README.md` for the full format. use std::collections::HashMap; @@ -44,12 +34,10 @@ pub struct Action { /// Keyboard key that fires this action in interactive mode. Actions with /// no `key` are still reachable via `--play` or another action's invoke /// step, so pure replay programs need no key. - #[serde(default)] pub key: Option<char>, /// Human-readable label shown in the interactive listing and in the log /// line printed when the action fires. - #[serde(default)] pub desc: Option<String>, /// The steps run, in order, each time the action fires. @@ -69,11 +57,8 @@ pub enum Step { /// must be set; `unit` defaults to empty. This is validated at load. Publish { topic: String, - #[serde(default)] value: Option<f32>, - #[serde(default)] values: Option<Vec<f32>>, - #[serde(default)] unit: Option<String>, }, diff --git a/calypso-sim/src/main.rs b/calypso-sim/src/main.rs index 989c6be..7c9f53e 100644 --- a/calypso-sim/src/main.rs +++ b/calypso-sim/src/main.rs @@ -116,8 +116,7 @@ async fn run_foreground( if cli.stream { modes::stream::run(token.clone(), client.clone()).await } else if let Some(action) = &cli.play { - // clap enforces `--play requires --key-map`, so `main` has loaded the - // scenario; a missing one here is an impossible state, not a runtime error. + // A missing scenario here is an impossible state, not a runtime error. let scenario = scenario.expect("clap enforces --play requires --key-map"); modes::replay::run(client.clone(), scenario, action).await } else if cli.key_map.is_some() { diff --git a/calypso-sim/src/modes/stream.rs b/calypso-sim/src/modes/stream.rs index 068d231..e08c79b 100644 --- a/calypso-sim/src/modes/stream.rs +++ b/calypso-sim/src/modes/stream.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio_util::sync::CancellationToken; -use crate::publish::publish_data; +use crate::publish::{publish_data, resolve_values}; /// JSON-RPC 2.0 over stdio. Reads one request per line from stdin, writes /// one response per line to stdout. Diagnostics go to stderr. @@ -117,18 +117,9 @@ async fn handle_publish(id: Value, params: Value, client: &AsyncClient) -> Value Err(e) => return error(id, ERR_INVALID_PARAMS, &format!("Invalid params: {e}")), }; - let values = match (p.value, p.values) { - (Some(_), Some(_)) => { - return error( - id, - ERR_INVALID_PARAMS, - "specify `value` or `values`, not both", - ); - } - (Some(v), None) => vec![v], - (None, Some(vs)) if !vs.is_empty() => vs, - (None, Some(_)) => return error(id, ERR_INVALID_PARAMS, "`values` must be non-empty"), - (None, None) => return error(id, ERR_INVALID_PARAMS, "missing `value` or `values`"), + let values = match resolve_values(p.value, p.values) { + Ok(vs) => vs, + Err(e) => return error(id, ERR_INVALID_PARAMS, &e), }; let unit = p.unit.unwrap_or_default(); diff --git a/calypso-sim/src/publish.rs b/calypso-sim/src/publish.rs index 8c148a7..e7a479f 100644 --- a/calypso-sim/src/publish.rs +++ b/calypso-sim/src/publish.rs @@ -41,3 +41,18 @@ pub async fn publish_data( Ok(timestamp) } + +/// Resolve a publish request's `value`/`values` into the concrete values to send. +/// +/// One rule for every input path (scenario load, replay, and the stream RPC): +/// exactly one of `value` (scalar) or `values` (array) must be set, and `values` +/// must be non-empty. On violation returns a bare reason; callers add context. +pub fn resolve_values(value: Option<f32>, values: Option<Vec<f32>>) -> Result<Vec<f32>, String> { + match (value, values) { + (Some(_), Some(_)) => Err("set exactly one of `value` or `values`, not both".into()), + (Some(v), None) => Ok(vec![v]), + (None, Some(vs)) if !vs.is_empty() => Ok(vs), + (None, Some(_)) => Err("`values` must be non-empty".into()), + (None, None) => Err("set `value` or `values`".into()), + } +} diff --git a/calypso-sim/src/simulatable_message.rs b/calypso-sim/src/simulatable_message.rs index 3951074..50d36d6 100644 --- a/calypso-sim/src/simulatable_message.rs +++ b/calypso-sim/src/simulatable_message.rs @@ -11,8 +11,6 @@ use regex::Regex; use std::sync::LazyLock; use std::time::Instant; -/********************* SIMULATE_MESSAGE.H *********************/ - /** * A `SimComponent` roughly corresponds to a `NetField` with properties inherited from `CANMsg` */ @@ -63,8 +61,6 @@ pub enum SimValue { }, } -/********************* SIMULATE_MESSAGE.C *********************/ - impl SimComponent { pub fn initialize(&mut self) { self.points.iter_mut().for_each(SimPoint::initialize); @@ -132,20 +128,8 @@ impl SimValue { current, .. } => { - // Guard a degenerate (min == max) or inverted (min > max) range: - // `random_range` panics on an empty or backwards range, and the - // spec validator does not reject either. - *current = if *max - *min > f32::EPSILON { - rng.random_range(*min..*max) - } else { - *min - }; - if *inc_min != 0.0 { - *current = (*current / *inc_min).round() * *inc_min; // Round to nearest inc_min - } - if *round { - *current = current.round(); // Round to nearest whole number - } + let sampled = Self::sample_range_or_low(&mut rng, *min, *max, f32::EPSILON); + *current = Self::quantize(sampled, *inc_min, *round); } SimValue::Discrete { options, current } => { // `choose` is empty-safe (returns None); direct indexing panics. @@ -163,28 +147,36 @@ impl SimValue { } } - /** - * Get a random offset within the range of `sim_inc_min` and `sim_inc_max` with a random sign. - * Use `sim_inc_min` as the offset if `sim_inc_min` == `sim_inc_max`. - * Rounds the offset to the nearest `sim_inc_min` if `sim_inc_min` is not 0. - */ + /// Snap `value` to the nearest multiple of `inc_min` (a no-op when `inc_min` + /// is 0), then optionally round to a whole number. + fn quantize(value: f32, inc_min: f32, round: bool) -> f32 { + let snapped = if inc_min == 0.0 { + value + } else { + (value / inc_min).round() * inc_min + }; + if round { snapped.round() } else { snapped } + } + + /// Sample uniformly from `lo..hi`, falling back to `lo` when the range is + /// empty or inverted — `random_range` panics on those, and the spec + /// validator rejects neither. `eps` is the width below which the range is + /// treated as degenerate. + fn sample_range_or_low(rng: &mut impl Rng, lo: f32, hi: f32, eps: f32) -> f32 { + if hi - lo > eps { + rng.random_range(lo..hi) + } else { + lo + } + } + + /// A random offset in `inc_min..inc_max` with a random sign, snapped to a + /// multiple of `inc_min` (or just `inc_min` when the range is degenerate). fn get_rand_offset(inc_min: f32, inc_max: f32) -> f32 { let mut rng = rand::rng(); let sign = if rng.random_bool(0.5) { 1.0 } else { -1.0 }; - - let offset: f32 = if inc_max - inc_min > 0.0001 { - let rand_offset = rng.random_range(inc_min..inc_max); - if inc_min == 0.0 { - rand_offset - } else { - (rand_offset / inc_min).round() * inc_min - } - } else { - // Degenerate (inc_min == inc_max) or inverted range: use inc_min - // instead of sampling an empty/backwards range (which panics). - inc_min - }; - offset * sign + let sampled = Self::sample_range_or_low(&mut rng, inc_min, inc_max, 0.0001); + Self::quantize(sampled, inc_min, false) * sign } fn update(&mut self) { @@ -199,35 +191,19 @@ impl SimValue { } => { const MAX_ATTEMPTS: u8 = 10; - let cur = *current; - let min_val = *min; - let max_val = *max; - let inc_min_val = *inc_min; - let inc_max_val = *inc_max; - - // First, call get_rand_offset without partially borrowing each field - // let mut new_value = cur + SimValue::get_rand_offset(inc_min_val, inc_max_val); - let mut new_value = cur + SimValue::get_rand_offset(inc_min_val, inc_max_val); - + let mut new_value = *current + SimValue::get_rand_offset(*inc_min, *inc_max); let mut attempts = 0; - while (new_value < min_val || new_value > max_val) && attempts < MAX_ATTEMPTS { - new_value = cur + SimValue::get_rand_offset(inc_min_val, inc_max_val); + while (new_value < *min || new_value > *max) && attempts < MAX_ATTEMPTS { + new_value = *current + SimValue::get_rand_offset(*inc_min, *inc_max); attempts += 1; } + // Range too tight to land in; keep the current value. if attempts >= MAX_ATTEMPTS { return; } - if inc_min_val != 0.0 { - new_value = (new_value / inc_min_val).round() * inc_min_val; - } - - if *round { - new_value = new_value.round(); - } - - *current = new_value; + *current = Self::quantize(new_value, *inc_min, *round); } SimValue::Discrete { options, current } => { let mut rng = rand::rng(); @@ -249,9 +225,7 @@ impl SimValue { } } -/// Placeholder pattern (`{}`) for in-topic value injection. Compiled once and -/// reused, unlike the upstream copy in `calypso`'s `src/simulatable_message.rs` -/// which recompiles it on every call; mirror this hoist if you optimize upstream. +/// Placeholder pattern (`{}`) for in-topic value injection, compiled once and reused. static PLACEHOLDER_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\}").unwrap()); /** From a953038a0e41009f2b0c8b17a3bbcde1438fbc46 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Sun, 12 Jul 2026 19:28:01 -0700 Subject: [PATCH 28/29] #305 - extract calypso-sim event-loop bodies into named helpers - interactive: `classify()` + an `Input` enum isolate the crossterm `KeyEvent` destructuring, so the `select!` loop is a flat 4-arm match (behavior unchanged: Ctrl+C/EOF quit, bound key runs, error reports+quits). - mock: the `interval.tick()` arm calls `publish_due()`; the walk-and-publish logic moves into that helper. - main: the mock-heartbeat partition setup (build filter, resolve `Partition`, print split, spawn) moves into `spawn_mock()`, slimming `main` to a sequence. --- calypso-sim/src/main.rs | 54 +++++++++++++----------- calypso-sim/src/modes/interactive.rs | 62 +++++++++++++++++++--------- calypso-sim/src/modes/mock.rs | 28 +++++++------ 3 files changed, 88 insertions(+), 56 deletions(-) diff --git a/calypso-sim/src/main.rs b/calypso-sim/src/main.rs index 7c9f53e..9caed9c 100644 --- a/calypso-sim/src/main.rs +++ b/calypso-sim/src/main.rs @@ -56,31 +56,7 @@ async fn main() { let token = CancellationToken::new(); let poll_handle = tokio::spawn(modes::poll_eventloop(token.clone(), eventloop)); - let mock_handle = if cli.run_mock() { - // Validate the enable/disable regex patterns up front so a bad pattern - // fails fast with a non-zero exit instead of silently disabling the - // entire mock heartbeat inside the spawned task. - let filter = ownership::FilterMode::build(&cli.enable_topic, &cli.disable_topic) - .unwrap_or_else(|err| { - eprintln!("Error: {err}"); - exit(1); - }); - // Reserve the driver's topics (a scenario's, if any) from the heartbeat, - // then print the resulting split so it's clear who drives what. - let driver_owned = scenario - .as_ref() - .map(keymap::scenario_topics) - .unwrap_or_default(); - let partition = ownership::Partition::resolve(&filter, driver_owned); - partition.print_summary(); - Some(tokio::spawn(modes::mock::run( - token.clone(), - client.clone(), - partition.heartbeat, - ))) - } else { - None - }; + let mock_handle = spawn_mock(&cli, scenario.as_ref(), &client, &token); let foreground = run_foreground(&cli, &token, &client, scenario).await; @@ -130,6 +106,34 @@ async fn run_foreground( } } +/// If the mock heartbeat is enabled, resolve its share of the topic space +/// against the driver (a scenario's topics, if any), print the split, and spawn +/// the task. Exits on a bad enable/disable pattern — fail fast, before spawning +/// (rather than silently disabling the heartbeat inside the spawned task). +fn spawn_mock( + cli: &Cli, + scenario: Option<&keymap::Scenario>, + client: &AsyncClient, + token: &CancellationToken, +) -> Option<tokio::task::JoinHandle<()>> { + if !cli.run_mock() { + return None; + } + let filter = ownership::FilterMode::build(&cli.enable_topic, &cli.disable_topic) + .unwrap_or_else(|err| { + eprintln!("Error: {err}"); + exit(1); + }); + let driver_owned = scenario.map(keymap::scenario_topics).unwrap_or_default(); + let partition = ownership::Partition::resolve(&filter, driver_owned); + partition.print_summary(); + Some(tokio::spawn(modes::mock::run( + token.clone(), + client.clone(), + partition.heartbeat, + ))) +} + fn init_tracing() { // Tracing always writes to stderr so stdout stays clean for stream mode // and keymap-mode logs. diff --git a/calypso-sim/src/modes/interactive.rs b/calypso-sim/src/modes/interactive.rs index ecc4cd5..f869b50 100644 --- a/calypso-sim/src/modes/interactive.rs +++ b/calypso-sim/src/modes/interactive.rs @@ -33,29 +33,19 @@ pub async fn run( loop { tokio::select! { () = token.cancelled() => break, - event = reader.next() => match event { - Some(Ok(Event::Key(KeyEvent { - code: KeyCode::Char('c'), - modifiers, - kind: KeyEventKind::Press, - .. - }))) if modifiers.contains(KeyModifiers::CONTROL) => break, - Some(Ok(Event::Key(KeyEvent { - code: KeyCode::Char(ch), - kind: KeyEventKind::Press, - .. - }))) => { - if let Some(name) = keys.get(&ch) { - run_action(&scenario, name, &client).await; - } - } - Some(Err(e)) => { + event = reader.next() => match classify(event) { + Input::Quit => break, + Input::Error(e) => { print!("Terminal event error: {e}{}", line_end()); let _ = std::io::stdout().flush(); break; } - None => break, - _ => {} + Input::Key(ch) => { + if let Some(name) = keys.get(&ch) { + run_action(&scenario, name, &client).await; + } + } + Input::Ignore => {} } } } @@ -65,3 +55,37 @@ pub async fn run( println!("Shutting down..."); Ok(()) } + +/// What the interactive loop should do with one terminal event. +enum Input { + /// A printable key was pressed — run its bound action, if any. + Key(char), + /// Ctrl+C or end-of-stream: leave the loop. + Quit, + /// The event stream errored; report and leave. + Error(String), + /// Anything else (key release, resize, non-char key): ignore. + Ignore, +} + +/// Classify one [`EventStream`] item into the loop's vocabulary, hiding the +/// crossterm `KeyEvent` destructuring. +fn classify(event: Option<std::io::Result<Event>>) -> Input { + match event { + None => Input::Quit, + Some(Err(e)) => Input::Error(e.to_string()), + Some(Ok(Event::Key(KeyEvent { + code: KeyCode::Char(ch), + modifiers, + kind: KeyEventKind::Press, + .. + }))) => { + if ch == 'c' && modifiers.contains(KeyModifiers::CONTROL) { + Input::Quit + } else { + Input::Key(ch) + } + } + _ => Input::Ignore, + } +} diff --git a/calypso-sim/src/modes/mock.rs b/calypso-sim/src/modes/mock.rs index 8c3583a..3c5f131 100644 --- a/calypso-sim/src/modes/mock.rs +++ b/calypso-sim/src/modes/mock.rs @@ -28,18 +28,22 @@ pub async fn run(token: CancellationToken, client: AsyncClient, mut components: debug!("Mock: shutting down."); break; } - _ = interval.tick() => { - for component in &mut components { - if !component.should_update() { - continue; - } - component.update(); - let data = component.get_decode_data(); - if let Err(e) = publish_data(&client, &data.topic, &data.unit, &data.value).await { - warn!("Mock publish failed for {}: {e}", data.topic); - } - } - } + _ = interval.tick() => publish_due(&mut components, &client).await, + } + } +} + +/// Publish every component that is due for an update (per its `sim_freq`), +/// advancing its simulated value first. +async fn publish_due(components: &mut [SimComponent], client: &AsyncClient) { + for component in components { + if !component.should_update() { + continue; + } + component.update(); + let data = component.get_decode_data(); + if let Err(e) = publish_data(client, &data.topic, &data.unit, &data.value).await { + warn!("Mock publish failed for {}: {e}", data.topic); } } } From 3dc738915fc6ace659a56fdb416c31b711648b80 Mon Sep 17 00:00:00 2001 From: wyattb <bracy.w@northeastern.edu> Date: Sun, 12 Jul 2026 21:28:38 -0700 Subject: [PATCH 29/29] #305 - simplify calypso-sim scenario validation and value-resolution helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detect_cycle: add a shared "verified acyclic" set so an action reused by many others is walked once, not once per path that reaches it — avoids exponential blowup on diamond-shaped invoke graphs. Cycle detection and error messages are unchanged. - resolve_values: take Option<&[f32]> instead of Option<Vec<f32>>, dropping the throwaway clone in validate/flatten; it now clones once internally only when returning the accepted array. - stream: replace two #[allow(clippy::needless_pass_by_value)] with #[expect(..., reason = ...)] so the suppressions are self-checking and documented. --- calypso-sim/src/keymap.rs | 24 +++++++++++++++++++----- calypso-sim/src/modes/stream.rs | 12 +++++++++--- calypso-sim/src/publish.rs | 4 ++-- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/calypso-sim/src/keymap.rs b/calypso-sim/src/keymap.rs index f788398..6434d98 100644 --- a/calypso-sim/src/keymap.rs +++ b/calypso-sim/src/keymap.rs @@ -51,15 +51,16 @@ pub fn validate(scenario: &Scenario) -> Result<(), String> { value, values, .. - } => resolve_values(*value, values.clone()) + } => resolve_values(*value, values.as_deref()) .map(|_| ()) .map_err(|e| format!("action '{name}': step for '{topic}': {e}"))?, Step::Invoke(_) | Step::Sleep { .. } => {} } } } + let mut verified = BTreeSet::new(); for name in scenario.keys() { - detect_cycle(scenario, name, &mut Vec::new())?; + detect_cycle(scenario, name, &mut Vec::new(), &mut verified)?; } // Reject two actions binding the same interactive key. Irrelevant to // `--play`, but keeps "well-formed" a single notion decided at load time. @@ -70,7 +71,19 @@ pub fn validate(scenario: &Scenario) -> Result<(), String> { /// Depth-first search tracking the current invoke path; a name already on the /// path is a cycle. Runs before any action executes, so [`flatten`] can then /// recurse without a visited-set. -fn detect_cycle(scenario: &Scenario, name: &str, path: &mut Vec<String>) -> Result<(), String> { +/// +/// `verified` accumulates names whose whole reachable subgraph is already proven +/// acyclic, so an action reused by many others is walked once, not once per path +/// that reaches it (otherwise a diamond-shaped invoke graph is exponential). +fn detect_cycle( + scenario: &Scenario, + name: &str, + path: &mut Vec<String>, + verified: &mut BTreeSet<String>, +) -> Result<(), String> { + if verified.contains(name) { + return Ok(()); + } if path.iter().any(|n| n == name) { path.push(name.to_string()); return Err(format!("cyclic action invocation: {}", path.join(" -> "))); @@ -79,11 +92,12 @@ fn detect_cycle(scenario: &Scenario, name: &str, path: &mut Vec<String>) -> Resu if let Some(action) = scenario.get(name) { for step in &action.steps { if let Step::Invoke(target) = step { - detect_cycle(scenario, target, path)?; + detect_cycle(scenario, target, path, verified)?; } } } path.pop(); + verified.insert(name.to_string()); Ok(()) } @@ -116,7 +130,7 @@ pub(crate) fn flatten(scenario: &Scenario, name: &str, out: &mut Vec<Prim>) { unit, } => out.push(Prim::Publish { topic: topic.clone(), - values: resolve_values(*value, values.clone()).unwrap_or_default(), + values: resolve_values(*value, values.as_deref()).unwrap_or_default(), unit: unit.clone().unwrap_or_default(), }), Step::Sleep { sleep_ms } => out.push(Prim::Sleep(*sleep_ms)), diff --git a/calypso-sim/src/modes/stream.rs b/calypso-sim/src/modes/stream.rs index e08c79b..c316f6a 100644 --- a/calypso-sim/src/modes/stream.rs +++ b/calypso-sim/src/modes/stream.rs @@ -117,7 +117,7 @@ async fn handle_publish(id: Value, params: Value, client: &AsyncClient) -> Value Err(e) => return error(id, ERR_INVALID_PARAMS, &format!("Invalid params: {e}")), }; - let values = match resolve_values(p.value, p.values) { + let values = match resolve_values(p.value, p.values.as_deref()) { Ok(vs) => vs, Err(e) => return error(id, ERR_INVALID_PARAMS, &e), }; @@ -147,12 +147,18 @@ fn handle_list_topics(id: Value) -> Value { ok(id, json!({"topics": topics})) } -#[allow(clippy::needless_pass_by_value)] +#[expect( + clippy::needless_pass_by_value, + reason = "id is moved into the json! payload" +)] fn ok(id: Value, result: impl serde::Serialize) -> Value { json!({"jsonrpc": "2.0", "id": id, "result": result}) } -#[allow(clippy::needless_pass_by_value)] +#[expect( + clippy::needless_pass_by_value, + reason = "id is moved into the json! payload" +)] fn error(id: Value, code: i32, message: &str) -> Value { json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) } diff --git a/calypso-sim/src/publish.rs b/calypso-sim/src/publish.rs index e7a479f..02af8ea 100644 --- a/calypso-sim/src/publish.rs +++ b/calypso-sim/src/publish.rs @@ -47,11 +47,11 @@ pub async fn publish_data( /// One rule for every input path (scenario load, replay, and the stream RPC): /// exactly one of `value` (scalar) or `values` (array) must be set, and `values` /// must be non-empty. On violation returns a bare reason; callers add context. -pub fn resolve_values(value: Option<f32>, values: Option<Vec<f32>>) -> Result<Vec<f32>, String> { +pub fn resolve_values(value: Option<f32>, values: Option<&[f32]>) -> Result<Vec<f32>, String> { match (value, values) { (Some(_), Some(_)) => Err("set exactly one of `value` or `values`, not both".into()), (Some(v), None) => Ok(vec![v]), - (None, Some(vs)) if !vs.is_empty() => Ok(vs), + (None, Some(vs)) if !vs.is_empty() => Ok(vs.to_vec()), (None, Some(_)) => Err("`values` must be non-empty".into()), (None, None) => Err("set `value` or `values`".into()), }