diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b6116a..e26f22f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,17 +2,21 @@ name: CI on: push: - branches: [main] + branches: [main, "agent/**"] pull_request: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: debian-trixie: - name: Debian 13 / stable Rust - runs-on: ubuntu-latest - container: debian:trixie-slim + name: Debian 13 / Rust 1.97.1 + runs-on: ubuntu-24.04 + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd steps: - name: Install system dependencies @@ -21,29 +25,49 @@ jobs: apt-get install --yes --no-install-recommends \ build-essential \ ca-certificates \ - curl \ git \ libudev-dev \ - pkg-config + pkg-config \ + rustup - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - name: Install Rust - shell: bash + - name: Install pinned Rust toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --default-toolchain stable - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + rustup toolchain install 1.97.1 \ + --profile minimal \ + --component rustfmt \ + --component clippy \ + --component llvm-tools-preview + rustup default 1.97.1 + rustc --version + cargo --version - - name: Install Rust components - run: rustup component add clippy rustfmt + - name: Validate lockfile and metadata + run: cargo metadata --locked --format-version 1 --no-deps >/dev/null - name: Check formatting run: cargo fmt --all -- --check - name: Run Clippy - run: cargo clippy --all-targets --all-features + run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - name: Run tests - run: cargo test --all-features + run: cargo test --workspace --all-features --locked + + - name: Check architecture boundaries + run: sh scripts/check-architecture.sh + + - name: Create tested source archive + if: always() + run: tar --exclude=.git --exclude=target -czf /tmp/vfd-lantern-source.tar.gz . + + - name: Upload tested source archive + if: always() + uses: actions/upload-artifact@v4 + with: + name: issues-1-9-tested-source + path: /tmp/vfd-lantern-source.tar.gz + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/export-source.yml b/.github/workflows/export-source.yml new file mode 100644 index 0000000..c71f618 --- /dev/null +++ b/.github/workflows/export-source.yml @@ -0,0 +1,25 @@ +name: Export validation source + +on: + push: + branches: [agent/issues-1-9-validation] + +permissions: + contents: read + +jobs: + export: + runs-on: ubuntu-24.04 + steps: + - name: Check out exact validation commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Create source archive + run: | + git ls-files -z | tar --null -T - -czf /tmp/vfd-lantern-source.tar.gz + - name: Upload source archive + uses: actions/upload-artifact@v4 + with: + name: issues-1-9-source + path: /tmp/vfd-lantern-source.tar.gz + if-no-files-found: error + retention-days: 1 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7491fe9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,758 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[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", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lantern-app" +version = "0.1.0" +dependencies = [ + "lantern-domain", + "lantern-profile", +] + +[[package]] +name = "lantern-domain" +version = "0.1.0" +dependencies = [ + "proptest", + "rust_decimal", + "thiserror", +] + +[[package]] +name = "lantern-profile" +version = "0.1.0" +dependencies = [ + "lantern-domain", + "rust_decimal", + "schemars", + "serde", + "serde_jcs", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "toml", +] + +[[package]] +name = "lantern-sim" +version = "0.1.0" +dependencies = [ + "lantern-app", + "lantern-domain", + "lantern-profile", + "lantern-transport", +] + +[[package]] +name = "lantern-storage" +version = "0.1.0" +dependencies = [ + "lantern-app", + "lantern-domain", +] + +[[package]] +name = "lantern-transport" +version = "0.1.0" +dependencies = [ + "lantern-app", + "lantern-domain", +] + +[[package]] +name = "lantern-tui" +version = "0.1.0" +dependencies = [ + "lantern-app", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +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 = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "num-traits", +] + +[[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", + "windows-sys", +] + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_jcs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3a60f3fda61525e439ef6d67422118f11e986566997d9021c56867ad814a0aa" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +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 = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +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.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vfd-lantern" +version = "0.1.0" +dependencies = [ + "lantern-app", + "lantern-domain", + "lantern-profile", + "lantern-storage", + "lantern-transport", + "lantern-tui", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index f671bc6..e60be94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,25 +1,83 @@ -[package] -name = "vfd-lantern" +[workspace] +members = [ + "crates/lantern-domain", + "crates/lantern-profile", + "crates/lantern-app", + "crates/lantern-storage", + "crates/lantern-transport", + "crates/lantern-tui", + "crates/lantern-sim", + "crates/vfd-lantern", +] +default-members = ["crates/vfd-lantern"] +resolver = "3" + +[workspace.package] version = "0.1.0" -edition = "2021" -rust-version = "1.85" -description = "Universal VFD diagnostics, monitoring and configuration TUI for Linux" +edition = "2024" +rust-version = "1.97" license = "MIT" repository = "https://github.com/KeyffMS/vfd-lantern" homepage = "https://vfd-lantern.aiteracja.pl" -readme = "README.md" -keywords = ["vfd", "modbus", "rs485", "diagnostics", "tui"] -categories = ["command-line-utilities", "hardware-support"] -publish = false - -[dependencies] -anyhow = "1.0.104" -clap = { version = "4.6.4", features = ["derive"] } -crossterm = "0.29.0" -ratatui = "0.30.2" -serde = { version = "1.0.229", features = ["derive"] } -serde_json = "1.0.151" -tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] } -tokio-modbus = { version = "0.17.0", default-features = false, features = ["rtu"] } -tokio-serial = "5.5.0" -toml = "1.1.3" +authors = ["VFD Lantern contributors"] + +[workspace.dependencies] +# Internal crates +lantern-domain = { path = "crates/lantern-domain", version = "=0.1.0" } +lantern-profile = { path = "crates/lantern-profile", version = "=0.1.0" } +lantern-app = { path = "crates/lantern-app", version = "=0.1.0" } +lantern-storage = { path = "crates/lantern-storage", version = "=0.1.0" } +lantern-transport = { path = "crates/lantern-transport", version = "=0.1.0" } +lantern-tui = { path = "crates/lantern-tui", version = "=0.1.0" } + +# Error handling and CLI +anyhow = "=1.0.104" +thiserror = "=2.0.19" +clap = { version = "=4.6.4", features = ["derive"] } +clap_complete = "=4.6.0" +clap_mangen = "=0.2.29" + +# Terminal UI +ratatui = { version = "=0.30.2", default-features = false, features = ["all-widgets", "crossterm_0_29", "layout-cache", "macros", "underline-color"] } +crossterm = { version = "=0.29.0", features = ["event-stream"] } + +# Async runtime and transport +tokio = { version = "=1.53.1", features = ["macros", "rt-multi-thread", "signal", "sync", "time", "fs", "io-util"] } +tokio-util = { version = "=0.7.18", features = ["rt"] } +futures-util = "=0.3.32" +tokio-modbus = { version = "=0.17.0", default-features = false, features = ["rtu"] } +tokio-serial = "=5.5.0" +udev = "=0.9.3" +nix = { version = "=0.31.3", features = ["fs", "ioctl", "term"] } +libc = "=0.2.177" + +# Formats and exact values +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +toml = "=1.1.3" +serde_jcs = "=0.2.0" +schemars = "=1.2.2" +serde_path_to_error = "=0.1.20" +rust_decimal = { version = "=1.42.1", default-features = false } +csv = "=1.4.0" +time = { version = "=0.3.54", features = ["formatting", "parsing", "serde"] } +directories = "=6.0.0" +sha2 = "=0.11.0" + +# Observability and storage +tracing = "=0.1.44" +tracing-subscriber = { version = "=0.3.23", features = ["env-filter", "fmt", "json"] } +tracing-appender = "=0.2.5" +tempfile = "=3.27.0" + +# Tests and benchmarks +proptest = "=1.9.0" +insta = { version = "=1.43.2", features = ["json", "redactions"] } +criterion = "=0.7.0" +rand_chacha = "=0.9.0" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = "warn" diff --git a/README.md b/README.md index 1b430f3..f0ae337 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,37 @@ # VFD Lantern -**Universal open-source VFD diagnostics, monitoring and configuration TUI for Linux.** +Universal open-source VFD diagnostics, monitoring and configuration TUI for Linux. -VFD Lantern is a terminal application for communicating with variable-frequency drives over **Modbus RTU and RS-485**. It is written in **Rust** and designed around external device profiles, so support for additional VFD manufacturers and models does not require hard-coding their register maps into the user interface. +VFD Lantern communicates with variable-frequency drives over Modbus RTU and RS-485. +The product is implemented as a Rust modular monolith with one process, one production +binary and explicit application ports. -> Project status: early development / pre-alpha. +> Project status: architecture bootstrap / pre-alpha. -## Planned capabilities +## Workspace -- live monitoring and multi-channel terminal charts; -- device-independent aliases mapped through JSON or TOML profiles; -- parameter browsing, validation and editing; -- configuration backup, diff and controlled restore; -- fault-code decoding and freeze-frame diagnostics; -- serial-port discovery, bus statistics and automatic reconnection; -- CSV data logging. +- `lantern-domain` — pure types and invariants; +- `lantern-profile` — profile parsing and validation boundary; +- `lantern-app` — use cases, state, ports and policy; +- `lantern-storage` — filesystem adapters; +- `lantern-transport` — serial/Modbus adapters; +- `lantern-tui` — presentation-only state and rendering; +- `vfd-lantern` — the only production composition root; +- `lantern-sim` — development-only simulator. -## Platform - -The initial development and reference environment is **Debian 13 (Trixie)**. The architecture is intended to remain portable across modern Linux distributions. +The architecture is documented in [ADR 0001](docs/adr/0001-modular-monolith.md). +Run `scripts/check-architecture.sh` to verify the dependency boundaries. -## Project website +## Platform -- https://vfd-lantern.aiteracja.pl +The reference environment is Debian 13 (Trixie) on amd64 and arm64. ## Safety -VFD Lantern communicates with industrial motor drives. Incorrect parameters or control commands may cause unexpected machine movement, equipment damage or personal injury. The project will default to read-only and explicitly guarded write operations wherever practical. Users remain responsible for machine isolation, commissioning procedures and compliance with the drive manufacturer's documentation. - -## Independence notice - -VFD Lantern is an independent open-source project and is not affiliated with or endorsed by any VFD manufacturer. Product and company names may be used only to identify compatible devices. +VFD Lantern communicates with industrial motor drives. Incorrect parameters or control +commands may cause unexpected machine movement, equipment damage or personal injury. +The product defaults to read-only. It is not safety-rated and does not replace E-stop, +LOTO, hardware interlocks, manufacturer instructions or qualified personnel. ## License diff --git a/crates/lantern-app/Cargo.toml b/crates/lantern-app/Cargo.toml new file mode 100644 index 0000000..e4b1046 --- /dev/null +++ b/crates/lantern-app/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "lantern-app" +description = "Application use cases and ports for VFD Lantern" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +lantern-domain.workspace = true +lantern-profile.workspace = true + +[lints] +workspace = true diff --git a/crates/lantern-app/src/lib.rs b/crates/lantern-app/src/lib.rs new file mode 100644 index 0000000..5469edd --- /dev/null +++ b/crates/lantern-app/src/lib.rs @@ -0,0 +1,117 @@ +//! Application state, use cases, and outbound ports. + +#![forbid(unsafe_code)] + +use lantern_domain::{ProfileId, SessionId}; +use lantern_profile::ValidatedDeviceProfile; + +/// Read-only application state rendered by the terminal frontend. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ApplicationState { + active_profile: Option, + active_session: Option, +} + +impl ApplicationState { + /// Returns a presentation-safe snapshot. + #[must_use] + pub fn view(&self) -> ApplicationView { + ApplicationView { + active_profile: self.active_profile.clone(), + active_session: self.active_session, + } + } +} + +/// Immutable projection exposed to presentation adapters. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ApplicationView { + active_profile: Option, + active_session: Option, +} + +impl ApplicationView { + /// Returns the active profile identifier as presentation text. + #[must_use] + pub fn active_profile_id(&self) -> Option<&str> { + self.active_profile.as_ref().map(ProfileId::as_str) + } + + /// Returns the active logical session. + #[must_use] + pub const fn active_session(&self) -> Option { + self.active_session + } +} + +/// Single application-owned registry snapshot. +#[derive(Clone, Debug, Default)] +pub struct ProfileRegistry { + profiles: Vec, +} + +impl ProfileRegistry { + /// Builds an immutable registry snapshot. + #[must_use] + pub fn new(profiles: Vec) -> Self { + Self { profiles } + } + + /// Returns all profiles in deterministic registry order. + #[must_use] + pub fn profiles(&self) -> &[ValidatedDeviceProfile] { + &self.profiles + } +} + +/// Application-owned polling policy placeholder. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PollPlanner; + +/// Capability for read-only bus operations. +pub trait ReadBusPort: Send + Sync { + /// Human-readable adapter name for diagnostics. + fn adapter_name(&self) -> &'static str; +} + +/// Capability for guarded write operations. +pub trait WriteBusPort: Send + Sync { + /// Human-readable adapter name for diagnostics. + fn adapter_name(&self) -> &'static str; +} + +/// Capability for passive serial-port discovery. +pub trait PortDiscoveryPort: Send + Sync { + /// Returns the number of currently known descriptors without opening them. + fn known_port_count(&self) -> usize; +} + +/// Source of bounded profile documents. +pub trait ProfileSourcePort: Send + Sync { + /// Returns a stable source description. + fn source_name(&self) -> &'static str; +} + +/// Capability for user-facing persistent artifacts. +pub trait ArtifactStoragePort: Send + Sync { + /// Returns a stable storage description. + fn storage_name(&self) -> &'static str; +} + +/// Durable audit capability used by guarded operations. +pub trait AuditPort: Send + Sync { + /// Reports whether durable audit is available. + fn is_available(&self) -> bool; +} + +/// Profile-origin and local-approval capability. +pub trait ProfileTrustPort: Send + Sync { + /// Reports whether a profile hash is trusted for guarded operations. + fn is_trusted(&self, profile_id: &ProfileId) -> bool; +} + +/// Time source used by deterministic application logic. +pub trait ClockPort: Send + Sync { + /// Returns monotonic nanoseconds from an implementation-defined epoch. + fn monotonic_ns(&self) -> u128; +} diff --git a/crates/lantern-domain/Cargo.toml b/crates/lantern-domain/Cargo.toml new file mode 100644 index 0000000..c3d2404 --- /dev/null +++ b/crates/lantern-domain/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "lantern-domain" +description = "Pure domain model for VFD Lantern" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +rust_decimal.workspace = true +thiserror.workspace = true + +[dev-dependencies] +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/lantern-domain/src/access.rs b/crates/lantern-domain/src/access.rs new file mode 100644 index 0000000..2afb033 --- /dev/null +++ b/crates/lantern-domain/src/access.rs @@ -0,0 +1,46 @@ +/// Parameter access class. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ParameterAccess { + ReadOnly, + WritableWhenStopped, + Commissioning, + Dangerous, +} + +/// Restore policy assigned explicitly by a validated profile. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum RestorePolicy { + Normal, + LinkCritical, + RestartRequired, + ManualOnly, +} + +/// Current drive state derived from profile-defined telemetry. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum DriveState { + Stopped, + Running, + Faulted, + Unknown, +} + +/// Drive-state guard required by an operation. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum RequiredDriveState { + Any, + Stopped, + Faulted, +} + +impl RequiredDriveState { + /// Checks whether a fresh state satisfies the guard. + #[must_use] + pub const fn is_satisfied_by(self, state: DriveState) -> bool { + match self { + Self::Any => true, + Self::Stopped => matches!(state, DriveState::Stopped), + Self::Faulted => matches!(state, DriveState::Faulted), + } + } +} diff --git a/crates/lantern-domain/src/codec.rs b/crates/lantern-domain/src/codec.rs new file mode 100644 index 0000000..01e2df6 --- /dev/null +++ b/crates/lantern-domain/src/codec.rs @@ -0,0 +1,392 @@ +use rust_decimal::Decimal; +use thiserror::Error; + +use crate::{ByteOrder, EngineeringValue, FixedScale, ScaleError, WordOrder}; + +/// Closed set of supported register encodings. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum RegisterEncoding { + Unsigned16, + Signed16, + Unsigned32, + Signed32, + Unsigned64, + Signed64, + Float32, + Float64, + Bcd16, + Bcd32, + Enum16, + Enum32, + Bitfield16, + Bitfield32, + Bitfield64, +} + +impl RegisterEncoding { + /// Number of Modbus registers required by this encoding. + #[must_use] + pub const fn register_width(self) -> usize { + match self { + Self::Unsigned16 | Self::Signed16 | Self::Bcd16 | Self::Enum16 | Self::Bitfield16 => 1, + Self::Unsigned32 + | Self::Signed32 + | Self::Float32 + | Self::Bcd32 + | Self::Enum32 + | Self::Bitfield32 => 2, + Self::Unsigned64 | Self::Signed64 | Self::Float64 | Self::Bitfield64 => 4, + } + } +} + +/// Stateless register codec configured by a validated profile. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RegisterCodec { + encoding: RegisterEncoding, + byte_order: ByteOrder, + word_order: WordOrder, + fixed_scale: Option, +} + +impl RegisterCodec { + /// Creates a codec and rejects a fixed scale for non-fixed encodings. + pub fn new( + encoding: RegisterEncoding, + byte_order: ByteOrder, + word_order: WordOrder, + fixed_scale: Option, + ) -> Result { + let supports_scale = matches!( + encoding, + RegisterEncoding::Unsigned16 + | RegisterEncoding::Signed16 + | RegisterEncoding::Unsigned32 + | RegisterEncoding::Signed32 + | RegisterEncoding::Unsigned64 + | RegisterEncoding::Signed64 + | RegisterEncoding::Bcd16 + | RegisterEncoding::Bcd32 + ); + if fixed_scale.is_some() && !supports_scale { + return Err(CodecError::UnexpectedScale(encoding)); + } + Ok(Self { + encoding, + byte_order, + word_order, + fixed_scale, + }) + } + + /// Decodes registers into one authoritative engineering value. + pub fn decode(&self, registers: &[u16]) -> Result { + self.validate_width(registers.len())?; + let bits = self.words_to_bits(registers); + + match self.encoding { + RegisterEncoding::Unsigned16 => self.decode_fixed(i128::from(bits as u16)), + RegisterEncoding::Signed16 => self.decode_fixed(i128::from(bits as u16 as i16)), + RegisterEncoding::Unsigned32 => self.decode_fixed(i128::from(bits as u32)), + RegisterEncoding::Signed32 => self.decode_fixed(i128::from(bits as u32 as i32)), + RegisterEncoding::Unsigned64 => self.decode_fixed(i128::from(bits)), + RegisterEncoding::Signed64 => self.decode_fixed(i128::from(bits as i64)), + RegisterEncoding::Float32 => Ok(EngineeringValue::Float32Bits(bits as u32)), + RegisterEncoding::Float64 => Ok(EngineeringValue::Float64Bits(bits)), + RegisterEncoding::Bcd16 => self.decode_fixed(i128::from(decode_bcd(bits, 4)?)), + RegisterEncoding::Bcd32 => self.decode_fixed(i128::from(decode_bcd(bits, 8)?)), + RegisterEncoding::Enum16 => Ok(EngineeringValue::EnumRaw(i64::from(bits as u16))), + RegisterEncoding::Enum32 => Ok(EngineeringValue::EnumRaw(i64::from(bits as u32))), + RegisterEncoding::Bitfield16 => { + Ok(EngineeringValue::BitfieldRaw(u64::from(bits as u16))) + } + RegisterEncoding::Bitfield32 => { + Ok(EngineeringValue::BitfieldRaw(u64::from(bits as u32))) + } + RegisterEncoding::Bitfield64 => Ok(EngineeringValue::BitfieldRaw(bits)), + } + } + + /// Encodes an engineering value into exact Modbus register words. + pub fn encode(&self, value: &EngineeringValue) -> Result, CodecError> { + let bits = match (self.encoding, value) { + (RegisterEncoding::Unsigned16, EngineeringValue::Fixed(value)) => { + checked_unsigned(self.encode_fixed(*value)?, u16::MAX.into())? + } + (RegisterEncoding::Signed16, EngineeringValue::Fixed(value)) => u64::from( + checked_signed(self.encode_fixed(*value)?, i16::MIN.into(), i16::MAX.into())? as i16 + as u16, + ), + (RegisterEncoding::Unsigned32, EngineeringValue::Fixed(value)) => { + checked_unsigned(self.encode_fixed(*value)?, u32::MAX.into())? + } + (RegisterEncoding::Signed32, EngineeringValue::Fixed(value)) => u64::from( + checked_signed(self.encode_fixed(*value)?, i32::MIN.into(), i32::MAX.into())? as i32 + as u32, + ), + (RegisterEncoding::Unsigned64, EngineeringValue::Fixed(value)) => { + checked_unsigned(self.encode_fixed(*value)?, u64::MAX.into())? + } + (RegisterEncoding::Signed64, EngineeringValue::Fixed(value)) => { + checked_signed(self.encode_fixed(*value)?, i64::MIN.into(), i64::MAX.into())? as u64 + } + (RegisterEncoding::Float32, EngineeringValue::Float32Bits(bits)) => u64::from(*bits), + (RegisterEncoding::Float64, EngineeringValue::Float64Bits(bits)) => *bits, + (RegisterEncoding::Bcd16, EngineeringValue::Fixed(value)) => { + encode_bcd(self.encode_fixed(*value)?, 4)? + } + (RegisterEncoding::Bcd32, EngineeringValue::Fixed(value)) => { + encode_bcd(self.encode_fixed(*value)?, 8)? + } + (RegisterEncoding::Enum16, EngineeringValue::EnumRaw(raw)) => { + u64::from(u16::try_from(*raw).map_err(|_| CodecError::OutOfRange)?) + } + (RegisterEncoding::Enum32, EngineeringValue::EnumRaw(raw)) => { + u64::from(u32::try_from(*raw).map_err(|_| CodecError::OutOfRange)?) + } + (RegisterEncoding::Bitfield16, EngineeringValue::BitfieldRaw(raw)) => { + u64::from(u16::try_from(*raw).map_err(|_| CodecError::OutOfRange)?) + } + (RegisterEncoding::Bitfield32, EngineeringValue::BitfieldRaw(raw)) => { + u64::from(u32::try_from(*raw).map_err(|_| CodecError::OutOfRange)?) + } + (RegisterEncoding::Bitfield64, EngineeringValue::BitfieldRaw(raw)) => *raw, + _ => { + return Err(CodecError::ValueKindMismatch { + encoding: self.encoding, + }); + } + }; + + Ok(self.bits_to_words(bits)) + } + + fn validate_width(&self, actual: usize) -> Result<(), CodecError> { + let expected = self.encoding.register_width(); + if actual != expected { + return Err(CodecError::RegisterWidth { expected, actual }); + } + Ok(()) + } + + fn decode_fixed(&self, raw: i128) -> Result { + let value = match &self.fixed_scale { + Some(scale) => scale.decode_i128(raw)?, + None => Decimal::from_i128_with_scale(raw, 0), + }; + Ok(EngineeringValue::Fixed(value)) + } + + fn encode_fixed(&self, value: Decimal) -> Result { + match &self.fixed_scale { + Some(scale) => Ok(scale.encode_i128(value)?), + None => FixedScale::identity() + .encode_i128(value) + .map_err(CodecError::from), + } + } + + fn words_to_bits(&self, registers: &[u16]) -> u64 { + let mut words = registers.to_vec(); + if self.word_order == WordOrder::LeastSignificantFirst { + words.reverse(); + } + + let mut bits = 0_u64; + for word in words { + let bytes = match self.byte_order { + ByteOrder::BigEndian => word.to_be_bytes(), + ByteOrder::LittleEndian => word.to_le_bytes(), + }; + bits = (bits << 8) | u64::from(bytes[0]); + bits = (bits << 8) | u64::from(bytes[1]); + } + bits + } + + fn bits_to_words(&self, bits: u64) -> Vec { + let width = self.encoding.register_width(); + let mut words = Vec::with_capacity(width); + for index in 0..width { + let shift = (width - index - 1) * 16; + let canonical = ((bits >> shift) & 0xffff) as u16; + let word = match self.byte_order { + ByteOrder::BigEndian => canonical, + ByteOrder::LittleEndian => canonical.swap_bytes(), + }; + words.push(word); + } + if self.word_order == WordOrder::LeastSignificantFirst { + words.reverse(); + } + words + } +} + +fn checked_unsigned(raw: i128, max: i128) -> Result { + if raw < 0 || raw > max { + return Err(CodecError::OutOfRange); + } + u64::try_from(raw).map_err(|_| CodecError::OutOfRange) +} + +fn checked_signed(raw: i128, min: i128, max: i128) -> Result { + if raw < min || raw > max { + return Err(CodecError::OutOfRange); + } + i64::try_from(raw).map_err(|_| CodecError::OutOfRange) +} + +fn decode_bcd(bits: u64, digits: usize) -> Result { + let mut value = 0_u64; + for index in (0..digits).rev() { + let digit = (bits >> (index * 4)) & 0xf; + if digit > 9 { + return Err(CodecError::InvalidBcdDigit { + index, + digit: digit as u8, + }); + } + value = value * 10 + digit; + } + Ok(value) +} + +fn encode_bcd(raw: i128, digits: usize) -> Result { + if raw < 0 { + return Err(CodecError::OutOfRange); + } + let mut remaining = u64::try_from(raw).map_err(|_| CodecError::OutOfRange)?; + let mut bits = 0_u64; + for index in 0..digits { + bits |= (remaining % 10) << (index * 4); + remaining /= 10; + } + if remaining != 0 { + return Err(CodecError::OutOfRange); + } + Ok(bits) +} + +/// Register decoding/encoding error. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum CodecError { + #[error("encoding {0:?} cannot use a fixed-point scale")] + UnexpectedScale(RegisterEncoding), + #[error("expected {expected} registers, received {actual}")] + RegisterWidth { expected: usize, actual: usize }, + #[error("engineering value kind does not match encoding {encoding:?}")] + ValueKindMismatch { encoding: RegisterEncoding }, + #[error("raw value is outside the encoding range")] + OutOfRange, + #[error("invalid BCD digit {digit} at nibble {index}")] + InvalidBcdDigit { index: usize, digit: u8 }, + #[error(transparent)] + Scale(#[from] ScaleError), +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use rust_decimal::Decimal; + + use crate::{ByteOrder, EngineeringValue, WordOrder}; + + use super::{CodecError, RegisterCodec, RegisterEncoding}; + + fn codec(encoding: RegisterEncoding, byte: ByteOrder, word: WordOrder) -> RegisterCodec { + RegisterCodec::new(encoding, byte, word, None).expect("codec") + } + + #[test] + fn golden_u32_word_and_byte_orders() { + let value = EngineeringValue::Fixed(Decimal::from(0x1234_5678_u32)); + assert_eq!( + codec( + RegisterEncoding::Unsigned32, + ByteOrder::BigEndian, + WordOrder::MostSignificantFirst + ) + .encode(&value) + .expect("encode"), + vec![0x1234, 0x5678] + ); + assert_eq!( + codec( + RegisterEncoding::Unsigned32, + ByteOrder::LittleEndian, + WordOrder::LeastSignificantFirst + ) + .encode(&value) + .expect("encode"), + vec![0x7856, 0x3412] + ); + } + + #[test] + fn rejects_invalid_bcd() { + let error = codec( + RegisterEncoding::Bcd16, + ByteOrder::BigEndian, + WordOrder::MostSignificantFirst, + ) + .decode(&[0x12fa]) + .expect_err("invalid BCD"); + assert!(matches!(error, CodecError::InvalidBcdDigit { .. })); + } + + #[test] + fn float_nan_round_trips_by_bits() { + let bits = 0x7fc0_0123; + let codec = codec( + RegisterEncoding::Float32, + ByteOrder::BigEndian, + WordOrder::MostSignificantFirst, + ); + let words = codec + .encode(&EngineeringValue::Float32Bits(bits)) + .expect("encode"); + assert_eq!( + codec.decode(&words), + Ok(EngineeringValue::Float32Bits(bits)) + ); + } + + proptest! { + #[test] + fn u32_round_trip(value in any::()) { + let codec = codec( + RegisterEncoding::Unsigned32, + ByteOrder::LittleEndian, + WordOrder::LeastSignificantFirst, + ); + let engineering = EngineeringValue::Fixed(Decimal::from(value)); + let words = codec.encode(&engineering).expect("encode"); + prop_assert_eq!(codec.decode(&words).expect("decode"), engineering); + } + + #[test] + fn arbitrary_registers_never_panic(registers in prop::collection::vec(any::(), 0..8)) { + for encoding in [ + RegisterEncoding::Unsigned16, + RegisterEncoding::Signed16, + RegisterEncoding::Unsigned32, + RegisterEncoding::Signed32, + RegisterEncoding::Unsigned64, + RegisterEncoding::Signed64, + RegisterEncoding::Float32, + RegisterEncoding::Float64, + RegisterEncoding::Bcd16, + RegisterEncoding::Bcd32, + RegisterEncoding::Enum16, + RegisterEncoding::Enum32, + RegisterEncoding::Bitfield16, + RegisterEncoding::Bitfield32, + RegisterEncoding::Bitfield64, + ] { + let codec = codec(encoding, ByteOrder::BigEndian, WordOrder::MostSignificantFirst); + let _ = codec.decode(®isters); + } + } + } +} diff --git a/crates/lantern-domain/src/ids.rs b/crates/lantern-domain/src/ids.rs new file mode 100644 index 0000000..36ec621 --- /dev/null +++ b/crates/lantern-domain/src/ids.rs @@ -0,0 +1,150 @@ +use std::fmt; + +use thiserror::Error; + +const MAX_ID_LEN: usize = 128; + +/// Error returned while constructing a stable textual identifier. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum IdError { + /// The identifier is empty. + #[error("identifier must not be empty")] + Empty, + /// The identifier exceeds the domain limit. + #[error("identifier exceeds {MAX_ID_LEN} bytes")] + TooLong, + /// The identifier contains a character outside the portable ASCII set. + #[error("identifier contains invalid character {character:?} at byte {index}")] + InvalidCharacter { index: usize, character: char }, +} + +fn validate_text_id(value: &str) -> Result<(), IdError> { + if value.is_empty() { + return Err(IdError::Empty); + } + if value.len() > MAX_ID_LEN { + return Err(IdError::TooLong); + } + + for (index, character) in value.char_indices() { + if !(character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | ':')) { + return Err(IdError::InvalidCharacter { index, character }); + } + } + + Ok(()) +} + +macro_rules! text_id { + ($name:ident, $documentation:literal) => { + #[doc = $documentation] + #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] + pub struct $name(String); + + impl $name { + /// Parses and validates an identifier. + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_text_id(&value)?; + Ok(Self(value)) + } + + /// Returns the identifier as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } + } + + impl TryFrom<&str> for $name { + type Error = IdError; + + fn try_from(value: &str) -> Result { + Self::parse(value) + } + } + }; +} + +text_id!( + ProfileId, + "Stable identifier of a validated device profile." +); +text_id!( + ParameterId, + "Stable identifier of a parameter within a profile." +); +text_id!( + DeviceFingerprint, + "Stable fingerprint of an identified device." +); +text_id!( + QuantityId, + "Stable identifier of a custom physical quantity." +); + +macro_rules! numeric_id { + ($name:ident, $inner:ty, $documentation:literal) => { + #[doc = $documentation] + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] + pub struct $name($inner); + + impl $name { + /// Creates an identifier from its opaque numeric value. + #[must_use] + pub const fn new(value: $inner) -> Self { + Self(value) + } + + /// Returns the opaque numeric value. + #[must_use] + pub const fn get(self) -> $inner { + self.0 + } + } + }; +} + +numeric_id!(RequestId, u64, "Identifier of one bus request."); +numeric_id!( + SessionId, + u128, + "Identifier of one logical connection session." +); +numeric_id!(BackupId, u128, "Identifier of a configuration backup."); +numeric_id!(FaultEventId, u128, "Identifier of a fault timeline event."); +numeric_id!( + OperationId, + u128, + "Identifier of a guarded multi-step operation." +); +numeric_id!(PlanId, u128, "Identifier of a prepared immutable plan."); + +#[cfg(test)] +mod tests { + use super::{IdError, ParameterId, ProfileId}; + + #[test] + fn accepts_portable_ids() { + let id = ProfileId::parse("vendor.drive-family:v1").expect("portable ID"); + assert_eq!(id.as_str(), "vendor.drive-family:v1"); + } + + #[test] + fn rejects_control_and_path_characters() { + assert!(matches!( + ParameterId::parse("group/value"), + Err(IdError::InvalidCharacter { .. }) + )); + assert!(matches!( + ParameterId::parse("group\u{1b}value"), + Err(IdError::InvalidCharacter { .. }) + )); + } +} diff --git a/crates/lantern-domain/src/lib.rs b/crates/lantern-domain/src/lib.rs new file mode 100644 index 0000000..a7ba9cb --- /dev/null +++ b/crates/lantern-domain/src/lib.rs @@ -0,0 +1,29 @@ +//! Pure domain types, codecs, and invariants shared by VFD Lantern layers. + +#![forbid(unsafe_code)] + +mod access; +mod codec; +mod ids; +mod modbus; +mod quantity; +mod telemetry; +mod value; + +pub use access::{DriveState, ParameterAccess, RequiredDriveState, RestorePolicy}; +pub use codec::{CodecError, RegisterCodec, RegisterEncoding}; +pub use ids::{ + BackupId, DeviceFingerprint, FaultEventId, IdError, OperationId, ParameterId, PlanId, + ProfileId, QuantityId, RequestId, SessionId, +}; +pub use modbus::{ + BaudRate, ByteOrder, DataBits, LinkSettings, LinkSettingsError, ModbusFunction, ModbusTable, + Parity, RegisterAddress, RegisterBlock, RegisterCount, RegisterRangeError, Rs485Mode, SlaveId, + StopBits, WordOrder, +}; +pub use quantity::{QuantityKind, UnitError, UnitId}; +pub use telemetry::{ + MonotonicInstant, RawRegisters, RawRegistersError, TelemetryQuality, TelemetrySampleCore, + UtcTimestamp, +}; +pub use value::{EngineeringValue, FixedScale, RoundingMode, ScaleError}; diff --git a/crates/lantern-domain/src/modbus.rs b/crates/lantern-domain/src/modbus.rs new file mode 100644 index 0000000..55a521e --- /dev/null +++ b/crates/lantern-domain/src/modbus.rs @@ -0,0 +1,352 @@ +use std::time::Duration; + +use thiserror::Error; + +/// Zero-based Modbus PDU register address. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RegisterAddress(u16); + +impl RegisterAddress { + /// Creates a normalized PDU address. + #[must_use] + pub const fn new(value: u16) -> Self { + Self(value) + } + + /// Returns the zero-based PDU address. + #[must_use] + pub const fn get(self) -> u16 { + self.0 + } +} + +/// Non-zero number of consecutive registers. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RegisterCount(u16); + +impl RegisterCount { + /// Creates a register count. + pub const fn new(value: u16) -> Result { + if value == 0 { + return Err(RegisterRangeError::ZeroCount); + } + Ok(Self(value)) + } + + /// Returns the count. + #[must_use] + pub const fn get(self) -> u16 { + self.0 + } +} + +/// Valid Modbus RTU slave address (1..=247). +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct SlaveId(u8); + +impl SlaveId { + /// Validates a non-broadcast slave identifier. + pub const fn new(value: u8) -> Result { + if value == 0 || value > 247 { + return Err(RegisterRangeError::InvalidSlave(value)); + } + Ok(Self(value)) + } + + /// Returns the slave identifier. + #[must_use] + pub const fn get(self) -> u8 { + self.0 + } +} + +/// Modbus register table. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ModbusTable { + /// Read-only input registers, function 04. + InputRegisters, + /// Holding registers, functions 03/06/16. + HoldingRegisters, +} + +/// Supported Modbus function. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ModbusFunction { + /// Function 03. + ReadHoldingRegisters, + /// Function 04. + ReadInputRegisters, + /// Function 06. + WriteSingleRegister, + /// Function 16. + WriteMultipleRegisters, +} + +impl ModbusFunction { + /// Returns true for a function that may modify the device. + #[must_use] + pub const fn is_write(self) -> bool { + matches!( + self, + Self::WriteSingleRegister | Self::WriteMultipleRegisters + ) + } + + /// Validates protocol and table limits for a register count. + pub const fn validate_count(self, count: RegisterCount) -> Result<(), RegisterRangeError> { + let count = count.get(); + match self { + Self::ReadHoldingRegisters | Self::ReadInputRegisters if count <= 125 => Ok(()), + Self::WriteSingleRegister if count == 1 => Ok(()), + Self::WriteMultipleRegisters if count <= 123 => Ok(()), + _ => Err(RegisterRangeError::FunctionLimit { + function: self, + count, + }), + } + } + + /// Validates compatibility between the function and register table. + pub const fn validate_table(self, table: ModbusTable) -> Result<(), RegisterRangeError> { + match (self, table) { + (Self::ReadInputRegisters, ModbusTable::InputRegisters) + | (Self::ReadHoldingRegisters, ModbusTable::HoldingRegisters) + | (Self::WriteSingleRegister, ModbusTable::HoldingRegisters) + | (Self::WriteMultipleRegisters, ModbusTable::HoldingRegisters) => Ok(()), + _ => Err(RegisterRangeError::FunctionTableMismatch { + function: self, + table, + }), + } + } +} + +/// Validated contiguous register block. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct RegisterBlock { + table: ModbusTable, + start: RegisterAddress, + count: RegisterCount, +} + +impl RegisterBlock { + /// Validates a block against address-space and function limits. + pub fn new( + table: ModbusTable, + start: RegisterAddress, + count: RegisterCount, + function: ModbusFunction, + ) -> Result { + function.validate_table(table)?; + function.validate_count(count)?; + + let last_offset = count.get() - 1; + if start.get().checked_add(last_offset).is_none() { + return Err(RegisterRangeError::AddressOverflow { + start: start.get(), + count: count.get(), + }); + } + + Ok(Self { + table, + start, + count, + }) + } + + /// Returns the table. + #[must_use] + pub const fn table(self) -> ModbusTable { + self.table + } + + /// Returns the first PDU address. + #[must_use] + pub const fn start(self) -> RegisterAddress { + self.start + } + + /// Returns the number of registers. + #[must_use] + pub const fn count(self) -> RegisterCount { + self.count + } + + /// Returns the inclusive final PDU address. + #[must_use] + pub const fn end(self) -> RegisterAddress { + RegisterAddress::new(self.start.get() + self.count.get() - 1) + } +} + +/// Register range validation error. +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum RegisterRangeError { + /// A block cannot contain zero registers. + #[error("register count must be non-zero")] + ZeroCount, + /// Slave zero is broadcast and values above 247 are reserved. + #[error("invalid Modbus slave ID {0}; expected 1..=247")] + InvalidSlave(u8), + /// Function-specific register limit was exceeded. + #[error("function {function:?} does not permit {count} registers")] + FunctionLimit { + function: ModbusFunction, + count: u16, + }, + /// Function and table do not match. + #[error("function {function:?} is incompatible with table {table:?}")] + FunctionTableMismatch { + function: ModbusFunction, + table: ModbusTable, + }, + /// The final address would exceed the PDU space. + #[error("register block start {start} and count {count} overflow the PDU address space")] + AddressOverflow { start: u16, count: u16 }, +} + +/// Byte order inside one 16-bit register. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ByteOrder { + /// High byte followed by low byte. + BigEndian, + /// Low byte followed by high byte. + LittleEndian, +} + +/// Order of 16-bit words in a multi-register value. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum WordOrder { + /// Most significant word first. + MostSignificantFirst, + /// Least significant word first. + LeastSignificantFirst, +} + +/// Validated baud rate. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct BaudRate(u32); + +impl BaudRate { + /// Creates a non-zero baud rate. + pub const fn new(value: u32) -> Result { + if value == 0 { + return Err(LinkSettingsError::ZeroBaudRate); + } + Ok(Self(value)) + } + + /// Returns bits per second. + #[must_use] + pub const fn get(self) -> u32 { + self.0 + } +} + +/// Link-setting validation error. +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum LinkSettingsError { + #[error("baud rate must be non-zero")] + ZeroBaudRate, +} + +/// Serial parity. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Parity { + None, + Even, + Odd, +} + +/// Serial data bits supported by the product. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum DataBits { + Seven, + Eight, +} + +/// Serial stop bits. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum StopBits { + One, + Two, +} + +/// RS-485 direction-control mode. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Rs485Mode { + AdapterManaged, + LinuxIoctl, +} + +/// Complete immutable link settings used for one connection attempt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LinkSettings { + pub baud_rate: BaudRate, + pub parity: Parity, + pub data_bits: DataBits, + pub stop_bits: StopBits, + pub response_timeout: Duration, + pub slave_id: SlaveId, + pub rs485_mode: Rs485Mode, +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::{ + ModbusFunction, ModbusTable, RegisterAddress, RegisterBlock, RegisterCount, + RegisterRangeError, SlaveId, + }; + + #[test] + fn protocol_limits_are_distinct() { + let read = RegisterCount::new(125).expect("read count"); + assert!( + ModbusFunction::ReadHoldingRegisters + .validate_count(read) + .is_ok() + ); + assert!( + ModbusFunction::WriteMultipleRegisters + .validate_count(read) + .is_err() + ); + + let write = RegisterCount::new(123).expect("write count"); + assert!( + ModbusFunction::WriteMultipleRegisters + .validate_count(write) + .is_ok() + ); + } + + #[test] + fn rejects_broadcast_and_reserved_slaves() { + assert_eq!(SlaveId::new(0), Err(RegisterRangeError::InvalidSlave(0))); + assert_eq!( + SlaveId::new(248), + Err(RegisterRangeError::InvalidSlave(248)) + ); + assert!(SlaveId::new(247).is_ok()); + } + + proptest! { + #[test] + fn accepted_blocks_never_overflow(start in any::(), count in 1_u16..=125) { + let count = RegisterCount::new(count).expect("non-zero"); + let result = RegisterBlock::new( + ModbusTable::HoldingRegisters, + RegisterAddress::new(start), + count, + ModbusFunction::ReadHoldingRegisters, + ); + if let Ok(block) = result { + prop_assert!(block.end().get() >= start); + prop_assert_eq!(u32::from(block.end().get()) - u32::from(start) + 1, u32::from(count.get())); + } + } + } +} diff --git a/crates/lantern-domain/src/quantity.rs b/crates/lantern-domain/src/quantity.rs new file mode 100644 index 0000000..901d3f8 --- /dev/null +++ b/crates/lantern-domain/src/quantity.rs @@ -0,0 +1,149 @@ +use std::fmt; + +use thiserror::Error; + +use crate::{IdError, QuantityId}; + +/// Physical quantity used for safe grouping and presentation. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum QuantityKind { + Frequency, + RotationalSpeed, + Current, + Voltage, + Power, + Energy, + Torque, + Temperature, + Time, + Ratio, + Pressure, + Flow, + Count, + DigitalState, + Unitless, + Custom(QuantityId), +} + +/// Stable unit identifier bound to exactly one quantity. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct UnitId { + id: String, + quantity: QuantityKind, +} + +impl UnitId { + /// Creates a unit and enforces standard quantity mappings. + pub fn new(quantity: QuantityKind, id: impl Into) -> Result { + let id = id.into(); + validate_unit_id(&id)?; + + if let Some(expected) = standard_quantity(&id) { + if expected != quantity { + return Err(UnitError::QuantityMismatch { + id, + expected, + actual: quantity, + }); + } + } else if !matches!(quantity, QuantityKind::Custom(_)) && !id.starts_with("custom.") { + return Err(UnitError::UnknownStandardUnit(id)); + } + + Ok(Self { id, quantity }) + } + + /// Returns the stable unit identifier. + #[must_use] + pub fn as_str(&self) -> &str { + &self.id + } + + /// Returns the quantity this unit belongs to. + #[must_use] + pub fn quantity(&self) -> &QuantityKind { + &self.quantity + } +} + +impl fmt::Display for UnitId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.id) + } +} + +fn validate_unit_id(id: &str) -> Result<(), UnitError> { + if id.is_empty() { + return Err(UnitError::InvalidId(IdError::Empty)); + } + if id.len() > 64 { + return Err(UnitError::TooLong); + } + for (index, character) in id.char_indices() { + if !(character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-' | '%' | '/')) + { + return Err(UnitError::InvalidCharacter { index, character }); + } + } + Ok(()) +} + +fn standard_quantity(id: &str) -> Option { + match id { + "hz" | "khz" => Some(QuantityKind::Frequency), + "rpm" | "rps" => Some(QuantityKind::RotationalSpeed), + "a" | "ma" => Some(QuantityKind::Current), + "v" | "kv" => Some(QuantityKind::Voltage), + "w" | "kw" => Some(QuantityKind::Power), + "wh" | "kwh" => Some(QuantityKind::Energy), + "nm" => Some(QuantityKind::Torque), + "celsius" | "kelvin" => Some(QuantityKind::Temperature), + "s" | "ms" | "us" => Some(QuantityKind::Time), + "%" | "ratio" => Some(QuantityKind::Ratio), + "pa" | "kpa" | "bar" => Some(QuantityKind::Pressure), + "l/min" | "m3/h" => Some(QuantityKind::Flow), + "count" => Some(QuantityKind::Count), + "bool" => Some(QuantityKind::DigitalState), + "1" => Some(QuantityKind::Unitless), + _ => None, + } +} + +/// Unit validation error. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum UnitError { + #[error(transparent)] + InvalidId(#[from] IdError), + #[error("unit identifier exceeds 64 bytes")] + TooLong, + #[error("unit identifier contains invalid character {character:?} at byte {index}")] + InvalidCharacter { index: usize, character: char }, + #[error("unit {id} belongs to {expected:?}, not {actual:?}")] + QuantityMismatch { + id: String, + expected: QuantityKind, + actual: QuantityKind, + }, + #[error("unknown standard unit {0}; custom units require a custom quantity")] + UnknownStandardUnit(String), +} + +#[cfg(test)] +mod tests { + use crate::{QuantityId, QuantityKind, UnitError, UnitId}; + + #[test] + fn rejects_rpm_as_frequency() { + assert!(matches!( + UnitId::new(QuantityKind::Frequency, "rpm"), + Err(UnitError::QuantityMismatch { .. }) + )); + } + + #[test] + fn accepts_matching_custom_quantity_and_unit() { + let quantity = QuantityKind::Custom(QuantityId::parse("vendor.flux").expect("quantity")); + let unit = UnitId::new(quantity.clone(), "custom.flux-unit").expect("custom unit"); + assert_eq!(unit.quantity(), &quantity); + } +} diff --git a/crates/lantern-domain/src/telemetry.rs b/crates/lantern-domain/src/telemetry.rs new file mode 100644 index 0000000..250877a --- /dev/null +++ b/crates/lantern-domain/src/telemetry.rs @@ -0,0 +1,125 @@ +use thiserror::Error; + +use crate::{EngineeringValue, ParameterId, RequestId, SessionId}; + +/// Monotonic timestamp in nanoseconds from an implementation-defined epoch. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct MonotonicInstant(u128); + +impl MonotonicInstant { + #[must_use] + pub const fn from_nanos(value: u128) -> Self { + Self(value) + } + + #[must_use] + pub const fn as_nanos(self) -> u128 { + self.0 + } +} + +/// UTC timestamp represented as Unix nanoseconds. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct UtcTimestamp(i128); + +impl UtcTimestamp { + #[must_use] + pub const fn from_unix_nanos(value: i128) -> Self { + Self(value) + } + + #[must_use] + pub const fn as_unix_nanos(self) -> i128 { + self.0 + } +} + +/// Exact words received from or prepared for Modbus. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RawRegisters(Box<[u16]>); + +impl RawRegisters { + /// Creates a non-empty, protocol-bounded register vector. + pub fn new(registers: impl Into>) -> Result { + let registers = registers.into(); + if registers.is_empty() { + return Err(RawRegistersError::Empty); + } + if registers.len() > 125 { + return Err(RawRegistersError::TooMany(registers.len())); + } + Ok(Self(registers)) + } + + /// Returns exact words without re-encoding engineering text. + #[must_use] + pub fn as_slice(&self) -> &[u16] { + &self.0 + } +} + +/// Raw register collection error. +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum RawRegistersError { + #[error("raw register collection must not be empty")] + Empty, + #[error("raw register collection has {0} words; maximum is 125")] + TooMany(usize), +} + +/// Current quality of one parameter observation. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum TelemetryQuality { + Good, + Stale, + Timeout, + ProtocolException, + DecodeError, + Disconnected, + Unavailable, +} + +impl TelemetryQuality { + /// Only a Good observation may satisfy a fresh safety guard. + #[must_use] + pub const fn can_satisfy_write_guard(self) -> bool { + matches!(self, Self::Good) + } +} + +/// Variable core of one decoded sample. Profile metadata is referenced by ID. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TelemetrySampleCore { + pub session_id: SessionId, + pub parameter_id: ParameterId, + pub raw: RawRegisters, + pub engineering: EngineeringValue, + pub quality: TelemetryQuality, + pub monotonic_time: MonotonicInstant, + pub utc_time: UtcTimestamp, + pub request_id: RequestId, +} + +#[cfg(test)] +mod tests { + use super::{RawRegisters, RawRegistersError, TelemetryQuality}; + + #[test] + fn raw_words_are_bounded() { + assert_eq!( + RawRegisters::new(Vec::::new()), + Err(RawRegistersError::Empty) + ); + assert!(matches!( + RawRegisters::new(vec![0; 126]), + Err(RawRegistersError::TooMany(126)) + )); + } + + #[test] + fn only_good_quality_can_guard_write() { + assert!(TelemetryQuality::Good.can_satisfy_write_guard()); + assert!(!TelemetryQuality::Stale.can_satisfy_write_guard()); + assert!(!TelemetryQuality::Timeout.can_satisfy_write_guard()); + } +} diff --git a/crates/lantern-domain/src/value.rs b/crates/lantern-domain/src/value.rs new file mode 100644 index 0000000..99ad340 --- /dev/null +++ b/crates/lantern-domain/src/value.rs @@ -0,0 +1,196 @@ +use rust_decimal::{Decimal, RoundingStrategy, prelude::ToPrimitive}; +use thiserror::Error; + +/// Exact engineering value. Float variants preserve only IEEE-754 bits. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum EngineeringValue { + Fixed(Decimal), + Float32Bits(u32), + Float64Bits(u64), + EnumRaw(i64), + BitfieldRaw(u64), +} + +impl EngineeringValue { + /// Calculates an f32 from the authoritative bits. + #[must_use] + pub fn to_f32(&self) -> Option { + match self { + Self::Float32Bits(bits) => Some(f32::from_bits(*bits)), + _ => None, + } + } + + /// Calculates an f64 from the authoritative bits. + #[must_use] + pub fn to_f64(&self) -> Option { + match self { + Self::Float64Bits(bits) => Some(f64::from_bits(*bits)), + Self::Float32Bits(bits) => Some(f64::from(f32::from_bits(*bits))), + _ => None, + } + } +} + +impl From for EngineeringValue { + fn from(value: f32) -> Self { + Self::Float32Bits(value.to_bits()) + } +} + +impl From for EngineeringValue { + fn from(value: f64) -> Self { + Self::Float64Bits(value.to_bits()) + } +} + +/// Explicit rounding policy for fixed-point conversion. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum RoundingMode { + MidpointNearestEven, + MidpointAwayFromZero, + TowardZero, + AwayFromZero, + TowardPositiveInfinity, + TowardNegativeInfinity, +} + +impl RoundingMode { + const fn decimal_strategy(self) -> RoundingStrategy { + match self { + Self::MidpointNearestEven => RoundingStrategy::MidpointNearestEven, + Self::MidpointAwayFromZero => RoundingStrategy::MidpointAwayFromZero, + Self::TowardZero => RoundingStrategy::ToZero, + Self::AwayFromZero => RoundingStrategy::AwayFromZero, + Self::TowardPositiveInfinity => RoundingStrategy::ToPositiveInfinity, + Self::TowardNegativeInfinity => RoundingStrategy::ToNegativeInfinity, + } + } +} + +/// Exact affine scale for integer/BCD register values. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FixedScale { + multiplier: Decimal, + divisor: Decimal, + offset: Decimal, + decimal_places: u32, + rounding: RoundingMode, +} + +impl FixedScale { + /// Validates a reversible fixed-point scale. + pub fn new( + multiplier: Decimal, + divisor: Decimal, + offset: Decimal, + decimal_places: u32, + rounding: RoundingMode, + ) -> Result { + if multiplier.is_zero() { + return Err(ScaleError::ZeroMultiplier); + } + if divisor.is_zero() { + return Err(ScaleError::ZeroDivisor); + } + if decimal_places > Decimal::MAX_SCALE { + return Err(ScaleError::TooManyDecimalPlaces(decimal_places)); + } + + Ok(Self { + multiplier, + divisor, + offset, + decimal_places, + rounding, + }) + } + + /// Identity scale. + #[must_use] + pub fn identity() -> Self { + Self { + multiplier: Decimal::ONE, + divisor: Decimal::ONE, + offset: Decimal::ZERO, + decimal_places: 0, + rounding: RoundingMode::MidpointNearestEven, + } + } + + /// Converts an integer raw value to exact engineering units. + pub fn decode_i128(&self, raw: i128) -> Result { + let raw = Decimal::from_i128_with_scale(raw, 0); + raw.checked_mul(self.multiplier) + .and_then(|value| value.checked_div(self.divisor)) + .and_then(|value| value.checked_add(self.offset)) + .map(|value| { + value.round_dp_with_strategy(self.decimal_places, self.rounding.decimal_strategy()) + }) + .ok_or(ScaleError::ArithmeticOverflow) + } + + /// Converts engineering units back to an integer raw value. + pub fn encode_i128(&self, value: Decimal) -> Result { + let raw = value + .checked_sub(self.offset) + .and_then(|value| value.checked_mul(self.divisor)) + .and_then(|value| value.checked_div(self.multiplier)) + .ok_or(ScaleError::ArithmeticOverflow)? + .round_dp_with_strategy(0, self.rounding.decimal_strategy()); + + raw.to_i128().ok_or(ScaleError::NotRepresentable) + } + + /// Returns the configured decimal places. + #[must_use] + pub const fn decimal_places(&self) -> u32 { + self.decimal_places + } +} + +/// Fixed-point conversion error. +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum ScaleError { + #[error("fixed scale multiplier must be non-zero")] + ZeroMultiplier, + #[error("fixed scale divisor must be non-zero")] + ZeroDivisor, + #[error("fixed scale decimal places {0} exceed Decimal capacity")] + TooManyDecimalPlaces(u32), + #[error("fixed-point arithmetic overflow")] + ArithmeticOverflow, + #[error("engineering value is not representable as an integer raw value")] + NotRepresentable, +} + +#[cfg(test)] +mod tests { + use rust_decimal::Decimal; + + use super::{EngineeringValue, FixedScale, RoundingMode}; + + #[test] + fn float_bits_are_authoritative() { + let value = EngineeringValue::from(f32::NAN); + let EngineeringValue::Float32Bits(bits) = value else { + panic!("expected bits") + }; + assert!(f32::from_bits(bits).is_nan()); + } + + #[test] + fn fixed_scale_round_trips_documented_values() { + let scale = FixedScale::new( + Decimal::ONE, + Decimal::from(100), + Decimal::ZERO, + 2, + RoundingMode::MidpointNearestEven, + ) + .expect("scale"); + let engineering = scale.decode_i128(5_001).expect("decode"); + assert_eq!(engineering, Decimal::new(5001, 2)); + assert_eq!(scale.encode_i128(engineering).expect("encode"), 5_001); + } +} diff --git a/crates/lantern-profile/Cargo.toml b/crates/lantern-profile/Cargo.toml new file mode 100644 index 0000000..819de67 --- /dev/null +++ b/crates/lantern-profile/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "lantern-profile" +description = "Parsing, validation, canonicalization, and hashing of VFD device profiles" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +lantern-domain.workspace = true +rust_decimal.workspace = true +schemars.workspace = true +serde.workspace = true +serde_jcs.workspace = true +serde_json.workspace = true +serde_path_to_error.workspace = true +sha2.workspace = true +thiserror.workspace = true +toml.workspace = true + +[lints] +workspace = true diff --git a/crates/lantern-profile/src/document.rs b/crates/lantern-profile/src/document.rs new file mode 100644 index 0000000..f1d2e60 --- /dev/null +++ b/crates/lantern-profile/src/document.rs @@ -0,0 +1,336 @@ +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Version-one device profile document. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileDocumentV1 { + pub schema_version: u32, + pub profile_id: String, + pub revision: u32, + pub vendor: String, + pub family: String, + pub model: String, + #[serde(default)] + pub sources: Vec, + #[serde(default)] + pub safety_notes: Vec, + pub protocol: ProtocolDocumentV1, + #[serde(default)] + pub identification: IdentificationDocumentV1, + #[serde(default)] + pub parameters: Vec, + #[serde(default)] + pub aliases: BTreeMap, + #[serde(default)] + pub groups: Vec, + pub fault_source: Option, + #[serde(default)] + pub faults: BTreeMap, + #[serde(default)] + pub telemetry_presets: Vec, + #[serde(default)] + pub restore_order: Vec, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProtocolDocumentV1 { + pub default_baud_rate: u32, + #[serde(default)] + pub allowed_baud_rates: Vec, + #[serde(default)] + pub default_parity: ParityDocument, + #[serde(default)] + pub allowed_parities: Vec, + #[serde(default = "default_data_bits")] + pub default_data_bits: u8, + #[serde(default)] + pub allowed_data_bits: Vec, + #[serde(default = "default_stop_bits")] + pub default_stop_bits: u8, + #[serde(default)] + pub allowed_stop_bits: Vec, + #[serde(default = "default_response_timeout_ms")] + pub response_timeout_ms: u64, + #[serde(default)] + pub minimum_inter_frame_delay_us: u64, + #[serde(default = "default_slave_id")] + pub default_slave_id: u8, + #[serde(default)] + pub rs485_mode: Rs485ModeDocument, +} + +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentificationDocumentV1 { + #[serde(default)] + pub probes: Vec, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentificationProbeDocumentV1 { + pub id: String, + pub description: String, + pub table: ModbusTableDocument, + pub address: AddressDocumentV1, + pub count: u16, + pub expected_raw: Vec>, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ParameterDocumentV1 { + pub id: String, + pub code: String, + pub name: String, + #[serde(default)] + pub description: String, + pub table: ModbusTableDocument, + pub address: AddressDocumentV1, + pub encoding: RegisterEncodingDocument, + #[serde(default)] + pub byte_order: ByteOrderDocument, + #[serde(default)] + pub word_order: WordOrderDocument, + pub scale: Option, + pub quantity: String, + pub unit: String, + #[serde(default)] + pub access: ParameterAccessDocument, + #[serde(default)] + pub restore_policy: RestorePolicyDocument, + #[serde(default)] + pub required_drive_state: RequiredDriveStateDocument, + pub write_function: Option, + pub read_back: Option, + #[serde(default)] + pub backup: bool, + #[serde(default)] + pub do_not_bridge: bool, + #[serde(default)] + pub maximum_bridge_gap: u16, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AddressDocumentV1 { + pub notation: AddressNotation, + pub value: u32, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FixedScaleDocumentV1 { + pub multiplier: String, + pub divisor: String, + #[serde(default = "default_decimal_zero")] + pub offset: String, + #[serde(default)] + pub decimal_places: u32, + #[serde(default)] + pub rounding: RoundingModeDocument, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ReadBackDocumentV1 { + ExactRaw, + AcceptedRawSet { values: Vec> }, + FloatExactBits, + FloatAbsRelTolerance { absolute: String, relative: String }, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ParameterGroupDocumentV1 { + pub id: String, + pub name: String, + pub parameters: Vec, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FaultSourceDocumentV1 { + pub kind: FaultSourceKindDocument, + pub parameter_id: String, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FaultDefinitionDocumentV1 { + pub code: String, + pub name: String, + pub description: String, + #[serde(default)] + pub severity: FaultSeverityDocument, + #[serde(default)] + pub freeze_frame: Vec, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TelemetryPresetDocumentV1 { + pub id: String, + pub name: String, + pub parameters: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AddressNotation { + PduZeroBased, + ProtocolOneBased, + #[serde(rename = "modicon_5_digit")] + Modicon5Digit, + #[serde(rename = "modicon_6_digit")] + Modicon6Digit, +} + +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ModbusTableDocument { + InputRegisters, + HoldingRegisters, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ParityDocument { + #[default] + None, + Even, + Odd, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Rs485ModeDocument { + #[default] + AdapterManaged, + LinuxIoctl, +} + +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RegisterEncodingDocument { + Unsigned16, + Signed16, + Unsigned32, + Signed32, + Unsigned64, + Signed64, + Float32, + Float64, + Bcd16, + Bcd32, + Enum16, + Enum32, + Bitfield16, + Bitfield32, + Bitfield64, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ByteOrderDocument { + #[default] + BigEndian, + LittleEndian, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WordOrderDocument { + #[default] + MostSignificantFirst, + LeastSignificantFirst, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ParameterAccessDocument { + #[default] + ReadOnly, + WritableWhenStopped, + Commissioning, + Dangerous, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RestorePolicyDocument { + #[default] + Normal, + LinkCritical, + RestartRequired, + ManualOnly, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RequiredDriveStateDocument { + #[default] + Any, + Stopped, + Faulted, +} + +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WriteFunctionDocument { + WriteSingleRegister, + WriteMultipleRegisters, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RoundingModeDocument { + #[default] + MidpointNearestEven, + MidpointAwayFromZero, + TowardZero, + AwayFromZero, + TowardPositiveInfinity, + TowardNegativeInfinity, +} + +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FaultSourceKindDocument { + ScalarCode, + BitSet, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FaultSeverityDocument { + Info, + Warning, + #[default] + Fault, + Critical, +} + +const fn default_data_bits() -> u8 { + 8 +} + +const fn default_stop_bits() -> u8 { + 1 +} + +const fn default_response_timeout_ms() -> u64 { + 500 +} + +const fn default_slave_id() -> u8 { + 1 +} + +fn default_decimal_zero() -> String { + "0".to_owned() +} diff --git a/crates/lantern-profile/src/error.rs b/crates/lantern-profile/src/error.rs new file mode 100644 index 0000000..74db3b2 --- /dev/null +++ b/crates/lantern-profile/src/error.rs @@ -0,0 +1,48 @@ +use std::fmt; + +use thiserror::Error; + +use crate::ProfileFormat; + +/// Maximum accepted profile document size. +pub const MAX_PROFILE_BYTES: usize = 4 * 1024 * 1024; +/// Maximum number of parameters in one profile. +pub const MAX_PARAMETERS: usize = 20_000; +/// Maximum number of fault definitions in one profile. +pub const MAX_FAULTS: usize = 4_096; +/// Maximum number of telemetry presets in one profile. +pub const MAX_PRESETS: usize = 256; +/// Maximum number of bytes in one human-readable field. +pub const MAX_TEXT_BYTES: usize = 16 * 1024; + +/// Profile parsing, validation, or canonicalization failure. +#[derive(Debug, Error)] +pub enum ProfileError { + #[error("profile contains {actual} bytes; maximum is {maximum}")] + SourceTooLarge { actual: usize, maximum: usize }, + #[error("profile is not valid UTF-8: {0}")] + InvalidUtf8(#[from] std::str::Utf8Error), + #[error("invalid {format} profile at {path}: {message}")] + Deserialize { + format: ProfileFormat, + path: String, + message: String, + }, + #[error("unsupported profile schema version {0}; supported version is 1")] + UnsupportedSchema(u32), + #[error("profile validation failed at {path}: {message}")] + Validation { path: String, message: String }, + #[error("profile canonicalization failed: {0}")] + Canonical(String), + #[error("profile TOML normalization failed: {0}")] + Normalize(String), +} + +impl ProfileError { + pub(crate) fn validation(path: impl Into, message: impl fmt::Display) -> Self { + Self::Validation { + path: path.into(), + message: message.to_string(), + } + } +} diff --git a/crates/lantern-profile/src/hash.rs b/crates/lantern-profile/src/hash.rs new file mode 100644 index 0000000..2074c68 --- /dev/null +++ b/crates/lantern-profile/src/hash.rs @@ -0,0 +1,49 @@ +use std::fmt; + +use sha2::{Digest, Sha256}; + +macro_rules! hash_type { + ($name:ident, $documentation:literal) => { + #[doc = $documentation] + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] + pub struct $name([u8; 32]); + + impl $name { + pub(crate) fn digest(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } + + /// Returns the binary SHA-256 digest. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Returns lowercase hexadecimal text. + #[must_use] + pub fn to_hex(self) -> String { + let mut text = String::with_capacity(64); + for byte in self.0 { + use std::fmt::Write as _; + write!(&mut text, "{byte:02x}").expect("writing to String cannot fail"); + } + text + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(formatter, "{byte:02x}")?; + } + Ok(()) + } + } + }; +} + +hash_type!(SourceHash, "SHA-256 of the exact input bytes."); +hash_type!( + ProfileHash, + "SHA-256 of the normalized semantic CanonicalProfileV1 model." +); diff --git a/crates/lantern-profile/src/lib.rs b/crates/lantern-profile/src/lib.rs new file mode 100644 index 0000000..bbebdb7 --- /dev/null +++ b/crates/lantern-profile/src/lib.rs @@ -0,0 +1,105 @@ +//! Versioned parsing, validation, canonicalization, and hashing of VFD profiles. + +#![forbid(unsafe_code)] + +mod document; +mod error; +mod hash; +mod validate; + +use std::fmt; + +use schemars::schema_for; +use serde::de::DeserializeOwned; + +pub use document::*; +pub use error::{ + MAX_FAULTS, MAX_PARAMETERS, MAX_PRESETS, MAX_PROFILE_BYTES, MAX_TEXT_BYTES, ProfileError, +}; +pub use hash::{ProfileHash, SourceHash}; +pub use validate::{ + FaultSeverity, FaultSourceKind, ReadBackPolicy, ValidatedDeviceProfile, + ValidatedFaultDefinition, ValidatedFaultSource, ValidatedParameter, ValidatedParameterGroup, + ValidatedProbe, ValidatedProtocol, ValidatedTelemetryPreset, +}; + +/// Explicit input format. File extensions are interpreted only by storage/CLI adapters. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProfileFormat { + Json, + Toml, +} + +impl fmt::Display for ProfileFormat { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Json => "JSON", + Self::Toml => "TOML", + }) + } +} + +/// Parses bounded bytes, validates all semantic references, and computes both hashes. +pub fn parse_and_validate_profile( + source: &[u8], + format: ProfileFormat, +) -> Result { + if source.len() > MAX_PROFILE_BYTES { + return Err(ProfileError::SourceTooLarge { + actual: source.len(), + maximum: MAX_PROFILE_BYTES, + }); + } + let source_hash = SourceHash::digest(source); + let document = match format { + ProfileFormat::Json => deserialize_json(source)?, + ProfileFormat::Toml => deserialize_toml(source)?, + }; + validate::validate_profile(document, source_hash) +} + +/// Serializes the materialized, normalized v1 input schema as deterministic TOML. +pub fn normalize_profile_toml(profile: &ValidatedDeviceProfile) -> Result { + toml::to_string_pretty(profile.normalized_document()) + .map_err(|error| ProfileError::Normalize(error.to_string())) +} + +/// Generates JSON Schema from the same Rust document type used by the parser. +pub fn profile_schema_json() -> Result { + serde_json::to_string_pretty(&schema_for!(ProfileDocumentV1)) + .map_err(|error| ProfileError::Canonical(error.to_string())) +} + +fn deserialize_json(source: &[u8]) -> Result { + let mut deserializer = serde_json::Deserializer::from_slice(source); + let value = serde_path_to_error::deserialize(&mut deserializer).map_err(|error| { + ProfileError::Deserialize { + format: ProfileFormat::Json, + path: error.path().to_string(), + message: error.inner().to_string(), + } + })?; + deserializer + .end() + .map_err(|error| ProfileError::Deserialize { + format: ProfileFormat::Json, + path: "$".to_owned(), + message: error.to_string(), + })?; + Ok(value) +} + +fn deserialize_toml(source: &[u8]) -> Result { + let text = std::str::from_utf8(source)?; + let deserializer = + toml::Deserializer::parse(text).map_err(|error| ProfileError::Deserialize { + format: ProfileFormat::Toml, + path: "$".to_owned(), + message: error.to_string(), + })?; + serde_path_to_error::deserialize(deserializer).map_err(|error| ProfileError::Deserialize { + format: ProfileFormat::Toml, + path: error.path().to_string(), + message: error.inner().to_string(), + }) +} diff --git a/crates/lantern-profile/src/validate/build/mod.rs b/crates/lantern-profile/src/validate/build/mod.rs new file mode 100644 index 0000000..4e12c49 --- /dev/null +++ b/crates/lantern-profile/src/validate/build/mod.rs @@ -0,0 +1,12 @@ +mod parameter; +mod profile; +mod protocol; + +use super::*; + +pub(crate) fn validate_profile( + document: ProfileDocumentV1, + source_hash: SourceHash, +) -> Result { + profile::validate_profile(document, source_hash) +} diff --git a/crates/lantern-profile/src/validate/build/parameter.rs b/crates/lantern-profile/src/validate/build/parameter.rs new file mode 100644 index 0000000..b2628ed --- /dev/null +++ b/crates/lantern-profile/src/validate/build/parameter.rs @@ -0,0 +1,202 @@ +use super::super::{helpers::*, references::normalize_address, *}; + +pub(super) fn validate_parameter( + document: &mut ParameterDocumentV1, + index: usize, +) -> Result { + let base = format!("parameters[{index}]"); + let id = ParameterId::parse(document.id.clone()) + .map_err(|error| ProfileError::validation(format!("{base}.id"), error))?; + validate_text(format!("{base}.code"), &document.code, false)?; + validate_text(format!("{base}.name"), &document.name, false)?; + validate_text(format!("{base}.description"), &document.description, true)?; + + let table = table(document.table); + let encoding = encoding(document.encoding); + let width = u16::try_from(encoding.register_width()).expect("encoding width fits u16"); + let address = normalize_address(table, &document.address, format!("{base}.address"))?; + document.address = AddressDocumentV1 { + notation: AddressNotation::PduZeroBased, + value: u32::from(address.get()), + }; + let count = RegisterCount::new(width).expect("encoding width is non-zero"); + let read_function = match table { + ModbusTable::InputRegisters => ModbusFunction::ReadInputRegisters, + ModbusTable::HoldingRegisters => ModbusFunction::ReadHoldingRegisters, + }; + let block = RegisterBlock::new(table, address, count, read_function) + .map_err(|error| ProfileError::validation(format!("{base}.address"), error))?; + + let scale = document + .scale + .as_mut() + .map(|scale| validate_scale(scale, format!("{base}.scale"))) + .transpose()?; + let codec = RegisterCodec::new( + encoding, + byte_order(document.byte_order), + word_order(document.word_order), + scale, + ) + .map_err(|error| ProfileError::validation(format!("{base}.encoding"), error))?; + + let quantity = quantity(&document.quantity, format!("{base}.quantity"))?; + let unit = UnitId::new(quantity.clone(), document.unit.clone()) + .map_err(|error| ProfileError::validation(format!("{base}.unit"), error))?; + let access = access(document.access); + let restore_policy = restore_policy(document.restore_policy); + let required_drive_state = required_drive_state(document.required_drive_state); + let write_function = document.write_function.map(write_function); + + if table == ModbusTable::InputRegisters && write_function.is_some() { + return Err(ProfileError::validation( + format!("{base}.write_function"), + "input registers cannot be written", + )); + } + if access == ParameterAccess::ReadOnly && write_function.is_some() { + return Err(ProfileError::validation( + format!("{base}.write_function"), + "read-only parameter cannot define a write function", + )); + } + if access != ParameterAccess::ReadOnly && write_function.is_none() { + return Err(ProfileError::validation( + format!("{base}.write_function"), + "writable parameter requires FC06 or FC16", + )); + } + if let Some(function) = write_function { + function + .validate_table(table) + .and_then(|()| function.validate_count(count)) + .map_err(|error| ProfileError::validation(format!("{base}.write_function"), error))?; + } + + let read_back = validate_read_back( + document, + encoding, + width as usize, + format!("{base}.read_back"), + )?; + + Ok(ValidatedParameter { + id, + code: document.code.clone(), + name: document.name.clone(), + description: document.description.clone(), + block, + codec, + quantity, + unit, + access, + restore_policy, + required_drive_state, + write_function, + read_back, + backup: document.backup, + do_not_bridge: document.do_not_bridge, + maximum_bridge_gap: document.maximum_bridge_gap, + }) +} + +fn validate_read_back( + document: &mut ParameterDocumentV1, + encoding: RegisterEncoding, + width: usize, + path: String, +) -> Result { + let is_float = matches!( + encoding, + RegisterEncoding::Float32 | RegisterEncoding::Float64 + ); + if document.read_back.is_none() { + document.read_back = Some(if is_float { + ReadBackDocumentV1::FloatExactBits + } else { + ReadBackDocumentV1::ExactRaw + }); + } + + match document.read_back.as_mut().expect("default materialized") { + ReadBackDocumentV1::ExactRaw if !is_float => Ok(ReadBackPolicy::ExactRaw), + ReadBackDocumentV1::ExactRaw => Err(ProfileError::validation( + path, + "float parameters must use float_exact_bits or float_abs_rel_tolerance", + )), + ReadBackDocumentV1::AcceptedRawSet { values } => { + if is_float { + return Err(ProfileError::validation( + path, + "accepted_raw_set is not valid for float parameters", + )); + } + if values.is_empty() || values.len() > 8 { + return Err(ProfileError::validation( + path, + "accepted_raw_set must contain 1..=8 values", + )); + } + let mut unique = BTreeSet::new(); + let mut validated = Vec::with_capacity(values.len()); + for (index, raw) in values.iter().enumerate() { + if raw.len() != width { + return Err(ProfileError::validation( + format!("{path}.values[{index}]"), + format!("expected {width} registers, received {}", raw.len()), + )); + } + if !unique.insert(raw.clone()) { + return Err(ProfileError::validation( + format!("{path}.values[{index}]"), + "duplicate accepted raw value", + )); + } + validated.push(RawRegisters::new(raw.clone()).map_err(|error| { + ProfileError::validation(format!("{path}.values[{index}]"), error) + })?); + } + values.sort(); + Ok(ReadBackPolicy::AcceptedRawSet(validated.into_boxed_slice())) + } + ReadBackDocumentV1::FloatExactBits if is_float => Ok(ReadBackPolicy::FloatExactBits), + ReadBackDocumentV1::FloatExactBits => Err(ProfileError::validation( + path, + "float_exact_bits is valid only for float parameters", + )), + ReadBackDocumentV1::FloatAbsRelTolerance { absolute, relative } if is_float => { + let absolute_value = parse_non_negative_decimal(absolute, format!("{path}.absolute"))?; + let relative_value = parse_non_negative_decimal(relative, format!("{path}.relative"))?; + *absolute = canonical_decimal(absolute_value); + *relative = canonical_decimal(relative_value); + Ok(ReadBackPolicy::FloatAbsRelTolerance { + absolute: absolute_value, + relative: relative_value, + }) + } + ReadBackDocumentV1::FloatAbsRelTolerance { .. } => Err(ProfileError::validation( + path, + "float tolerance is valid only for float parameters", + )), + } +} + +fn validate_scale( + document: &mut crate::FixedScaleDocumentV1, + path: String, +) -> Result { + let multiplier = parse_decimal(&document.multiplier, format!("{path}.multiplier"))?; + let divisor = parse_decimal(&document.divisor, format!("{path}.divisor"))?; + let offset = parse_decimal(&document.offset, format!("{path}.offset"))?; + document.multiplier = canonical_decimal(multiplier); + document.divisor = canonical_decimal(divisor); + document.offset = canonical_decimal(offset); + FixedScale::new( + multiplier, + divisor, + offset, + document.decimal_places, + rounding(document.rounding), + ) + .map_err(|error| ProfileError::validation(path, error)) +} diff --git a/crates/lantern-profile/src/validate/build/profile.rs b/crates/lantern-profile/src/validate/build/profile.rs new file mode 100644 index 0000000..13a30b1 --- /dev/null +++ b/crates/lantern-profile/src/validate/build/profile.rs @@ -0,0 +1,101 @@ +use super::super::{helpers::*, references::*, *}; +use super::{parameter::validate_parameter, protocol::validate_protocol}; + +pub(super) fn validate_profile( + mut document: ProfileDocumentV1, + source_hash: SourceHash, +) -> Result { + if document.schema_version != 1 { + return Err(ProfileError::UnsupportedSchema(document.schema_version)); + } + if document.revision == 0 { + return Err(ProfileError::validation( + "revision", + "revision must be non-zero", + )); + } + validate_text("vendor", &document.vendor, false)?; + validate_text("family", &document.family, false)?; + validate_text("model", &document.model, false)?; + for (index, source) in document.sources.iter().enumerate() { + validate_text(format!("sources[{index}]"), source, false)?; + } + for (index, note) in document.safety_notes.iter().enumerate() { + validate_text(format!("safety_notes[{index}]"), note, false)?; + } + + let profile_id = ProfileId::parse(document.profile_id.clone()) + .map_err(|error| ProfileError::validation("profile_id", error))?; + let protocol = validate_protocol(&mut document)?; + + if document.parameters.len() > MAX_PARAMETERS { + return Err(ProfileError::validation( + "parameters", + format!( + "contains {} entries; maximum is {MAX_PARAMETERS}", + document.parameters.len() + ), + )); + } + + let mut parameter_ids = BTreeSet::new(); + let mut parameter_codes = BTreeSet::new(); + let mut parameters = BTreeMap::new(); + for (index, parameter) in document.parameters.iter_mut().enumerate() { + let validated = validate_parameter(parameter, index)?; + if !parameter_ids.insert(validated.id.clone()) { + return Err(ProfileError::validation( + format!("parameters[{index}].id"), + "duplicate parameter ID", + )); + } + if !parameter_codes.insert(validated.code.clone()) { + return Err(ProfileError::validation( + format!("parameters[{index}].code"), + "duplicate parameter code", + )); + } + parameters.insert(validated.id.clone(), validated); + } + document + .parameters + .sort_by(|left, right| left.id.cmp(&right.id)); + + let probes = validate_probes(&mut document, ¶meters)?; + let aliases = validate_aliases(&document, ¶meters)?; + let groups = validate_groups(&document, ¶meters)?; + let (fault_source, faults) = validate_faults(&document, ¶meters)?; + let presets = validate_presets(&document, ¶meters)?; + let restore_order = validate_restore_order(&document, ¶meters)?; + + document.sources.sort(); + document.sources.dedup(); + + let canonical = CanonicalProfileV1 { + canonical_schema_version: 1, + profile: &document, + }; + let canonical_bytes = serde_jcs::to_vec(&canonical) + .map_err(|error| ProfileError::Canonical(error.to_string()))?; + let profile_hash = ProfileHash::digest(&canonical_bytes); + + Ok(ValidatedDeviceProfile { + profile_id, + revision: document.revision, + vendor: document.vendor.clone(), + family: document.family.clone(), + model: document.model.clone(), + source_hash, + profile_hash, + protocol, + probes: probes.into_boxed_slice(), + parameters, + aliases, + groups: groups.into_boxed_slice(), + fault_source, + faults, + presets: presets.into_boxed_slice(), + restore_order: restore_order.into_boxed_slice(), + normalized_document: document, + }) +} diff --git a/crates/lantern-profile/src/validate/build/protocol.rs b/crates/lantern-profile/src/validate/build/protocol.rs new file mode 100644 index 0000000..d74c039 --- /dev/null +++ b/crates/lantern-profile/src/validate/build/protocol.rs @@ -0,0 +1,88 @@ +use super::super::{helpers::*, *}; + +pub(super) fn validate_protocol( + document: &mut ProfileDocumentV1, +) -> Result { + let protocol = &mut document.protocol; + let default_baud_rate = BaudRate::new(protocol.default_baud_rate) + .map_err(|error| ProfileError::validation("protocol.default_baud_rate", error))?; + let default_parity = parity(protocol.default_parity); + let default_data_bits = data_bits(protocol.default_data_bits, "protocol.default_data_bits")?; + let default_stop_bits = stop_bits(protocol.default_stop_bits, "protocol.default_stop_bits")?; + let slave_id = SlaveId::new(protocol.default_slave_id) + .map_err(|error| ProfileError::validation("protocol.default_slave_id", error))?; + if protocol.response_timeout_ms == 0 { + return Err(ProfileError::validation( + "protocol.response_timeout_ms", + "timeout must be non-zero", + )); + } + + protocol.allowed_baud_rates.push(protocol.default_baud_rate); + protocol.allowed_baud_rates.sort_unstable(); + protocol.allowed_baud_rates.dedup(); + let allowed_baud_rates = protocol + .allowed_baud_rates + .iter() + .enumerate() + .map(|(index, value)| { + BaudRate::new(*value).map_err(|error| { + ProfileError::validation(format!("protocol.allowed_baud_rates[{index}]"), error) + }) + }) + .collect::, _>>()?; + + protocol.allowed_parities.push(protocol.default_parity); + protocol + .allowed_parities + .sort_by_key(|value| parity_rank(*value)); + protocol + .allowed_parities + .dedup_by_key(|value| parity_rank(*value)); + let allowed_parities = protocol + .allowed_parities + .iter() + .copied() + .map(parity) + .collect::>(); + + protocol.allowed_data_bits.push(protocol.default_data_bits); + protocol.allowed_data_bits.sort_unstable(); + protocol.allowed_data_bits.dedup(); + let allowed_data_bits = protocol + .allowed_data_bits + .iter() + .enumerate() + .map(|(index, value)| data_bits(*value, format!("protocol.allowed_data_bits[{index}]"))) + .collect::, _>>()?; + + protocol.allowed_stop_bits.push(protocol.default_stop_bits); + protocol.allowed_stop_bits.sort_unstable(); + protocol.allowed_stop_bits.dedup(); + let allowed_stop_bits = protocol + .allowed_stop_bits + .iter() + .enumerate() + .map(|(index, value)| stop_bits(*value, format!("protocol.allowed_stop_bits[{index}]"))) + .collect::, _>>()?; + + Ok(ValidatedProtocol { + default_link: LinkSettings { + baud_rate: default_baud_rate, + parity: default_parity, + data_bits: default_data_bits, + stop_bits: default_stop_bits, + response_timeout: Duration::from_millis(protocol.response_timeout_ms), + slave_id, + rs485_mode: match protocol.rs485_mode { + Rs485ModeDocument::AdapterManaged => Rs485Mode::AdapterManaged, + Rs485ModeDocument::LinuxIoctl => Rs485Mode::LinuxIoctl, + }, + }, + allowed_baud_rates: allowed_baud_rates.into_boxed_slice(), + allowed_parities: allowed_parities.into_boxed_slice(), + allowed_data_bits: allowed_data_bits.into_boxed_slice(), + allowed_stop_bits: allowed_stop_bits.into_boxed_slice(), + minimum_inter_frame_delay: Duration::from_micros(protocol.minimum_inter_frame_delay_us), + }) +} diff --git a/crates/lantern-profile/src/validate/helpers/conversions.rs b/crates/lantern-profile/src/validate/helpers/conversions.rs new file mode 100644 index 0000000..c96c152 --- /dev/null +++ b/crates/lantern-profile/src/validate/helpers/conversions.rs @@ -0,0 +1,149 @@ +use super::super::*; + +pub(super) const fn table(value: ModbusTableDocument) -> ModbusTable { + match value { + ModbusTableDocument::InputRegisters => ModbusTable::InputRegisters, + ModbusTableDocument::HoldingRegisters => ModbusTable::HoldingRegisters, + } +} + +pub(super) const fn parity(value: ParityDocument) -> Parity { + match value { + ParityDocument::None => Parity::None, + ParityDocument::Even => Parity::Even, + ParityDocument::Odd => Parity::Odd, + } +} + +pub(super) const fn parity_rank(value: ParityDocument) -> u8 { + match value { + ParityDocument::None => 0, + ParityDocument::Even => 1, + ParityDocument::Odd => 2, + } +} + +pub(super) fn data_bits(value: u8, path: impl Into) -> Result { + match value { + 7 => Ok(DataBits::Seven), + 8 => Ok(DataBits::Eight), + _ => Err(ProfileError::validation(path, "data bits must be 7 or 8")), + } +} + +pub(super) fn stop_bits(value: u8, path: impl Into) -> Result { + match value { + 1 => Ok(StopBits::One), + 2 => Ok(StopBits::Two), + _ => Err(ProfileError::validation(path, "stop bits must be 1 or 2")), + } +} + +pub(super) const fn encoding(value: RegisterEncodingDocument) -> RegisterEncoding { + match value { + RegisterEncodingDocument::Unsigned16 => RegisterEncoding::Unsigned16, + RegisterEncodingDocument::Signed16 => RegisterEncoding::Signed16, + RegisterEncodingDocument::Unsigned32 => RegisterEncoding::Unsigned32, + RegisterEncodingDocument::Signed32 => RegisterEncoding::Signed32, + RegisterEncodingDocument::Unsigned64 => RegisterEncoding::Unsigned64, + RegisterEncodingDocument::Signed64 => RegisterEncoding::Signed64, + RegisterEncodingDocument::Float32 => RegisterEncoding::Float32, + RegisterEncodingDocument::Float64 => RegisterEncoding::Float64, + RegisterEncodingDocument::Bcd16 => RegisterEncoding::Bcd16, + RegisterEncodingDocument::Bcd32 => RegisterEncoding::Bcd32, + RegisterEncodingDocument::Enum16 => RegisterEncoding::Enum16, + RegisterEncodingDocument::Enum32 => RegisterEncoding::Enum32, + RegisterEncodingDocument::Bitfield16 => RegisterEncoding::Bitfield16, + RegisterEncodingDocument::Bitfield32 => RegisterEncoding::Bitfield32, + RegisterEncodingDocument::Bitfield64 => RegisterEncoding::Bitfield64, + } +} + +pub(super) const fn byte_order(value: ByteOrderDocument) -> ByteOrder { + match value { + ByteOrderDocument::BigEndian => ByteOrder::BigEndian, + ByteOrderDocument::LittleEndian => ByteOrder::LittleEndian, + } +} + +pub(super) const fn word_order(value: WordOrderDocument) -> WordOrder { + match value { + WordOrderDocument::MostSignificantFirst => WordOrder::MostSignificantFirst, + WordOrderDocument::LeastSignificantFirst => WordOrder::LeastSignificantFirst, + } +} + +pub(super) const fn access(value: ParameterAccessDocument) -> ParameterAccess { + match value { + ParameterAccessDocument::ReadOnly => ParameterAccess::ReadOnly, + ParameterAccessDocument::WritableWhenStopped => ParameterAccess::WritableWhenStopped, + ParameterAccessDocument::Commissioning => ParameterAccess::Commissioning, + ParameterAccessDocument::Dangerous => ParameterAccess::Dangerous, + } +} + +pub(super) const fn restore_policy(value: RestorePolicyDocument) -> RestorePolicy { + match value { + RestorePolicyDocument::Normal => RestorePolicy::Normal, + RestorePolicyDocument::LinkCritical => RestorePolicy::LinkCritical, + RestorePolicyDocument::RestartRequired => RestorePolicy::RestartRequired, + RestorePolicyDocument::ManualOnly => RestorePolicy::ManualOnly, + } +} + +pub(super) const fn required_drive_state(value: RequiredDriveStateDocument) -> RequiredDriveState { + match value { + RequiredDriveStateDocument::Any => RequiredDriveState::Any, + RequiredDriveStateDocument::Stopped => RequiredDriveState::Stopped, + RequiredDriveStateDocument::Faulted => RequiredDriveState::Faulted, + } +} + +pub(super) const fn write_function(value: WriteFunctionDocument) -> ModbusFunction { + match value { + WriteFunctionDocument::WriteSingleRegister => ModbusFunction::WriteSingleRegister, + WriteFunctionDocument::WriteMultipleRegisters => ModbusFunction::WriteMultipleRegisters, + } +} + +pub(super) const fn rounding(value: RoundingModeDocument) -> RoundingMode { + match value { + RoundingModeDocument::MidpointNearestEven => RoundingMode::MidpointNearestEven, + RoundingModeDocument::MidpointAwayFromZero => RoundingMode::MidpointAwayFromZero, + RoundingModeDocument::TowardZero => RoundingMode::TowardZero, + RoundingModeDocument::AwayFromZero => RoundingMode::AwayFromZero, + RoundingModeDocument::TowardPositiveInfinity => RoundingMode::TowardPositiveInfinity, + RoundingModeDocument::TowardNegativeInfinity => RoundingMode::TowardNegativeInfinity, + } +} + +pub(super) fn quantity(value: &str, path: String) -> Result { + let quantity = match value { + "frequency" => QuantityKind::Frequency, + "rotational_speed" => QuantityKind::RotationalSpeed, + "current" => QuantityKind::Current, + "voltage" => QuantityKind::Voltage, + "power" => QuantityKind::Power, + "energy" => QuantityKind::Energy, + "torque" => QuantityKind::Torque, + "temperature" => QuantityKind::Temperature, + "time" => QuantityKind::Time, + "ratio" => QuantityKind::Ratio, + "pressure" => QuantityKind::Pressure, + "flow" => QuantityKind::Flow, + "count" => QuantityKind::Count, + "digital_state" => QuantityKind::DigitalState, + "unitless" => QuantityKind::Unitless, + custom if custom.starts_with("custom:") => QuantityKind::Custom( + QuantityId::parse(custom.trim_start_matches("custom:")) + .map_err(|error| ProfileError::validation(path.clone(), error))?, + ), + _ => { + return Err(ProfileError::validation( + path, + "unknown quantity; custom quantities use custom:", + )); + } + }; + Ok(quantity) +} diff --git a/crates/lantern-profile/src/validate/helpers/decimal.rs b/crates/lantern-profile/src/validate/helpers/decimal.rs new file mode 100644 index 0000000..61b9840 --- /dev/null +++ b/crates/lantern-profile/src/validate/helpers/decimal.rs @@ -0,0 +1,24 @@ +use super::super::*; + +pub(super) fn parse_decimal(value: &str, path: String) -> Result { + Decimal::from_str(value).map_err(|error| ProfileError::validation(path, error)) +} + +pub(super) fn parse_non_negative_decimal( + value: &str, + path: String, +) -> Result { + let parsed = parse_decimal(value, path.clone())?; + if parsed.is_sign_negative() { + return Err(ProfileError::validation(path, "value must be non-negative")); + } + Ok(parsed) +} + +pub(super) fn canonical_decimal(value: Decimal) -> String { + if value.is_zero() { + "0".to_owned() + } else { + value.normalize().to_string() + } +} diff --git a/crates/lantern-profile/src/validate/helpers/mod.rs b/crates/lantern-profile/src/validate/helpers/mod.rs new file mode 100644 index 0000000..1c7d138 --- /dev/null +++ b/crates/lantern-profile/src/validate/helpers/mod.rs @@ -0,0 +1,84 @@ +mod conversions; +mod decimal; +mod text; + +use super::*; + +pub(crate) const fn table(value: ModbusTableDocument) -> ModbusTable { + conversions::table(value) +} + +pub(crate) const fn parity(value: ParityDocument) -> Parity { + conversions::parity(value) +} + +pub(crate) const fn parity_rank(value: ParityDocument) -> u8 { + conversions::parity_rank(value) +} + +pub(crate) fn data_bits(value: u8, path: impl Into) -> Result { + conversions::data_bits(value, path) +} + +pub(crate) fn stop_bits(value: u8, path: impl Into) -> Result { + conversions::stop_bits(value, path) +} + +pub(crate) const fn encoding(value: RegisterEncodingDocument) -> RegisterEncoding { + conversions::encoding(value) +} + +pub(crate) const fn byte_order(value: ByteOrderDocument) -> ByteOrder { + conversions::byte_order(value) +} + +pub(crate) const fn word_order(value: WordOrderDocument) -> WordOrder { + conversions::word_order(value) +} + +pub(crate) const fn access(value: ParameterAccessDocument) -> ParameterAccess { + conversions::access(value) +} + +pub(crate) const fn restore_policy(value: RestorePolicyDocument) -> RestorePolicy { + conversions::restore_policy(value) +} + +pub(crate) const fn required_drive_state(value: RequiredDriveStateDocument) -> RequiredDriveState { + conversions::required_drive_state(value) +} + +pub(crate) const fn write_function(value: WriteFunctionDocument) -> ModbusFunction { + conversions::write_function(value) +} + +pub(crate) const fn rounding(value: RoundingModeDocument) -> RoundingMode { + conversions::rounding(value) +} + +pub(crate) fn quantity(value: &str, path: String) -> Result { + conversions::quantity(value, path) +} + +pub(crate) fn parse_decimal(value: &str, path: String) -> Result { + decimal::parse_decimal(value, path) +} + +pub(crate) fn parse_non_negative_decimal( + value: &str, + path: String, +) -> Result { + decimal::parse_non_negative_decimal(value, path) +} + +pub(crate) fn canonical_decimal(value: Decimal) -> String { + decimal::canonical_decimal(value) +} + +pub(crate) fn validate_text( + path: impl Into, + value: &str, + allow_empty: bool, +) -> Result<(), ProfileError> { + text::validate_text(path, value, allow_empty) +} diff --git a/crates/lantern-profile/src/validate/helpers/text.rs b/crates/lantern-profile/src/validate/helpers/text.rs new file mode 100644 index 0000000..792d67f --- /dev/null +++ b/crates/lantern-profile/src/validate/helpers/text.rs @@ -0,0 +1,28 @@ +use super::super::*; + +pub(super) fn validate_text( + path: impl Into, + value: &str, + allow_empty: bool, +) -> Result<(), ProfileError> { + let path = path.into(); + if !allow_empty && value.is_empty() { + return Err(ProfileError::validation(path, "text must not be empty")); + } + if value.len() > MAX_TEXT_BYTES { + return Err(ProfileError::validation( + path, + format!("text exceeds {MAX_TEXT_BYTES} bytes"), + )); + } + if let Some((index, character)) = value + .char_indices() + .find(|(_, character)| character.is_control() && !matches!(character, '\n' | '\t')) + { + return Err(ProfileError::validation( + path, + format!("contains control character {character:?} at byte {index}"), + )); + } + Ok(()) +} diff --git a/crates/lantern-profile/src/validate/mod.rs b/crates/lantern-profile/src/validate/mod.rs new file mode 100644 index 0000000..49b108e --- /dev/null +++ b/crates/lantern-profile/src/validate/mod.rs @@ -0,0 +1,367 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + str::FromStr, + time::Duration, +}; + +use lantern_domain::{ + BaudRate, ByteOrder, DataBits, FixedScale, LinkSettings, ModbusFunction, ModbusTable, + ParameterAccess, ParameterId, Parity, ProfileId, QuantityId, QuantityKind, RawRegisters, + RegisterAddress, RegisterBlock, RegisterCodec, RegisterCount, RegisterEncoding, + RequiredDriveState, RestorePolicy, RoundingMode, Rs485Mode, SlaveId, StopBits, UnitId, + WordOrder, +}; +use rust_decimal::Decimal; +use serde::Serialize; + +use crate::{ + AddressDocumentV1, AddressNotation, ByteOrderDocument, FaultDefinitionDocumentV1, + FaultSeverityDocument, FaultSourceKindDocument, MAX_FAULTS, MAX_PARAMETERS, MAX_PRESETS, + MAX_TEXT_BYTES, ModbusTableDocument, ParameterAccessDocument, ParameterDocumentV1, + ParityDocument, ProfileDocumentV1, ProfileError, ProfileHash, ReadBackDocumentV1, + RegisterEncodingDocument, RequiredDriveStateDocument, RestorePolicyDocument, + RoundingModeDocument, Rs485ModeDocument, SourceHash, TelemetryPresetDocumentV1, + WordOrderDocument, WriteFunctionDocument, +}; + +mod build; +mod helpers; +mod references; + +pub(crate) use build::validate_profile; + +/// Validated read-back policy used by guarded writes. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ReadBackPolicy { + ExactRaw, + AcceptedRawSet(Box<[RawRegisters]>), + FloatExactBits, + FloatAbsRelTolerance { + absolute: Decimal, + relative: Decimal, + }, +} + +/// One validated profile parameter. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedParameter { + id: ParameterId, + code: String, + name: String, + description: String, + block: RegisterBlock, + codec: RegisterCodec, + quantity: QuantityKind, + unit: UnitId, + access: ParameterAccess, + restore_policy: RestorePolicy, + required_drive_state: RequiredDriveState, + write_function: Option, + read_back: ReadBackPolicy, + backup: bool, + do_not_bridge: bool, + maximum_bridge_gap: u16, +} + +impl ValidatedParameter { + #[must_use] + pub fn id(&self) -> &ParameterId { + &self.id + } + + #[must_use] + pub fn code(&self) -> &str { + &self.code + } + + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + #[must_use] + pub fn description(&self) -> &str { + &self.description + } + + #[must_use] + pub const fn block(&self) -> RegisterBlock { + self.block + } + + #[must_use] + pub fn codec(&self) -> &RegisterCodec { + &self.codec + } + + #[must_use] + pub fn quantity(&self) -> &QuantityKind { + &self.quantity + } + + #[must_use] + pub fn unit(&self) -> &UnitId { + &self.unit + } + + #[must_use] + pub const fn access(&self) -> ParameterAccess { + self.access + } + + #[must_use] + pub const fn restore_policy(&self) -> RestorePolicy { + self.restore_policy + } + + #[must_use] + pub const fn required_drive_state(&self) -> RequiredDriveState { + self.required_drive_state + } + + #[must_use] + pub const fn write_function(&self) -> Option { + self.write_function + } + + #[must_use] + pub fn read_back(&self) -> &ReadBackPolicy { + &self.read_back + } + + #[must_use] + pub const fn included_in_backup(&self) -> bool { + self.backup + } + + #[must_use] + pub const fn do_not_bridge(&self) -> bool { + self.do_not_bridge + } + + #[must_use] + pub const fn maximum_bridge_gap(&self) -> u16 { + self.maximum_bridge_gap + } +} + +/// Fully validated serial protocol constraints. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedProtocol { + default_link: LinkSettings, + allowed_baud_rates: Box<[BaudRate]>, + allowed_parities: Box<[Parity]>, + allowed_data_bits: Box<[DataBits]>, + allowed_stop_bits: Box<[StopBits]>, + minimum_inter_frame_delay: Duration, +} + +impl ValidatedProtocol { + #[must_use] + pub const fn default_link(&self) -> LinkSettings { + self.default_link + } + + #[must_use] + pub fn allowed_baud_rates(&self) -> &[BaudRate] { + &self.allowed_baud_rates + } + + #[must_use] + pub fn allowed_parities(&self) -> &[Parity] { + &self.allowed_parities + } + + #[must_use] + pub fn allowed_data_bits(&self) -> &[DataBits] { + &self.allowed_data_bits + } + + #[must_use] + pub fn allowed_stop_bits(&self) -> &[StopBits] { + &self.allowed_stop_bits + } + + #[must_use] + pub const fn minimum_inter_frame_delay(&self) -> Duration { + self.minimum_inter_frame_delay + } +} + +/// One validated read-only identification probe. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedProbe { + pub id: String, + pub description: String, + pub block: RegisterBlock, + pub expected_raw: Box<[RawRegisters]>, +} + +/// Profile-defined parameter group; parameter order is presentation-significant. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedParameterGroup { + pub id: String, + pub name: String, + pub parameters: Box<[ParameterId]>, +} + +/// Fault source type. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FaultSourceKind { + ScalarCode, + BitSet, +} + +/// Validated profile fault source. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedFaultSource { + pub kind: FaultSourceKind, + pub parameter_id: ParameterId, +} + +/// Validated fault metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedFaultDefinition { + pub raw: u64, + pub code: String, + pub name: String, + pub description: String, + pub severity: FaultSeverity, + pub freeze_frame: Box<[ParameterId]>, +} + +/// Fault severity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FaultSeverity { + Info, + Warning, + Fault, + Critical, +} + +/// Validated telemetry preset. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedTelemetryPreset { + pub id: String, + pub name: String, + pub parameters: Box<[ParameterId]>, +} + +/// Immutable semantic profile accepted by the application layer. +#[derive(Clone, Debug)] +pub struct ValidatedDeviceProfile { + profile_id: ProfileId, + revision: u32, + vendor: String, + family: String, + model: String, + source_hash: SourceHash, + profile_hash: ProfileHash, + protocol: ValidatedProtocol, + probes: Box<[ValidatedProbe]>, + parameters: BTreeMap, + aliases: BTreeMap, + groups: Box<[ValidatedParameterGroup]>, + fault_source: Option, + faults: BTreeMap, + presets: Box<[ValidatedTelemetryPreset]>, + restore_order: Box<[ParameterId]>, + normalized_document: ProfileDocumentV1, +} + +impl ValidatedDeviceProfile { + #[must_use] + pub fn profile_id(&self) -> &ProfileId { + &self.profile_id + } + + #[must_use] + pub const fn revision(&self) -> u32 { + self.revision + } + + #[must_use] + pub fn vendor(&self) -> &str { + &self.vendor + } + + #[must_use] + pub fn family(&self) -> &str { + &self.family + } + + #[must_use] + pub fn model(&self) -> &str { + &self.model + } + + #[must_use] + pub const fn source_hash(&self) -> SourceHash { + self.source_hash + } + + #[must_use] + pub const fn profile_hash(&self) -> ProfileHash { + self.profile_hash + } + + #[must_use] + pub fn protocol(&self) -> &ValidatedProtocol { + &self.protocol + } + + #[must_use] + pub fn probes(&self) -> &[ValidatedProbe] { + &self.probes + } + + #[must_use] + pub fn parameters(&self) -> &BTreeMap { + &self.parameters + } + + #[must_use] + pub fn parameter(&self, id: &ParameterId) -> Option<&ValidatedParameter> { + self.parameters.get(id) + } + + #[must_use] + pub fn aliases(&self) -> &BTreeMap { + &self.aliases + } + + #[must_use] + pub fn groups(&self) -> &[ValidatedParameterGroup] { + &self.groups + } + + #[must_use] + pub fn fault_source(&self) -> Option<&ValidatedFaultSource> { + self.fault_source.as_ref() + } + + #[must_use] + pub fn faults(&self) -> &BTreeMap { + &self.faults + } + + #[must_use] + pub fn telemetry_presets(&self) -> &[ValidatedTelemetryPreset] { + &self.presets + } + + #[must_use] + pub fn restore_order(&self) -> &[ParameterId] { + &self.restore_order + } + + pub(crate) fn normalized_document(&self) -> &ProfileDocumentV1 { + &self.normalized_document + } +} + +#[derive(Serialize)] +struct CanonicalProfileV1<'a> { + canonical_schema_version: u32, + profile: &'a ProfileDocumentV1, +} diff --git a/crates/lantern-profile/src/validate/references/address.rs b/crates/lantern-profile/src/validate/references/address.rs new file mode 100644 index 0000000..5ec0c4a --- /dev/null +++ b/crates/lantern-profile/src/validate/references/address.rs @@ -0,0 +1,57 @@ +use super::super::*; + +pub(super) fn normalize_address( + table: ModbusTable, + document: &AddressDocumentV1, + path: String, +) -> Result { + let value = match document.notation { + AddressNotation::PduZeroBased if document.value <= u32::from(u16::MAX) => document.value, + AddressNotation::PduZeroBased => { + return Err(ProfileError::validation( + path, + "PDU address must be 0..=65535", + )); + } + AddressNotation::ProtocolOneBased if (1..=65_536).contains(&document.value) => { + document.value - 1 + } + AddressNotation::ProtocolOneBased => { + return Err(ProfileError::validation( + path, + "one-based protocol address must be 1..=65536", + )); + } + AddressNotation::Modicon5Digit => match table { + ModbusTable::InputRegisters if (30_001..=39_999).contains(&document.value) => { + document.value - 30_001 + } + ModbusTable::HoldingRegisters if (40_001..=49_999).contains(&document.value) => { + document.value - 40_001 + } + _ => { + return Err(ProfileError::validation( + path, + "5-digit Modicon address prefix does not match the register table", + )); + } + }, + AddressNotation::Modicon6Digit => match table { + ModbusTable::InputRegisters if (300_001..=365_536).contains(&document.value) => { + document.value - 300_001 + } + ModbusTable::HoldingRegisters if (400_001..=465_536).contains(&document.value) => { + document.value - 400_001 + } + _ => { + return Err(ProfileError::validation( + path, + "6-digit Modicon address prefix does not match the register table", + )); + } + }, + }; + Ok(RegisterAddress::new( + u16::try_from(value).expect("validated PDU range"), + )) +} diff --git a/crates/lantern-profile/src/validate/references/faults.rs b/crates/lantern-profile/src/validate/references/faults.rs new file mode 100644 index 0000000..9692664 --- /dev/null +++ b/crates/lantern-profile/src/validate/references/faults.rs @@ -0,0 +1,120 @@ +use super::super::{helpers::*, *}; +use super::presets::validate_parameter_references; + +pub(super) fn validate_faults( + document: &ProfileDocumentV1, + parameters: &BTreeMap, +) -> Result< + ( + Option, + BTreeMap, + ), + ProfileError, +> { + if document.faults.len() > MAX_FAULTS { + return Err(ProfileError::validation( + "faults", + format!( + "contains {} entries; maximum is {MAX_FAULTS}", + document.faults.len() + ), + )); + } + + let source = document + .fault_source + .as_ref() + .map(|source| { + let parameter_id = ParameterId::parse(source.parameter_id.clone()) + .map_err(|error| ProfileError::validation("fault_source.parameter_id", error))?; + let Some(parameter) = parameters.get(¶meter_id) else { + return Err(ProfileError::validation( + "fault_source.parameter_id", + format!("unknown parameter {parameter_id}"), + )); + }; + let kind = match source.kind { + FaultSourceKindDocument::ScalarCode => { + if !matches!( + parameter + .codec() + .decode(&vec![0; usize::from(parameter.block().count().get())]), + Ok(lantern_domain::EngineeringValue::EnumRaw(_)) + | Ok(lantern_domain::EngineeringValue::Fixed(_)) + ) { + return Err(ProfileError::validation( + "fault_source.kind", + "scalar_code requires enum or fixed integer encoding", + )); + } + FaultSourceKind::ScalarCode + } + FaultSourceKindDocument::BitSet => { + if !matches!( + parameter + .codec() + .decode(&vec![0; usize::from(parameter.block().count().get())]), + Ok(lantern_domain::EngineeringValue::BitfieldRaw(_)) + ) { + return Err(ProfileError::validation( + "fault_source.kind", + "bit_set requires bitfield encoding", + )); + } + FaultSourceKind::BitSet + } + }; + Ok(ValidatedFaultSource { kind, parameter_id }) + }) + .transpose()?; + + if !document.faults.is_empty() && source.is_none() { + return Err(ProfileError::validation( + "fault_source", + "fault definitions require a fault source", + )); + } + + let mut faults = BTreeMap::new(); + for (raw_text, fault) in &document.faults { + let raw = raw_text + .parse::() + .map_err(|error| ProfileError::validation(format!("faults.{raw_text}"), error))?; + validate_fault_text(raw_text, fault)?; + let freeze_frame = validate_parameter_references( + &fault.freeze_frame, + parameters, + format!("faults.{raw_text}.freeze_frame"), + )?; + faults.insert( + raw, + ValidatedFaultDefinition { + raw, + code: fault.code.clone(), + name: fault.name.clone(), + description: fault.description.clone(), + severity: match fault.severity { + FaultSeverityDocument::Info => FaultSeverity::Info, + FaultSeverityDocument::Warning => FaultSeverity::Warning, + FaultSeverityDocument::Fault => FaultSeverity::Fault, + FaultSeverityDocument::Critical => FaultSeverity::Critical, + }, + freeze_frame: freeze_frame.into_boxed_slice(), + }, + ); + } + Ok((source, faults)) +} + +fn validate_fault_text( + raw_text: &str, + fault: &FaultDefinitionDocumentV1, +) -> Result<(), ProfileError> { + validate_text(format!("faults.{raw_text}.code"), &fault.code, false)?; + validate_text(format!("faults.{raw_text}.name"), &fault.name, false)?; + validate_text( + format!("faults.{raw_text}.description"), + &fault.description, + false, + ) +} diff --git a/crates/lantern-profile/src/validate/references/groups.rs b/crates/lantern-profile/src/validate/references/groups.rs new file mode 100644 index 0000000..aeff2ca --- /dev/null +++ b/crates/lantern-profile/src/validate/references/groups.rs @@ -0,0 +1,56 @@ +use super::super::{helpers::*, *}; +use super::presets::validate_parameter_references; + +pub(super) fn validate_aliases( + document: &ProfileDocumentV1, + parameters: &BTreeMap, +) -> Result, ProfileError> { + let mut aliases = BTreeMap::new(); + for (alias, target) in &document.aliases { + let alias_id = ParameterId::parse(alias.clone()) + .map_err(|error| ProfileError::validation(format!("aliases.{alias}"), error))?; + let target_id = ParameterId::parse(target.clone()) + .map_err(|error| ProfileError::validation(format!("aliases.{alias}"), error))?; + if !parameters.contains_key(&target_id) { + return Err(ProfileError::validation( + format!("aliases.{alias}"), + format!("unknown parameter {target_id}"), + )); + } + aliases.insert(alias_id.as_str().to_owned(), target_id); + } + Ok(aliases) +} + +pub(super) fn validate_groups( + document: &ProfileDocumentV1, + parameters: &BTreeMap, +) -> Result, ProfileError> { + let mut group_ids = BTreeSet::new(); + document + .groups + .iter() + .enumerate() + .map(|(index, group)| { + let base = format!("groups[{index}]"); + validate_text(format!("{base}.id"), &group.id, false)?; + validate_text(format!("{base}.name"), &group.name, false)?; + if !group_ids.insert(group.id.clone()) { + return Err(ProfileError::validation( + format!("{base}.id"), + "duplicate group ID", + )); + } + let parameters = validate_parameter_references( + &group.parameters, + parameters, + format!("{base}.parameters"), + )?; + Ok(ValidatedParameterGroup { + id: group.id.clone(), + name: group.name.clone(), + parameters: parameters.into_boxed_slice(), + }) + }) + .collect() +} diff --git a/crates/lantern-profile/src/validate/references/mod.rs b/crates/lantern-profile/src/validate/references/mod.rs new file mode 100644 index 0000000..545268f --- /dev/null +++ b/crates/lantern-profile/src/validate/references/mod.rs @@ -0,0 +1,63 @@ +mod address; +mod faults; +mod groups; +mod presets; +mod probes; + +use super::*; + +pub(crate) fn normalize_address( + table: ModbusTable, + document: &AddressDocumentV1, + path: String, +) -> Result { + address::normalize_address(table, document, path) +} + +pub(crate) fn validate_faults( + document: &ProfileDocumentV1, + parameters: &BTreeMap, +) -> Result< + ( + Option, + BTreeMap, + ), + ProfileError, +> { + faults::validate_faults(document, parameters) +} + +pub(crate) fn validate_aliases( + document: &ProfileDocumentV1, + parameters: &BTreeMap, +) -> Result, ProfileError> { + groups::validate_aliases(document, parameters) +} + +pub(crate) fn validate_groups( + document: &ProfileDocumentV1, + parameters: &BTreeMap, +) -> Result, ProfileError> { + groups::validate_groups(document, parameters) +} + +pub(crate) fn validate_presets( + document: &ProfileDocumentV1, + parameters: &BTreeMap, +) -> Result, ProfileError> { + presets::validate_presets(document, parameters) +} + +pub(crate) fn validate_restore_order( + document: &ProfileDocumentV1, + parameters: &BTreeMap, +) -> Result, ProfileError> { + presets::validate_restore_order(document, parameters) +} + +pub(crate) fn validate_probes( + document: &mut ProfileDocumentV1, + parameters: &BTreeMap, +) -> Result, ProfileError> { + probes::validate_probes(document, parameters) +} diff --git a/crates/lantern-profile/src/validate/references/presets.rs b/crates/lantern-profile/src/validate/references/presets.rs new file mode 100644 index 0000000..220c8d9 --- /dev/null +++ b/crates/lantern-profile/src/validate/references/presets.rs @@ -0,0 +1,106 @@ +use super::super::{helpers::*, *}; + +pub(super) fn validate_presets( + document: &ProfileDocumentV1, + parameters: &BTreeMap, +) -> Result, ProfileError> { + if document.telemetry_presets.len() > MAX_PRESETS { + return Err(ProfileError::validation( + "telemetry_presets", + format!( + "contains {} entries; maximum is {MAX_PRESETS}", + document.telemetry_presets.len() + ), + )); + } + let mut ids = BTreeSet::new(); + document + .telemetry_presets + .iter() + .enumerate() + .map(|(index, preset)| validate_preset(preset, index, parameters, &mut ids)) + .collect() +} + +fn validate_preset( + preset: &TelemetryPresetDocumentV1, + index: usize, + parameters: &BTreeMap, + ids: &mut BTreeSet, +) -> Result { + let base = format!("telemetry_presets[{index}]"); + validate_text(format!("{base}.id"), &preset.id, false)?; + validate_text(format!("{base}.name"), &preset.name, false)?; + if !ids.insert(preset.id.clone()) { + return Err(ProfileError::validation( + format!("{base}.id"), + "duplicate preset ID", + )); + } + if preset.parameters.len() > 8 { + return Err(ProfileError::validation( + format!("{base}.parameters"), + "preset may contain at most 8 channels", + )); + } + let parameters = validate_parameter_references( + &preset.parameters, + parameters, + format!("{base}.parameters"), + )?; + Ok(ValidatedTelemetryPreset { + id: preset.id.clone(), + name: preset.name.clone(), + parameters: parameters.into_boxed_slice(), + }) +} + +pub(super) fn validate_restore_order( + document: &ProfileDocumentV1, + parameters: &BTreeMap, +) -> Result, ProfileError> { + let restore_order = validate_parameter_references( + &document.restore_order, + parameters, + "restore_order".to_owned(), + )?; + for (index, id) in restore_order.iter().enumerate() { + let parameter = ¶meters[id]; + if parameter.access() == ParameterAccess::ReadOnly { + return Err(ProfileError::validation( + format!("restore_order[{index}]"), + "read-only parameter cannot appear in restore order", + )); + } + } + Ok(restore_order) +} + +pub(super) fn validate_parameter_references( + values: &[String], + parameters: &BTreeMap, + path: String, +) -> Result, ProfileError> { + let mut unique = BTreeSet::new(); + values + .iter() + .enumerate() + .map(|(index, value)| { + let id = ParameterId::parse(value.clone()) + .map_err(|error| ProfileError::validation(format!("{path}[{index}]"), error))?; + if !parameters.contains_key(&id) { + return Err(ProfileError::validation( + format!("{path}[{index}]"), + format!("unknown parameter {id}"), + )); + } + if !unique.insert(id.clone()) { + return Err(ProfileError::validation( + format!("{path}[{index}]"), + format!("duplicate parameter {id}"), + )); + } + Ok(id) + }) + .collect() +} diff --git a/crates/lantern-profile/src/validate/references/probes.rs b/crates/lantern-profile/src/validate/references/probes.rs new file mode 100644 index 0000000..873a239 --- /dev/null +++ b/crates/lantern-profile/src/validate/references/probes.rs @@ -0,0 +1,70 @@ +use super::super::{helpers::*, *}; +use super::address::normalize_address; + +pub(super) fn validate_probes( + document: &mut ProfileDocumentV1, + _parameters: &BTreeMap, +) -> Result, ProfileError> { + if document.identification.probes.len() > 64 { + return Err(ProfileError::validation( + "identification.probes", + "maximum is 64 probes", + )); + } + let mut ids = BTreeSet::new(); + let mut probes = Vec::with_capacity(document.identification.probes.len()); + for (index, probe) in document.identification.probes.iter_mut().enumerate() { + let base = format!("identification.probes[{index}]"); + validate_text(format!("{base}.id"), &probe.id, false)?; + validate_text(format!("{base}.description"), &probe.description, false)?; + if !ids.insert(probe.id.clone()) { + return Err(ProfileError::validation( + format!("{base}.id"), + "duplicate probe ID", + )); + } + let table = table(probe.table); + let address = normalize_address(table, &probe.address, format!("{base}.address"))?; + probe.address = AddressDocumentV1 { + notation: AddressNotation::PduZeroBased, + value: u32::from(address.get()), + }; + let count = RegisterCount::new(probe.count) + .map_err(|error| ProfileError::validation(format!("{base}.count"), error))?; + let function = match table { + ModbusTable::InputRegisters => ModbusFunction::ReadInputRegisters, + ModbusTable::HoldingRegisters => ModbusFunction::ReadHoldingRegisters, + }; + let block = RegisterBlock::new(table, address, count, function) + .map_err(|error| ProfileError::validation(format!("{base}.address"), error))?; + if probe.expected_raw.is_empty() || probe.expected_raw.len() > 8 { + return Err(ProfileError::validation( + format!("{base}.expected_raw"), + "expected_raw must contain 1..=8 alternatives", + )); + } + let expected_raw = probe + .expected_raw + .iter() + .enumerate() + .map(|(raw_index, raw)| { + if raw.len() != usize::from(probe.count) { + return Err(ProfileError::validation( + format!("{base}.expected_raw[{raw_index}]"), + format!("expected {} registers, received {}", probe.count, raw.len()), + )); + } + RawRegisters::new(raw.clone()).map_err(|error| { + ProfileError::validation(format!("{base}.expected_raw[{raw_index}]"), error) + }) + }) + .collect::, _>>()?; + probes.push(ValidatedProbe { + id: probe.id.clone(), + description: probe.description.clone(), + block, + expected_raw: expected_raw.into_boxed_slice(), + }); + } + Ok(probes) +} diff --git a/crates/lantern-profile/tests/common/mod.rs b/crates/lantern-profile/tests/common/mod.rs new file mode 100644 index 0000000..47a49e8 --- /dev/null +++ b/crates/lantern-profile/tests/common/mod.rs @@ -0,0 +1,170 @@ +pub const TOML_PROFILE: &str = r#" +schema_version = 1 +profile_id = "example.vfd1000" +revision = 1 +vendor = "Example Devices" +family = "Fictional" +model = "VFD 1000" +sources = ["Fictional manual revision A"] +safety_notes = ["Demonstration profile only"] +restore_order = ["config.acceleration"] + +[protocol] +default_baud_rate = 9600 +allowed_baud_rates = [19200] +default_parity = "none" +allowed_parities = ["even"] +default_data_bits = 8 +default_stop_bits = 1 +response_timeout_ms = 500 +default_slave_id = 1 +rs485_mode = "adapter_managed" + +[[identification.probes]] +id = "model" +description = "Fictional model word" +table = "holding_registers" +count = 1 +expected_raw = [[4096]] +address = { notation = "protocol_one_based", value = 1 } + +[[parameters]] +id = "status.output_frequency" +code = "D1.00" +name = "Output frequency" +table = "holding_registers" +address = { notation = "modicon_5_digit", value = 40002 } +encoding = "unsigned16" +quantity = "frequency" +unit = "hz" +scale = { multiplier = "1.00", divisor = "100", offset = "-0", decimal_places = 2 } + +[[parameters]] +id = "config.acceleration" +code = "D0.01" +name = "Acceleration time" +description = "Fictional writable parameter" +table = "holding_registers" +address = { notation = "pdu_zero_based", value = 10 } +encoding = "unsigned16" +quantity = "time" +unit = "s" +access = "writable_when_stopped" +restore_policy = "normal" +required_drive_state = "stopped" +write_function = "write_single_register" +backup = true +read_back = { kind = "accepted_raw_set", values = [[100], [101]] } +scale = { multiplier = "1", divisor = "10", offset = "0", decimal_places = 1 } + +[aliases] +"status.output_frequency" = "status.output_frequency" + +[[groups]] +id = "status" +name = "Status" +parameters = ["status.output_frequency", "config.acceleration"] + +[fault_source] +kind = "scalar_code" +parameter_id = "config.acceleration" + +[faults."1"] +code = "DEMO.01" +name = "Demonstration fault" +description = "Fictional fault" +severity = "warning" +freeze_frame = ["status.output_frequency"] + +[[telemetry_presets]] +id = "overview" +name = "Overview" +parameters = ["status.output_frequency"] +"#; + +pub const JSON_PROFILE: &str = r#"{ + "schema_version": 1, + "profile_id": "example.vfd1000", + "revision": 1, + "vendor": "Example Devices", + "family": "Fictional", + "model": "VFD 1000", + "sources": ["Fictional manual revision A"], + "safety_notes": ["Demonstration profile only"], + "protocol": { + "default_baud_rate": 9600, + "allowed_baud_rates": [19200], + "default_parity": "none", + "allowed_parities": ["even"], + "default_data_bits": 8, + "allowed_data_bits": [], + "default_stop_bits": 1, + "allowed_stop_bits": [], + "response_timeout_ms": 500, + "minimum_inter_frame_delay_us": 0, + "default_slave_id": 1, + "rs485_mode": "adapter_managed" + }, + "identification": { + "probes": [{ + "id": "model", + "description": "Fictional model word", + "table": "holding_registers", + "address": {"notation": "pdu_zero_based", "value": 0}, + "count": 1, + "expected_raw": [[4096]] + }] + }, + "parameters": [ + { + "id": "config.acceleration", + "code": "D0.01", + "name": "Acceleration time", + "description": "Fictional writable parameter", + "table": "holding_registers", + "address": {"notation": "protocol_one_based", "value": 11}, + "encoding": "unsigned16", + "byte_order": "big_endian", + "word_order": "most_significant_first", + "scale": {"multiplier": "1", "divisor": "10.0", "offset": "0", "decimal_places": 1, "rounding": "midpoint_nearest_even"}, + "quantity": "time", + "unit": "s", + "access": "writable_when_stopped", + "restore_policy": "normal", + "required_drive_state": "stopped", + "write_function": "write_single_register", + "read_back": {"kind": "accepted_raw_set", "values": [[101], [100]]}, + "backup": true, + "do_not_bridge": false, + "maximum_bridge_gap": 0 + }, + { + "id": "status.output_frequency", + "code": "D1.00", + "name": "Output frequency", + "description": "", + "table": "holding_registers", + "address": {"notation": "pdu_zero_based", "value": 1}, + "encoding": "unsigned16", + "byte_order": "big_endian", + "word_order": "most_significant_first", + "scale": {"multiplier": "1", "divisor": "100", "offset": "0", "decimal_places": 2, "rounding": "midpoint_nearest_even"}, + "quantity": "frequency", + "unit": "hz", + "access": "read_only", + "restore_policy": "normal", + "required_drive_state": "any", + "write_function": null, + "read_back": {"kind": "exact_raw"}, + "backup": false, + "do_not_bridge": false, + "maximum_bridge_gap": 0 + } + ], + "aliases": {"status.output_frequency": "status.output_frequency"}, + "groups": [{"id": "status", "name": "Status", "parameters": ["status.output_frequency", "config.acceleration"]}], + "fault_source": {"kind": "scalar_code", "parameter_id": "config.acceleration"}, + "faults": {"1": {"code": "DEMO.01", "name": "Demonstration fault", "description": "Fictional fault", "severity": "warning", "freeze_frame": ["status.output_frequency"]}}, + "telemetry_presets": [{"id": "overview", "name": "Overview", "parameters": ["status.output_frequency"]}], + "restore_order": ["config.acceleration"] +}"#; diff --git a/crates/lantern-profile/tests/profile_hash.rs b/crates/lantern-profile/tests/profile_hash.rs new file mode 100644 index 0000000..2630e8a --- /dev/null +++ b/crates/lantern-profile/tests/profile_hash.rs @@ -0,0 +1,65 @@ +mod common; + +use common::{JSON_PROFILE, TOML_PROFILE}; +use lantern_profile::{ + ProfileFormat, normalize_profile_toml, parse_and_validate_profile, profile_schema_json, +}; + +#[test] +fn equivalent_toml_and_json_have_the_same_semantic_hash() { + let toml = parse_and_validate_profile(TOML_PROFILE.as_bytes(), ProfileFormat::Toml) + .expect("TOML profile"); + let json = parse_and_validate_profile(JSON_PROFILE.as_bytes(), ProfileFormat::Json) + .expect("JSON profile"); + assert_eq!(toml.profile_hash(), json.profile_hash()); + assert_ne!(toml.source_hash(), json.source_hash()); +} + +#[test] +fn normalization_is_idempotent_and_materializes_addresses_and_defaults() { + let first = + parse_and_validate_profile(TOML_PROFILE.as_bytes(), ProfileFormat::Toml).expect("profile"); + let normalized = normalize_profile_toml(&first).expect("normalize"); + assert!(normalized.contains("notation = \"pdu_zero_based\"")); + assert!(normalized.contains("offset = \"0\"")); + let second = parse_and_validate_profile(normalized.as_bytes(), ProfileFormat::Toml) + .expect("normalized profile"); + assert_eq!(first.profile_hash(), second.profile_hash()); + assert_eq!( + normalized, + normalize_profile_toml(&second).expect("normalize twice") + ); +} + +#[test] +fn ordering_of_semantic_sets_is_normalized_but_restore_order_is_significant() { + let profile = + parse_and_validate_profile(JSON_PROFILE.as_bytes(), ProfileFormat::Json).expect("profile"); + + let mut reordered: serde_json::Value = + serde_json::from_str(JSON_PROFILE).expect("JSON fixture"); + reordered["parameters"] + .as_array_mut() + .expect("parameters") + .reverse(); + let reordered = serde_json::to_vec(&reordered).expect("serialize reordered fixture"); + let reordered = + parse_and_validate_profile(&reordered, ProfileFormat::Json).expect("reordered profile"); + assert_eq!(profile.profile_hash(), reordered.profile_hash()); + + let mut changed_restore: serde_json::Value = + serde_json::from_str(JSON_PROFILE).expect("JSON fixture"); + changed_restore["restore_order"] = serde_json::json!([]); + let changed_restore = serde_json::to_vec(&changed_restore).expect("serialize changed fixture"); + let changed = parse_and_validate_profile(&changed_restore, ProfileFormat::Json) + .expect("changed restore order"); + assert_ne!(profile.profile_hash(), changed.profile_hash()); +} + +#[test] +fn schema_is_generated_from_parser_types() { + let schema = profile_schema_json().expect("schema"); + assert!(schema.contains("schema_version")); + assert!(schema.contains("modicon_6_digit")); + assert!(schema.contains("float_abs_rel_tolerance")); +} diff --git a/crates/lantern-profile/tests/profile_validation.rs b/crates/lantern-profile/tests/profile_validation.rs new file mode 100644 index 0000000..1d86c59 --- /dev/null +++ b/crates/lantern-profile/tests/profile_validation.rs @@ -0,0 +1,67 @@ +mod common; + +use common::{JSON_PROFILE, TOML_PROFILE}; +use lantern_profile::{ + AddressNotation, MAX_PROFILE_BYTES, ProfileError, ProfileFormat, normalize_profile_toml, + parse_and_validate_profile, +}; + +#[test] +fn unknown_fields_and_invalid_references_are_rejected_with_paths() { + assert!(parse_and_validate_profile(JSON_PROFILE.as_bytes(), ProfileFormat::Json).is_ok()); + + let unknown = TOML_PROFILE.replace( + "schema_version = 1", + "schema_version = 1\nunknown_switch = true", + ); + assert!(matches!( + parse_and_validate_profile(unknown.as_bytes(), ProfileFormat::Toml), + Err(ProfileError::Deserialize { .. }) + )); + + let invalid = TOML_PROFILE.replace( + "\"status.output_frequency\" = \"status.output_frequency\"", + "\"status.output_frequency\" = \"missing.parameter\"", + ); + let error = parse_and_validate_profile(invalid.as_bytes(), ProfileFormat::Toml) + .expect_err("invalid reference"); + assert!( + error + .to_string() + .contains("aliases.status.output_frequency") + ); +} + +#[test] +fn all_address_notations_reach_the_same_pdu_address() { + for (notation, value) in [ + (AddressNotation::PduZeroBased, 1_u32), + (AddressNotation::ProtocolOneBased, 2), + (AddressNotation::Modicon5Digit, 40_002), + (AddressNotation::Modicon6Digit, 400_002), + ] { + let notation = match notation { + AddressNotation::PduZeroBased => "pdu_zero_based", + AddressNotation::ProtocolOneBased => "protocol_one_based", + AddressNotation::Modicon5Digit => "modicon_5_digit", + AddressNotation::Modicon6Digit => "modicon_6_digit", + }; + let changed = TOML_PROFILE.replace( + "address = { notation = \"modicon_5_digit\", value = 40002 }", + &format!("address = {{ notation = \"{notation}\", value = {value} }}"), + ); + let profile = parse_and_validate_profile(changed.as_bytes(), ProfileFormat::Toml) + .expect("address notation"); + let normalized = normalize_profile_toml(&profile).expect("normalize"); + assert!(normalized.contains("value = 1")); + } +} + +#[test] +fn input_limit_is_checked_before_deserialization() { + let source = vec![b' '; MAX_PROFILE_BYTES + 1]; + assert!(matches!( + parse_and_validate_profile(&source, ProfileFormat::Toml), + Err(ProfileError::SourceTooLarge { .. }) + )); +} diff --git a/crates/lantern-sim/Cargo.toml b/crates/lantern-sim/Cargo.toml new file mode 100644 index 0000000..b594a02 --- /dev/null +++ b/crates/lantern-sim/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lantern-sim" +description = "Development-only VFD simulator harness" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +lantern-domain.workspace = true +lantern-profile.workspace = true +lantern-app.workspace = true +lantern-transport.workspace = true + +[lints] +workspace = true diff --git a/crates/lantern-sim/src/lib.rs b/crates/lantern-sim/src/lib.rs new file mode 100644 index 0000000..2832f4e --- /dev/null +++ b/crates/lantern-sim/src/lib.rs @@ -0,0 +1,9 @@ +//! Development-only simulator boundary. + +#![forbid(unsafe_code)] + +/// Reports that the simulator crate is linked only by development tooling. +#[must_use] +pub const fn is_development_only() -> bool { + true +} diff --git a/crates/lantern-storage/Cargo.toml b/crates/lantern-storage/Cargo.toml new file mode 100644 index 0000000..d9e807a --- /dev/null +++ b/crates/lantern-storage/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "lantern-storage" +description = "Filesystem adapters for VFD Lantern application ports" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +lantern-app.workspace = true +lantern-domain.workspace = true + +[lints] +workspace = true diff --git a/crates/lantern-storage/src/lib.rs b/crates/lantern-storage/src/lib.rs new file mode 100644 index 0000000..d763d5e --- /dev/null +++ b/crates/lantern-storage/src/lib.rs @@ -0,0 +1,21 @@ +//! Filesystem adapter implementations. + +#![forbid(unsafe_code)] + +use lantern_app::{ArtifactStoragePort, ProfileSourcePort}; + +/// Filesystem-backed application adapter placeholder. +#[derive(Clone, Copy, Debug, Default)] +pub struct FileStorage; + +impl ArtifactStoragePort for FileStorage { + fn storage_name(&self) -> &'static str { + "filesystem" + } +} + +impl ProfileSourcePort for FileStorage { + fn source_name(&self) -> &'static str { + "filesystem" + } +} diff --git a/crates/lantern-transport/Cargo.toml b/crates/lantern-transport/Cargo.toml new file mode 100644 index 0000000..00edcc8 --- /dev/null +++ b/crates/lantern-transport/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "lantern-transport" +description = "Serial and Modbus transport adapters for VFD Lantern" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +lantern-app.workspace = true +lantern-domain.workspace = true + +[lints] +workspace = true diff --git a/crates/lantern-transport/src/lib.rs b/crates/lantern-transport/src/lib.rs new file mode 100644 index 0000000..4579879 --- /dev/null +++ b/crates/lantern-transport/src/lib.rs @@ -0,0 +1,27 @@ +//! Hardware transport adapter implementations. + +#![forbid(unsafe_code)] + +use lantern_app::{PortDiscoveryPort, ReadBusPort, WriteBusPort}; + +/// Placeholder adapter; real serial ownership is introduced by the transport issues. +#[derive(Clone, Copy, Debug, Default)] +pub struct TransportAdapter; + +impl ReadBusPort for TransportAdapter { + fn adapter_name(&self) -> &'static str { + "serial-modbus" + } +} + +impl WriteBusPort for TransportAdapter { + fn adapter_name(&self) -> &'static str { + "serial-modbus" + } +} + +impl PortDiscoveryPort for TransportAdapter { + fn known_port_count(&self) -> usize { + 0 + } +} diff --git a/crates/lantern-tui/Cargo.toml b/crates/lantern-tui/Cargo.toml new file mode 100644 index 0000000..ddb69bb --- /dev/null +++ b/crates/lantern-tui/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lantern-tui" +description = "Ratatui presentation adapter for VFD Lantern" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +lantern-app.workspace = true + +[lints] +workspace = true diff --git a/crates/lantern-tui/src/lib.rs b/crates/lantern-tui/src/lib.rs new file mode 100644 index 0000000..be67bba --- /dev/null +++ b/crates/lantern-tui/src/lib.rs @@ -0,0 +1,21 @@ +//! Presentation-only state and rendering boundary. + +#![forbid(unsafe_code)] + +use lantern_app::ApplicationView; + +/// State that affects presentation only. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UiState { + /// Currently selected top-level view. + pub selected_view: usize, + /// Current vertical scroll offset. + pub scroll_offset: usize, +} + +/// Builds a minimal text representation without accessing adapters. +#[must_use] +pub fn render_status(view: &ApplicationView, ui: &UiState) -> String { + let profile = view.active_profile_id().unwrap_or("none"); + format!("profile={profile}; view={}", ui.selected_view) +} diff --git a/crates/vfd-lantern/Cargo.toml b/crates/vfd-lantern/Cargo.toml new file mode 100644 index 0000000..cf1db4c --- /dev/null +++ b/crates/vfd-lantern/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vfd-lantern" +description = "Universal VFD diagnostics, monitoring and configuration TUI for Linux" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish = false + +[dependencies] +lantern-domain.workspace = true +lantern-profile.workspace = true +lantern-app.workspace = true +lantern-storage.workspace = true +lantern-transport.workspace = true +lantern-tui.workspace = true + +[lints] +workspace = true diff --git a/crates/vfd-lantern/src/main.rs b/crates/vfd-lantern/src/main.rs new file mode 100644 index 0000000..e2deae3 --- /dev/null +++ b/crates/vfd-lantern/src/main.rs @@ -0,0 +1,22 @@ +//! VFD Lantern composition root. + +#![forbid(unsafe_code)] + +use lantern_app::{ApplicationState, ArtifactStoragePort, ReadBusPort}; +use lantern_storage::FileStorage; +use lantern_transport::TransportAdapter; +use lantern_tui::UiState; + +fn main() { + let storage = FileStorage; + let transport = TransportAdapter; + let application = ApplicationState::default(); + let ui = UiState::default(); + + println!("VFD Lantern {}", env!("CARGO_PKG_VERSION")); + println!("Status: modular-monolith bootstrap"); + println!("Storage adapter: {}", storage.storage_name()); + println!("Transport adapter: {}", transport.adapter_name()); + println!("{}", lantern_tui::render_status(&application.view(), &ui)); + println!("No serial connection is attempted by this bootstrap build."); +} diff --git a/docs/adr/0001-modular-monolith.md b/docs/adr/0001-modular-monolith.md new file mode 100644 index 0000000..36bb058 --- /dev/null +++ b/docs/adr/0001-modular-monolith.md @@ -0,0 +1,41 @@ +# ADR 0001: Modular monolith and dependency inversion + +- Status: accepted +- Scope: VFD Lantern 1.0 + +## Decision + +VFD Lantern is one process and one production binary assembled by `vfd-lantern`. +Application policy lives in `lantern-app`. The application crate defines narrow outbound +ports; `lantern-storage` and `lantern-transport` implement them. Presentation lives in +`lantern-tui` and receives read-only view models. `lantern-sim` is development-only. + +The allowed dependency direction is: + +```text +lantern-domain <- lantern-profile <- lantern-app + ^ ^ ^ + | | | + storage transport tui + \ | / + vfd-lantern + +lantern-sim -> domain + profile + app + transport +``` + +## Sources of truth and authorities + +- Profile: `ValidatedDeviceProfile`. +- Application state: `ApplicationState`. +- Presentation state: `UiState`. +- Profile registry: `ProfileRegistry`. +- Polling policy: `PollPlanner`. +- Bus authority: one future `BusActor`. +- Write authority: one future `WriteCoordinator`. +- File ownership: storage adapters. + +## Consequences + +There are no runtime plugins, scripting engines, service locators, global mutable +singletons, or adapter imports in the TUI. The production binary is the only composition +root. Every new dependency edge must remain acyclic and is checked in CI. diff --git a/docs/development/toolchain.md b/docs/development/toolchain.md new file mode 100644 index 0000000..bd2f24e --- /dev/null +++ b/docs/development/toolchain.md @@ -0,0 +1,18 @@ +# Pinned development toolchain + +VFD Lantern targets Debian 13 (Trixie) on amd64 and arm64. Install `rustup` from +APT, then let `rust-toolchain.toml` select Rust 1.97.1. Do not use `curl | sh`. + +```sh +sudo apt-get update +sudo apt-get install --yes build-essential ca-certificates git libudev-dev pkg-config rustup +rustup toolchain install 1.97.1 --profile minimal \ + --component rustfmt --component clippy --component llvm-tools-preview +cargo build --workspace --locked +cargo test --workspace --all-features --locked +``` + +Direct crate versions are centralized in `[workspace.dependencies]`. Binary tool +versions are centralized in `tools.lock.toml`. Updates require a dedicated change +with a refreshed lockfile and the full CI suite. Supply-chain verification uses +`cargo vet check`, not the non-existent `cargo vet verify` command. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..f485a69 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +profile = "minimal" +components = ["rustfmt", "clippy", "llvm-tools-preview"] diff --git a/scripts/check-architecture.sh b/scripts/check-architecture.sh new file mode 100644 index 0000000..d87fbf6 --- /dev/null +++ b/scripts/check-architecture.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +assert_absent() { + package="$1" + forbidden="$2" + tree="$(cargo tree --locked -p "$package" --edges normal --prefix none)" + if printf '%s\n' "$tree" | grep -Eq "^(${forbidden})( |$)"; then + printf 'forbidden dependency in %s: %s\n' "$package" "$forbidden" >&2 + exit 1 + fi +} + +assert_absent lantern-domain 'tokio|serde|ratatui|lantern-app|lantern-storage|lantern-transport|lantern-tui' +assert_absent lantern-app 'lantern-storage|lantern-transport|lantern-tui|lantern-sim' +assert_absent lantern-tui 'lantern-storage|lantern-transport|lantern-sim' +assert_absent vfd-lantern 'lantern-sim' + +cargo metadata --locked --no-deps --format-version 1 >/dev/null +printf 'architecture checks passed\n' diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index ac087fd..0000000 --- a/src/config.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::{collections::BTreeMap, fs, path::Path}; - -use anyhow::{bail, Context, Result}; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct DeviceProfile { - pub profile_version: u32, - pub vendor: String, - pub model: String, - pub protocol: Protocol, - #[serde(default)] - pub aliases: BTreeMap, - #[serde(default)] - pub scaling: BTreeMap, - #[serde(default)] - pub faults: BTreeMap, - #[serde(default)] - pub parameter_groups: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct Protocol { - pub default_baud_rate: u32, - pub default_parity: String, - pub register_type: String, -} - -#[derive(Debug, Deserialize)] -pub struct FaultDefinition { - pub code: String, - pub name: String, - pub description: String, -} - -#[derive(Debug, Deserialize)] -pub struct ParameterGroup { - pub group_name: String, - pub base_address: String, - #[serde(default)] - pub registers: BTreeMap, -} - -#[derive(Debug, Deserialize)] -pub struct ParameterDefinition { - pub code: String, - pub name: String, - pub min: i64, - pub max: i64, - pub unit: Option, - pub scale: Option, -} - -impl DeviceProfile { - pub fn load(path: &Path) -> Result { - let source = fs::read_to_string(path) - .with_context(|| format!("failed to read profile {}", path.display()))?; - - match path.extension().and_then(|extension| extension.to_str()) { - Some("json") => serde_json::from_str(&source) - .with_context(|| format!("invalid JSON profile {}", path.display())), - Some("toml") => toml::from_str(&source) - .with_context(|| format!("invalid TOML profile {}", path.display())), - _ => bail!("profile must use a .json or .toml extension"), - } - } -} - -#[cfg(test)] -mod tests { - use super::DeviceProfile; - - #[test] - fn parses_minimal_json_profile() { - let source = r#" - { - "profile_version": 1, - "vendor": "Example", - "model": "Demo", - "protocol": { - "default_baud_rate": 9600, - "default_parity": "none", - "register_type": "holding" - } - } - "#; - - let profile: DeviceProfile = serde_json::from_str(source).expect("profile should parse"); - - assert_eq!(profile.profile_version, 1); - assert_eq!(profile.vendor, "Example"); - assert!(profile.aliases.is_empty()); - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index c921dc5..0000000 --- a/src/main.rs +++ /dev/null @@ -1,45 +0,0 @@ -mod config; - -use std::path::PathBuf; - -use anyhow::Result; -use clap::Parser; -use config::DeviceProfile; - -#[derive(Debug, Parser)] -#[command( - name = "vfd-lantern", - version, - about = "Universal VFD diagnostics and scope TUI for Linux" -)] -struct Cli { - /// Device profile to validate and load. - #[arg(long, value_name = "FILE")] - profile: Option, - - /// Explicitly enable commands that may write to a connected drive. - #[arg(long)] - enable_writes: bool, -} - -#[tokio::main] -async fn main() -> Result<()> { - let cli = Cli::parse(); - let read_only = !cli.enable_writes; - - println!("VFD Lantern {}", env!("CARGO_PKG_VERSION")); - println!("Status: pre-alpha bootstrap"); - println!("Read-only mode: {read_only}"); - - if let Some(profile_path) = cli.profile { - let profile = DeviceProfile::load(&profile_path)?; - - println!("Profile: {} {}", profile.vendor, profile.model); - println!("Profile format version: {}", profile.profile_version); - println!("Aliases: {}", profile.aliases.len()); - } - - println!("No serial connection is attempted by this bootstrap build."); - - Ok(()) -} diff --git a/tools.lock.toml b/tools.lock.toml new file mode 100644 index 0000000..1484134 --- /dev/null +++ b/tools.lock.toml @@ -0,0 +1,22 @@ +schema_version = 1 +rust_toolchain = "1.97.1" + +[tools] +cargo-nextest = "0.9.118" +cargo-hack = "0.6.39" +cargo-machete = "0.8.0" +cargo-deny = "0.18.5" +cargo-audit = "0.21.2" +cargo-vet = "0.10.2" +cargo-cyclonedx = "0.5.7" +cargo-about = "0.8.4" +cargo-auditable = "0.7.2" +cargo-llvm-cov = "0.6.21" +cargo-mutants = "26.0.1" +cargo-fuzz = "0.13.1" +cargo-dist = "0.30.3" +cargo-deb = "3.6.2" +mdbook = "0.4.52" + +[commands] +cargo-vet = "cargo vet check"