diff --git a/.github/workflows/calypso-sim-ci.yml b/.github/workflows/calypso-sim-ci.yml new file mode 100644 index 0000000..293532b --- /dev/null +++ b/.github/workflows/calypso-sim-ci.yml @@ -0,0 +1,47 @@ +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 + # 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/.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/Cargo.lock b/Cargo.lock index e5c1f91..b371248 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,7 @@ dependencies = [ "regex", "rumqttc", "schemars", + "serde", "serde_json", "socketcan", "tokio", @@ -912,9 +913,9 @@ dependencies = [ [[package]] name = "phf" -version = "0.14.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", "phf_shared", @@ -923,9 +924,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.14.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", "phf_shared", @@ -933,9 +934,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.14.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", "phf_shared", @@ -946,9 +947,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.14.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ "siphasher", ] 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"] diff --git a/README.md b/README.md index 0f86c07..61a6478 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,6 @@ 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``` - -#### 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``` +### Simulation + +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..edbd529 --- /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.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +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..74e01c2 --- /dev/null +++ b/calypso-sim/Cargo.toml @@ -0,0 +1,45 @@ +# 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" } + +[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" + +[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..3846bb2 --- /dev/null +++ b/calypso-sim/README.md @@ -0,0 +1,127 @@ +# 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 | +|---|---|---| +| **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` (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_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 ` and `--disable-topic ` 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`. + +## Ownership (static partition) + +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`) + +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 +{ + "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"] } +} +``` + +Each **step** is one of three shapes, disambiguated purely by form (so there is no order-dependent parsing): + +| 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. + +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`) + +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":"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":{"ts_us":1735347123456999}} +... +``` + +| Method | Params | Result | +|---|---|---| +| `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 + +The tests need **no broker** — just run: + +``` +cd calypso-sim +cargo test +``` + +| Layer | Where | What it checks | +|---|---|---| +| 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 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`. 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/build.rs b/calypso-sim/build.rs new file mode 100644 index 0000000..279a412 --- /dev/null +++ b/calypso-sim/build.rs @@ -0,0 +1,14 @@ +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() + .includes(["src/proto"]) + .input("src/proto/serverdata.proto") + .out_dir("src/proto") + .run_from_script(); +} diff --git a/calypso-sim/manual_sim_buttons.keymap.json b/calypso-sim/manual_sim_buttons.keymap.json new file mode 100644 index 0000000..881ea6f --- /dev/null +++ b/calypso-sim/manual_sim_buttons.keymap.json @@ -0,0 +1,32 @@ +{ + "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 } + ] + }, + "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 new file mode 100644 index 0000000..d83bb26 --- /dev/null +++ b/calypso-sim/manual_sim_keymap.example.json @@ -0,0 +1,5 @@ +{ + "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 new file mode 100644 index 0000000..91a4e67 --- /dev/null +++ b/calypso-sim/src/cli.rs @@ -0,0 +1,62 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +#[command( + name = "calypso-sim", + version, + about = "MQTT simulation tool for Calypso (mock, interactive, replay, or streamed)" +)] +pub struct Cli { + /// MQTT broker host:port + #[arg( + short = 'u', + long, + env = "CALYPSO_SIREN_HOST_URL", + default_value = "localhost:1883" + )] + pub siren_host_url: String, + + /// List all simulatable topics and exit + #[arg(long)] + pub list_topics: bool, + + /// Run the mock heartbeat simulator. Default ON when no other mode + /// is chosen; explicit when paired with --key-map or --stream. + #[arg(long)] + pub mock: bool, + + /// Disable the mock heartbeat for topics matching these regex + /// patterns (blacklist mode) + #[arg(long = "disable-topic", conflicts_with = "enable_topic")] + pub disable_topic: Vec, + + /// 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, + + /// 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, + + /// 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, + + /// Accept JSON-RPC 2.0 commands on stdin (one per line); replies on + /// 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 mock heartbeat should run. + 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 new file mode 100644 index 0000000..6434d98 --- /dev/null +++ b/calypso-sim/src/keymap.rs @@ -0,0 +1,242 @@ +//! 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 rumqttc::v5::AsyncClient; + +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 +/// [`load_scenario`], which does both) before running it. +pub fn parse_scenario(content: &str) -> Result { + serde_json::from_str(content).map_err(|e| format!("Invalid scenario JSON: {e}")) +} + +/// Read a scenario file from `path`, parse it, and validate it. +pub fn load_scenario(path: &str) -> Result { + 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, +/// * 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()); + } + 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}'")); + } + Step::Publish { + topic, + value, + values, + .. + } => 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(), &mut verified)?; + } + // 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(()) +} + +/// 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. +/// +/// `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, + verified: &mut BTreeSet, +) -> 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(" -> "))); + } + 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, verified)?; + } + } + } + path.pop(); + verified.insert(name.to_string()); + Ok(()) +} + +/// A flattened, executable step: invokes have been resolved away, leaving only +/// publishes and waits. `pub(crate)` only so the flatten test can inspect it. +#[derive(Debug)] +pub(crate) enum Prim { + Publish { + topic: String, + values: Vec, + unit: String, + }, + Sleep(u64), +} + +/// 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. +pub(crate) fn flatten(scenario: &Scenario, name: &str, out: &mut Vec) { + 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, + values, + unit, + } => out.push(Prim::Publish { + topic: topic.clone(), + 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)), + } + } +} + +/// The `key -> action name` bindings for interactive mode, erroring if two +/// actions claim the same key. +pub fn key_bindings(scenario: &Scenario) -> Result, 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()) + { + return Err(format!( + "key '{key}' is bound to both '{prev}' and '{name}'" + )); + } + } + Ok(map) +} + +/// 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 { + scenario + .values() + .flat_map(|action| &action.steps) + .filter_map(|step| match step { + Step::Publish { topic, .. } => Some(topic.clone()), + Step::Invoke(_) | Step::Sleep { .. } => None, + }) + .collect() +} + +/// 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(); + } + + 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, + } => log_and_publish(name, &topic, &unit, &values, client).await, + } + } +} + +async fn log_and_publish( + action: &str, + topic: &str, + unit: &str, + values: &[f32], + client: &AsyncClient, +) { + let nl = line_end(); + let unit_s = if unit.is_empty() { + String::new() + } else { + format!(" {unit}") + }; + match publish_data(client, topic, unit, values).await { + Ok(_) => { + let vals: Vec = 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) { + 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..fdc489d --- /dev/null +++ b/calypso-sim/src/keymap/model.rs @@ -0,0 +1,67 @@ +//! 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`. A step publishes to a topic, sleeps, or invokes another +//! action by name: +//! +//! ```json +//! { +//! "home": { "key": "h", "steps": [{"topic": "VCU/CarState/home_mode", "value": 1}] }, +//! "combo": { "steps": ["home", {"sleep_ms": 10}, "home"] } +//! } +//! ``` +//! +//! 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; + +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; + +/// 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. + pub key: Option, + + /// Human-readable label shown in the interactive listing and in the log + /// line printed when the action fires. + pub desc: Option, + + /// The steps run, in order, each time the action fires. + pub steps: Vec, +} + +/// 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, + value: Option, + values: Option>, + unit: Option, + }, + + /// 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 new file mode 100644 index 0000000..9caed9c --- /dev/null +++ b/calypso-sim/src/main.rs @@ -0,0 +1,183 @@ +mod cli; +mod keymap; +mod modes; +mod ownership; +#[allow(clippy::all, clippy::pedantic)] +mod proto; +mod publish; +mod raw_mode; +mod simulatable_message; +mod simulate_data; +mod warnings; + +#[cfg(test)] +mod tests; + +use std::process::exit; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::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; + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + init_tracing(); + + if cli.list_topics { + list_topics_and_exit(); + } + + 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); + }); + + let token = CancellationToken::new(); + let poll_handle = tokio::spawn(modes::poll_eventloop(token.clone(), eventloop)); + + let mock_handle = spawn_mock(&cli, scenario.as_ref(), &client, &token); + + 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 + // 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) = mock_handle + && let Err(e) = h.await + { + tracing::error!("mock task panicked: {e}"); + } + if let Err(e) = poll_handle.await { + tracing::error!("MQTT eventloop task panicked: {e}"); + } + + if let Err(err) = foreground { + eprintln!("Error: {err}"); + exit(1); + } +} + +async fn run_foreground( + cli: &Cli, + token: &CancellationToken, + client: &AsyncClient, + scenario: Option, +) -> Result<(), String> { + if cli.stream { + modes::stream::run(token.clone(), client.clone()).await + } else if let Some(action) = &cli.play { + // 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() { + 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() + .await + .map_err(|e| format!("ctrl+c handler failed: {e}")) + } +} + +/// 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> { + 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. + let subscriber = tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .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_or(0, |d| d.as_millis()) + ); + + 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)); + Ok(AsyncClient::new(mqtt_opts, 600)) +} diff --git a/calypso-sim/src/modes/interactive.rs b/calypso-sim/src/modes/interactive.rs new file mode 100644 index 0000000..f869b50 --- /dev/null +++ b/calypso-sim/src/modes/interactive.rs @@ -0,0 +1,91 @@ +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::{Scenario, key_bindings, print_listing, run_action}; +use crate::raw_mode::{RawModeGuard, line_end}; + +/// Run the interactive raw-mode keypress loop. Each key-bound action fires on +/// 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: Scenario, +) -> Result<(), String> { + 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(), + ); + } + print_listing(&scenario, &keys); + 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 classify(event) { + Input::Quit => break, + Input::Error(e) => { + print!("Terminal event error: {e}{}", line_end()); + let _ = std::io::stdout().flush(); + break; + } + Input::Key(ch) => { + if let Some(name) = keys.get(&ch) { + run_action(&scenario, name, &client).await; + } + } + Input::Ignore => {} + } + } + } + + drop(guard); + println!(); + 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>) -> 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 new file mode 100644 index 0000000..3c5f131 --- /dev/null +++ b/calypso-sim/src/modes/mock.rs @@ -0,0 +1,49 @@ +use std::time::Duration; + +use rumqttc::v5::AsyncClient; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use crate::publish::publish_data; +use crate::simulatable_message::SimComponent; + +/// Background task: every 5ms, walk `components` and publish any that are due +/// (per `sim_freq`). +/// +/// `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) { + if components.is_empty() { + info!("Mock: no components to simulate."); + } else { + info!("Mock: simulating {} components", components.len()); + } + + let mut interval = tokio::time::interval(Duration::from_millis(5)); + + loop { + tokio::select! { + () = token.cancelled() => { + debug!("Mock: shutting down."); + break; + } + _ = 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); + } + } +} diff --git a/calypso-sim/src/modes/mod.rs b/calypso-sim/src/modes/mod.rs new file mode 100644 index 0000000..031f620 --- /dev/null +++ b/calypso-sim/src/modes/mod.rs @@ -0,0 +1,31 @@ +pub mod interactive; +pub mod mock; +pub mod replay; +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/calypso-sim/src/modes/replay.rs b/calypso-sim/src/modes/replay.rs new file mode 100644 index 0000000..9053f8e --- /dev/null +++ b/calypso-sim/src/modes/replay.rs @@ -0,0 +1,23 @@ +use std::time::Duration; + +use rumqttc::v5::AsyncClient; + +use crate::keymap::{Scenario, run_action}; + +/// Deterministic replay (`--play `): 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 the scenario")); + } + + println!("Playing action '{action}' ..."); + println!(); + + 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; + Ok(()) +} diff --git a/calypso-sim/src/modes/stream.rs b/calypso-sim/src/modes/stream.rs new file mode 100644 index 0000000..c316f6a --- /dev/null +++ b/calypso-sim/src/modes/stream.rs @@ -0,0 +1,174 @@ +use std::sync::LazyLock; + +use crate::simulate_data::create_simulated_components; +use rumqttc::v5::AsyncClient; +use serde::Deserialize; +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio_util::sync::CancellationToken; + +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. +/// +/// 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}` +/// * `list_topics` — `{}` → `{topics: [{name, unit}, ...]}` +/// * `ping` — `{}` → `{ok: true}` +pub async fn run(token: CancellationToken, client: AsyncClient) -> 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).await; + write_line(&resp).await; + } + 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, + #[serde(default)] + method: Option, + #[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) -> 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); + + // 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).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) -> 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 resolve_values(p.value, p.values.as_deref()) { + Ok(vs) => vs, + Err(e) => return error(id, ERR_INVALID_PARAMS, &e), + }; + + 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}")), + } +} + +/// 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> = LazyLock::new(|| { + create_simulated_components() + .into_iter() + .map(|c| (c.name, c.unit)) + .collect() +}); + +fn handle_list_topics(id: Value) -> Value { + let topics: Vec = TOPICS + .iter() + .map(|(name, unit)| json!({"name": name, "unit": unit})) + .collect(); + ok(id, json!({"topics": topics})) +} + +#[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}) +} + +#[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}}) +} + +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/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), + /// Publish *only* topics matching these patterns. + Whitelist(Vec), +} + +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 { + 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() +} + +/// 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, + /// Topics reserved for the foreground driver; the heartbeat is off on these. + pub driver_owned: BTreeSet, +} + +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) -> 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/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 diff --git a/calypso-sim/src/publish.rs b/calypso-sim/src/publish.rs new file mode 100644 index 0000000..02af8ea --- /dev/null +++ b/calypso-sim/src/publish.rs @@ -0,0 +1,58 @@ +use std::time::UNIX_EPOCH; + +use crate::proto::serverdata; +use protobuf::Message; +use rumqttc::v5::AsyncClient; +use rumqttc::v5::mqttbytes::QoS; + +/// 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, u64), String> { + 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(); + payload.values = values.to_vec(); + payload.time_us = timestamp; + + let bytes = payload + .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 { + let (bytes, timestamp) = encode_server_data(unit, values)?; + + client + .publish(topic, QoS::AtMostOnce, false, bytes) + .await + .map_err(|e| format!("publish: {e}"))?; + + 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, values: Option<&[f32]>) -> Result, 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.to_vec()), + (None, Some(_)) => Err("`values` must be non-empty".into()), + (None, None) => Err("set `value` or `values`".into()), + } +} diff --git a/calypso-sim/src/raw_mode.rs b/calypso-sim/src/raw_mode.rs new file mode 100644 index 0000000..2bc3661 --- /dev/null +++ b/calypso-sim/src/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 / mock / 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/simulatable_message.rs b/calypso-sim/src/simulatable_message.rs similarity index 64% rename from src/simulatable_message.rs rename to calypso-sim/src/simulatable_message.rs index 87bb00e..50d36d6 100644 --- a/src/simulatable_message.rs +++ b/calypso-sim/src/simulatable_message.rs @@ -1,14 +1,20 @@ -use super::data::DecodeData; +#![allow(dead_code)] +// 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 calypso_cangen::data::DecodeData; use rand::prelude::*; 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` */ -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SimComponent { pub id: String, pub points: Vec, @@ -23,7 +29,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 +43,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 { @@ -55,8 +61,6 @@ pub enum SimValue { }, } -/********************* SIMULATE_MESSAGE.C *********************/ - impl SimComponent { pub fn initialize(&mut self) { self.points.iter_mut().for_each(SimPoint::initialize); @@ -124,17 +128,14 @@ impl SimValue { 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 - } + let sampled = Self::sample_range_or_low(&mut rng, *min, *max, f32::EPSILON); + *current = Self::quantize(sampled, *inc_min, *round); } 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; + } } } } @@ -146,26 +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_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 + 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) { @@ -180,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(); @@ -230,6 +225,9 @@ impl SimValue { } } +/// Placeholder pattern (`{}`) for in-topic value injection, compiled once and reused. +static PLACEHOLDER_RE: LazyLock = 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}" @@ -243,26 +241,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 = 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() } diff --git a/src/simulate_data.rs b/calypso-sim/src/simulate_data.rs similarity index 100% rename from src/simulate_data.rs rename to calypso-sim/src/simulate_data.rs 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..e7358c4 --- /dev/null +++ b/calypso-sim/src/tests/cli.rs @@ -0,0 +1,24 @@ +//! 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. + +use crate::cli::Cli; +use clap::Parser; + +fn cli(args: &[&str]) -> Cli { + Cli::try_parse_from(args).expect("valid args") +} + +#[test] +fn run_mock_arbitrates_the_heartbeat_against_other_modes() { + // On by default when nothing else is selected. + assert!(cli(&["calypso-sim"]).run_mock()); + // 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()); + assert!(cli(&["calypso-sim", "--stream", "--mock"]).run_mock()); +} diff --git a/calypso-sim/src/tests/keymap.rs b/calypso-sim/src/tests/keymap.rs new file mode 100644 index 0000000..e98823b --- /dev/null +++ b/calypso-sim/src/tests/keymap.rs @@ -0,0 +1,115 @@ +//! 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::{Prim, Step, flatten, parse_scenario, validate}; + +#[test] +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!( + matches!(steps[2], Step::Sleep { .. }), + "object with sleep_ms -> sleep" + ); +} + +#[test] +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" + ); + + let self_cycle = parse_scenario(r#"{ "a": { "steps": ["a"] } }"#).unwrap(); + assert!( + 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_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 = + parse_scenario(r#"{ "a": {"steps":[{"topic":"T","value":1.0,"values":[1.0]}]} }"#).unwrap(); + assert!( + validate(&both).is_err(), + "value + values together must fail" + ); + + let neither = parse_scenario(r#"{ "a": {"steps":[{"topic":"T"}]} }"#).unwrap(); + assert!( + 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 flatten_inlines_invoked_actions() { + 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(); + + // 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!( + prims.iter().any(|p| matches!(p, Prim::Sleep(5))), + "invoked action's sleep must be inlined too, got {prims:?}" + ); +} diff --git a/calypso-sim/src/warnings.rs b/calypso-sim/src/warnings.rs new file mode 100644 index 0000000..191b786 --- /dev/null +++ b/calypso-sim/src/warnings.rs @@ -0,0 +1,46 @@ +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 mock simulator +/// 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. +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/calypso-sim/tests/stream.rs b/calypso-sim/tests/stream.rs new file mode 100644 index 0000000..aa20748 --- /dev/null +++ b/calypso-sim/tests/stream.rs @@ -0,0 +1,174 @@ +//! 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`. +//! 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, + next_id: i64, +} + +impl Sim { + /// Spawn the sim in stream mode with the mock heartbeat off. + fn spawn() -> 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"); + 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 { + 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}")) + } +} + +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 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_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); + // 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] +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, "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(), + 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}" + ); +} diff --git a/libs/calypso-cangen/src/data.rs b/libs/calypso-cangen/src/data.rs new file mode 100644 index 0000000..baea9f7 --- /dev/null +++ b/libs/calypso-cangen/src/data.rs @@ -0,0 +1,135 @@ +//! 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; + +/** + * 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/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/bin/simulate.rs b/src/bin/simulate.rs deleted file mode 100644 index 1585633..0000000 --- a/src/bin/simulate.rs +++ /dev/null @@ -1,300 +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)); - 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/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, - 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 - } -} +pub use calypso_cangen::data::{DecodeData, EncodeData, FormatData}; 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;