diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index 941c8a099..000000000 --- a/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Bug -about: Notify that an error has ben found on the app and what happened. -title: '' -labels: Bug -assignees: '' - ---- - -**Bug Description** -What is happening? - -**Steps to reproduce** -How do I replicate the issue, what where you doing? - -**Screenshots** -Provide screenshots, videos, links, etc. - -**Context** -What is your environment? - -Fill the following information and add any relevant information: -*OS*: Windows, Mac, Linux, Android, iOS... -*Browser*: Chrome, Opera, Firefox, Safari... -*App Version*: v1.0.0 - -**Notes** -Extra information relevant to the issue. diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md deleted file mode 100644 index 6aee6e0d5..000000000 --- a/.github/ISSUE_TEMPLATE/feature.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Feature -about: Propose a feature to be added to the application. -title: '' -labels: Feature -assignees: '' - ---- - -**Feature description** -What is the feature supposed to do, what will it include? - -**Possible solutions** -What are the possible ways to implement this feature that you can think of? - -**Notes** -What context / extra information is required to understand the proposal? diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md deleted file mode 100644 index 8a717356d..000000000 --- a/.github/ISSUE_TEMPLATE/task.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Task -about: Tasks are units of work assigned to members to be completed. -title: '' -labels: Task -assignees: '' - ---- - -**Description** -Brief description of the task and important details - -**Related** -Related Issues, PRs, Documents, etc. - -**Notes** -Extra information that might be good to know about it diff --git a/.github/workflows/rust-tests.yaml b/.github/workflows/rust-tests.yaml new file mode 100644 index 000000000..6ab44a9ce --- /dev/null +++ b/.github/workflows/rust-tests.yaml @@ -0,0 +1,30 @@ +name: Rust Tests + +on: + pull_request: + paths: + - "backend/**" + - ".github/workflows/rust-tests.yaml" + +jobs: + test: + name: Test (${{ matrix.crate }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + crate: [software-core, station] + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: backend + + - name: Run tests + working-directory: backend + run: cargo test --package ${{ matrix.crate }} diff --git a/.gitignore b/.gitignore index bc66fec72..35fee63ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ # Monorepo / IDE node_modules/ .turbo/ -.vscode/ +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json +!.vscode/launch.json +!.vscode/tasks.json .DS_Store *.code-workspace diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..37006f2d8 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "rust-lang.rust-analyzer", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "ms-python.python", + "tamasfe.even-better-toml" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..617eff252 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug Backend (Rust)", + "program": "${workspaceFolder}/backend/target/debug/station", + "args": [], + "cwd": "${workspaceFolder}/backend/crates/station", + "preLaunchTask": "Build Backend (Rust)", + "sourceLanguages": ["rust"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 9ef398bb3..6f381d6a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -50,5 +50,33 @@ "editor.codeActionsOnSave": { "source.fixAll.rust-analyzer": "explicit" } + }, + "rust-analyzer.linkedProjects": ["backend/Cargo.toml"], + "rust-analyzer.check.command": "clippy", + "rust-analyzer.check.extraArgs": ["--all-targets"], + + // ESLint: each frontend/* package has its own flat eslint.config.js + "eslint.workingDirectories": [{ "mode": "auto" }], + + // Python: blcu-programming is the only Python project, venv created by + // its postinstall script (blcu-programming/scripts/setup.mjs) + "python.defaultInterpreterPath": "${workspaceFolder}/blcu-programming/.venv/bin/python", + + // Keep VS Code from indexing/watching generated and vendored trees + "files.watcherExclude": { + "**/node_modules/**": true, + "**/target/**": true, + "**/dist/**": true, + "**/.venv/**": true, + "**/.venv-build/**": true, + "**/.turbo/**": true + }, + "search.exclude": { + "**/node_modules": true, + "**/target": true, + "**/dist": true, + "**/.venv": true, + "**/.venv-build": true, + "**/.turbo": true } } diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..10489440d --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,187 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Testing View", + "type": "shell", + "command": "pnpm", + "args": ["dev", "--filter", "testing-view"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Run Competition View", + "type": "shell", + "command": "pnpm", + "args": ["dev", "--filter", "competition-view"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Run Flashing View", + "type": "shell", + "command": "pnpm", + "args": ["dev", "--filter", "flashing-view"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Run Logging View", + "type": "shell", + "command": "pnpm", + "args": ["dev", "--filter", "logging-view"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Run ADJ View", + "type": "shell", + "command": "pnpm", + "args": ["dev", "--filter", "adj-view"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Run Backend (Rust)", + "type": "shell", + "command": "cargo", + "args": ["run", "--package", "station"], + "options": { + "cwd": "${workspaceFolder}/backend" + }, + "problemMatcher": ["$rustc"], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Run Python (BLCU Programming)", + "type": "shell", + "command": "pnpm", + "args": ["dev", "--filter", "blcu-programming"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Build App", + "type": "shell", + "command": "pnpm", + "args": ["run", "build"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "group": "build" + }, + { + "label": "Build Backend (Rust)", + "type": "shell", + "command": "cargo", + "args": ["build", "--package", "station"], + "options": { + "cwd": "${workspaceFolder}/backend" + }, + "problemMatcher": ["$rustc"], + "group": "build" + }, + { + "label": "Test App", + "type": "shell", + "command": "pnpm", + "args": ["run", "test"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "group": "test" + }, + { + "label": "Test Backend (Rust)", + "type": "shell", + "command": "cargo test ${input:rustTestScope} ${input:rustTestFilter}", + "options": { + "cwd": "${workspaceFolder}/backend" + }, + "problemMatcher": ["$rustc"], + "group": "test" + }, + { + "label": "Clippy Backend (Rust)", + "type": "shell", + "command": "cargo", + "args": ["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"], + "options": { + "cwd": "${workspaceFolder}/backend" + }, + "problemMatcher": ["$rustc"], + "group": "test" + }, + { + "label": "Format Backend (Rust)", + "type": "shell", + "command": "cargo", + "args": ["fmt", "--all"], + "options": { + "cwd": "${workspaceFolder}/backend" + }, + "problemMatcher": [], + "group": "none" + } + ], + "inputs": [ + { + "id": "rustTestScope", + "type": "pickString", + "description": "¿Qué crate del backend querés testear?", + "options": [ + { "label": "Todo el workspace", "value": "--workspace" }, + { "label": "software-core", "value": "-p software-core" }, + { "label": "station", "value": "-p station" } + ], + "default": "--workspace" + }, + { + "id": "rustTestFilter", + "type": "promptString", + "description": "Filtro de test/módulo dentro del crate elegido (ej. adj::tests, vacío = todos)", + "default": "" + } + ] +} diff --git a/backend/.gitignore b/backend/.gitignore index 384729b1f..d51409096 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,22 +1,7 @@ -log -trace.json - +/target /bin -# Test coverage -*testfile - -# Build artifacts -/bin/ -backend -trace.json - -# Test data -*.out -c.out - -# Logs -cmd/logger/ -cmd/cmd -cmd/adj -/logger/ \ No newline at end of file +# Backend runtime output +/logger/ +*.log +*.csv diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 000000000..5f25ff681 --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,629 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[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 = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "ntp_usg-proto" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31316ff46fc87e03a95e55b93525a27feecfd86fab094ca887a773395f13f50a" +dependencies = [ + "byteorder", + "log", +] + +[[package]] +name = "ntp_usg-server" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0002bd55912864af88a46077abd71db2c4cda88887b46b19c31e1e9a1f289e4e" +dependencies = [ + "ntp_usg-proto", + "tokio", + "tracing", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[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 = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[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.6", +] + +[[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_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "software-core" +version = "0.1.0" +dependencies = [ + "bytes", + "ntp_usg-server", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "station" +version = "0.1.0" +dependencies = [ + "serde", + "software-core", + "thiserror", + "tokio", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +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 1.0.4", +] + +[[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 = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "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 = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 000000000..d14dd7807 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,45 @@ +[workspace] +resolver = "3" +members = ["crates/core", "crates/station"] +default-members = ["crates/station"] + +[workspace.package] +edition = "2024" +rust-version = "1.98" +license = "MIT" +authors = [ + "Hyperloop UPV Software Subsystem", + "Javier Ribal del Río", + "Gazi Halawani", + "Pau Minguet Micó" +] +repository = "https://github.com/Hyperloop-UPV/software" + +# Shared dependency versions. Members opt in with `dep.workspace = true`. +[workspace.dependencies] +software-core = { path = "crates/core" } + +bytes = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "signal"] } +toml = "0.9" +tracing = "0.1" +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } + +[workspace.lints.rust] +unsafe_code = "forbid" +missing_debug_implementations = "warn" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +unwrap_used = "warn" +expect_used = "warn" +panic = "warn" + +[profile.release] +lto = "thin" +codegen-units = 1 +panic = "abort" diff --git a/backend/crates/core/Cargo.toml b/backend/crates/core/Cargo.toml new file mode 100644 index 000000000..e9eabc177 --- /dev/null +++ b/backend/crates/core/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "software-core" +version = "0.1.0" +description = "Generic Hyperloop UPV building blocks: ADJ model, packet protocol and transport" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[features] +default = [] +# NTP server +ntp = ["dep:tokio", "dep:ntp_usg-server", "dep:tracing-subscriber"] +# TCP/UDP transport (pulls in tokio) +net = ["dep:tokio"] +# ADJ download through git (dependency added when the module is written) +git = [] +# Packet sniffing through pcap (dependency added when the module is written) +pcap = ["net"] +# CSV data logger (records, sessions, subloggers) +logger = [] +# Reusable tracing/diagnostics setup (console + non-blocking file + stderr layers) +trace = ["dep:tracing-subscriber", "dep:tracing-appender"] + +[dependencies] +bytes.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +tokio = { workspace = true, optional = true, features = ["macros", "rt-multi-thread"] } +tracing-subscriber = { workspace = true, optional = true , features = ["env-filter", "fmt"]} +tracing-appender = { workspace = true, optional = true } +ntp_usg-server = { version = "5.0.0", optional = true, features = ["ipv4", "tokio"] } + +# `workspace = true` can't be combined with extra lints in the same table, +# so the workspace list is repeated here with `missing_docs` added: this is +# the crate meant to be reused by other projects, so its public API must be +# documented. +[lints.rust] +unsafe_code = "forbid" +missing_debug_implementations = "warn" +missing_docs = "warn" + +[lints.clippy] +all = { level = "warn", priority = -1 } +unwrap_used = "warn" +expect_used = "warn" +panic = "warn" diff --git a/backend/crates/core/README.md b/backend/crates/core/README.md new file mode 100644 index 000000000..22ced735d --- /dev/null +++ b/backend/crates/core/README.md @@ -0,0 +1,32 @@ +# software-core + +Piezas genéricas y reutilizables de Hyperloop UPV: modelo de datos, protocolo de +paquetes, lectura del ADJ y transporte de red. No depende de nada propio del +backend (`station`), así que también sirve de base para otras herramientas +como un sniffer o un simulador. + +Ver [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) para el diseño completo. + +## Módulos + +- `model` — identificadores, paquetes, valores, medidas y unidades. Siempre disponible. +- `protocol` — traduce bytes a paquetes y paquetes a bytes. Siempre disponible. +- `adj` — lee el ADJ y construye los descriptores para `protocol`. +- `net` *(feature `net`)* — conexiones TCP y UDP. +- `logger` *(feature `logger`)* — registro de datos en CSV. +- `trace` *(feature `trace`)* — configuración de trazas de diagnóstico. + +## Features + +```toml +software-core = { path = "...", features = ["net", "adj"] } +``` + +Por defecto no se activa ninguna: quien use el crate paga solo por lo que +necesita. Ver la tabla completa en `ARCHITECTURE.md`, apartado 12. + +## Ejemplo + +```rust +// Pendiente: añadir cuando exista el primer decodificador público. +``` diff --git a/backend/crates/core/src/adj.rs b/backend/crates/core/src/adj.rs new file mode 100644 index 000000000..839f4fa4a --- /dev/null +++ b/backend/crates/core/src/adj.rs @@ -0,0 +1,33 @@ +//! Parses an ADJ (Architecture Description JSON) directory into +//! [`crate::model::PodData`], plus backend-session metadata not modeled as +//! vehicle data ([`crate::adj::AdjInfo`]). +//! +//! Loading (and if `git` feature is enabled also downloading) is not specific to `station`: a sniffer, a +//! simulator, or any other tool that reads an ADJ needs exactly the same +//! parsing. Scope for this iteration: no `sockets.json`, no +//! `period`/`period_type`/`socket` packet fields (matches +//! [`crate::model::PacketDef`]'s own omission), and no schema validation +//! (`adj::validate` is a separate, deliberately out-of-scope concern for +//! now) — this module only surfaces structural errors it cannot avoid, +//! e.g. a packet referencing a measurement alias that doesn't exist. + +mod error; +mod info; +mod raw; + +pub use error::{LoadError, MeasurementError, ProtectionError}; +pub use info::{AdjInfo, ProtectionTypeInfo}; +pub use raw::load_from_dir; + +use crate::model::PodData; + +/// The result of loading an ADJ: the vehicle's data (as [`crate::model`] +/// describes it) plus backend-session metadata. Built by +/// [`raw::load_from_dir`]. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Adj { + /// Every board and packet defined by the ADJ. + pub pod_data: PodData, + /// Backend-session metadata from `general_info.json`. + pub info: AdjInfo, +} diff --git a/backend/crates/core/src/adj/README.md b/backend/crates/core/src/adj/README.md new file mode 100644 index 000000000..c167bdfbc --- /dev/null +++ b/backend/crates/core/src/adj/README.md @@ -0,0 +1,92 @@ +# ADJ Module (`adj`) + +This module deserializes, validates, and loads ADJ JSON fixtures into the backend domain model. + +For schema specifications, packet/measurement ID conventions, and network requirements, refer directly to the official documentation in the **ADJ repository**. + +--- + +## The `Adj` Object Structure + +Calling `load_from_dir` produces a root `Adj` instance: + +```rust +pub struct Adj { + pub pod_data: PodData, + pub info: AdjInfo, +} +``` + +```text +Adj +├── pod_data (PodData) +│ └── boards: HashMap +│ ├── measurements: HashMap +│ └── packets: HashMap +│ ├── explicit (Data, Order, Message — declared in packets.json; +│ │ a "message"-type packet's own `name` must be one of +│ │ MessageLevel::ALL's four severities, resolved via +│ │ MessageLevel::from_name — UnknownMessageSeverity if not) +│ └── synthetic (Protection — one per declared protection, +│ derived from each measurement's own `protections`; +│ never declared directly in packets.json) +└── info (AdjInfo) + ├── ports: HashMap + ├── addresses: HashMap + ├── message_ids: HashMap -- name -> id, straight from the ADJ + ├── message_levels: HashMap -- id -> severity, the reverse + └── protection_types: HashMap +``` + +`message_ids` is the ADJ's own direction: a name (e.g. `"fault"`) to its +id. Not every entry is one of `model::MessageLevel`'s four severities — +other reserved names (e.g. `add_state_order`) share this same map. + +`message_levels` is the direction decoding actually needs: a wire id +(all a decoder ever has) straight to its severity, with no name in +between. It's the reverse of `message_ids`, restricted to whichever of +`MessageLevel::ALL`'s four names `message_ids` declares — built once in +`adj::raw::types`'s `TryFrom for AdjInfo`, right after +`message_ids` itself, so it can never drift out of sync with it: + +```rust +let message_levels: HashMap = MessageLevel::ALL + .into_iter() + .filter_map(|level| message_ids.get(level.name()).map(|&id| (id, level))) + .collect(); +``` + +Which ADJ name corresponds to which `MessageLevel` is **not** decided +here — `level.name()`/`MessageLevel::from_name` (see `model/README.md` +§4.4) is the one place that association is spelled out, so `adj` +doesn't hardcode `"fault"`/`"warning"`/`"info"`/`"panic"` itself. + +**One layer up, `protocol::Index` is what actually turns +`message_levels` into something a decoder can look up a packet by.** +`Index::new(pod_data, message_levels)` takes `message_levels` as +`Option<&HashMap>` — `None` for callers with no +`AdjInfo` at all (most tests in this crate, which only build a bare +`PodData`) — and, for each entry, synthesizes a +`PacketDef { packet_type: PacketType::Message(level), board: BoardId(0), .. }` +into the very same `AdjId -> PacketDef` table every other packet lives +in. `BoardId(0)` (reserved, not validated — see `AdjId`/`BoardId`'s own +docs) because a severity isn't owned by any one board — any board's +free-text log frame can reuse the same `"fault"` id. See +`protocol/README.md`'s `index.rs` section for the full picture. + +--- + +## Why It Is Structured This Way + +The `Adj` object enforces a clear architectural boundary between **vehicle topology** and **session configuration**: + +### 1. Separation of Concerns (`PodData` vs. `AdjInfo`) +* **`pod_data` (Vehicle Domain)**: Represents telemetry, controls, and board topology intrinsic to the physical vehicle. It contains only what exists on the vehicle bus (boards, packets, measurements). +* **`info` (Session & Tooling Domain)**: Captures session-level networking and station UI requirements (host addresses, local server port bindings, and alert format strings like `text` and `textTime`). +* **Design rationale**: The vehicle itself does not know or care about backend socket configurations or display strings. Separating them prevents UI and orchestration details from polluting the core telemetry model. + +### 2. Standalone Loader Submodule (`raw`) +The raw deserialization types and the loader logic live isolated inside `raw`[cite: 2]. This enables non-station tools (such as bus sniffers, test runners, or simulators) to parse and ingest ADJ directories without dragging along application-level UI or station state logic. + +### 3. Unified Packet Map (`packets`) +Protections defined under measurements are materialized during loading as first-class `PacketType::Protection(Measurement)` packets alongside the explicit data, order and message packets declared in `packets.json`. Downstream networking layers can route and handle every wire event uniformly via `Board::packets` without needing specialized traversal logic for nested measurement alarms. `packets.json` itself never declares a `"type": "protection"` entry directly — that shape only exists synthesized, one per protection a measurement declares, never as a free-standing declaration with its own arbitrary variable list. \ No newline at end of file diff --git a/backend/crates/core/src/adj/error.rs b/backend/crates/core/src/adj/error.rs new file mode 100644 index 000000000..53fcf1bed --- /dev/null +++ b/backend/crates/core/src/adj/error.rs @@ -0,0 +1,175 @@ +//! Errors produced while loading an ADJ. +//! +//! Layered on purpose: [`LoadError`] is the only public error type here — +//! the one a composition root's own aggregate error would `#[from]`-chain +//! into — while [`MeasurementError`] and [`ProtectionError`] stay +//! crate-private. Nothing outside this crate needs to match past +//! `LoadError` into them; `.source()` still returns `&dyn Error` for +//! logging regardless of the concrete type's visibility. + +use std::net::AddrParseError; +use std::path::PathBuf; + +/// An error produced while loading an ADJ directory into an [`crate::adj::Adj`]. +#[derive(Debug, thiserror::Error)] +pub enum LoadError { + /// Couldn't read a file. + #[error("couldn't read {path:?}")] + ReadFile { + /// The file that couldn't be read. + path: PathBuf, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, + + /// A file's contents weren't valid JSON for the shape expected there. + #[error("couldn't parse {path:?} as JSON")] + ParseJson { + /// The file that failed to parse. + path: PathBuf, + /// The underlying JSON error. + #[source] + source: serde_json::Error, + }, + + /// A board's `board_ip` isn't a valid IP address. + #[error("board {board:?}: invalid IP address {raw:?}")] + InvalidIp { + /// The board whose IP address is invalid. + board: String, + /// The text that failed to parse. + raw: String, + /// The underlying parse error. + #[source] + source: AddrParseError, + }, + + /// An address in `general_info.json`'s `addresses` isn't a valid IP + /// address. + #[error("address {name:?}: invalid IP address {raw:?}")] + InvalidAddress { + /// The address's name (e.g. `"backend"`). + name: String, + /// The text that failed to parse. + raw: String, + /// The underlying parse error. + #[source] + source: AddrParseError, + }, + + /// A board's `mac` isn't a valid MAC address. + #[error("board {board:?}: invalid MAC address {raw:?}")] + InvalidMac { + /// The board whose MAC address is invalid. + board: String, + /// The text that failed to parse. + raw: String, + }, + + /// A board has no `mac` field at all. + #[error("board {board:?}: missing MAC address")] + MissingMac { + /// The board missing its MAC address. + board: String, + }, + + /// A packet or order declares a `type` other than `"data", `"order"`, `"message"` or `"protection"`. + #[error("board {board:?}, packet {name:?}: unknown packet type {type_name:?}")] + UnknownPacketType { + /// The board the packet belongs to. + board: String, + /// The packet's name. + name: String, + /// The unrecognized type string. + type_name: String, + }, + + /// A `"type": "message"` packet's `name` isn't one of + /// [`crate::model::MessageLevel`]'s four severities. + #[error("board {board:?}, packet {name:?}: not a known message severity")] + UnknownMessageSeverity { + /// The board declaring this message packet. + board: String, + /// The packet's name — the text that failed to match a severity. + name: String, + }, + + /// A packet or order references a measurement alias that doesn't exist + /// among that board's own measurements. + #[error("board {board:?}, packet {name:?}: unknown measurement alias {alias:?}")] + UnknownAlias { + /// The board the packet belongs to. + board: String, + /// The packet's name. + name: String, + /// The alias that couldn't be resolved. + alias: String, + }, + + /// A measurement couldn't be converted into [`crate::model::Measurement`]. + #[error("board {board:?}, measurement {alias:?}: {source}")] + Measurement { + /// The board the measurement belongs to. + board: String, + /// The measurement's alias. + alias: String, + /// The underlying conversion error. + #[source] + source: MeasurementError, + }, +} + +/// An error converting a raw measurement into [`crate::model::Measurement`]. +#[derive(Debug, thiserror::Error)] +pub enum MeasurementError { + /// The measurement has more than 7 protections. + #[error("measurement has {count} protections, maximum allowed is 7")] + TooManyProtections { + /// How many protections were declared. + count: usize, + }, + + /// The measurement's `type` isn't a recognized wire type, `"bool"` or + /// `"enum"`. + #[error("unknown measurement type {0:?}")] + UnknownType(String), + + /// The measurement's `type` is `"enum"` but no `enumValues` were given. + #[error("type is \"enum\" but no enumValues were given")] + MissingEnumValues, + + /// One of the measurement's declared protections failed to convert. + #[error("protection #{index}: {source}")] + Protection { + /// The protection's 1-based position within the measurement's list. + index: usize, + /// The underlying conversion error. + #[source] + source: ProtectionError, + }, +} + +/// An error converting a raw protection into [`crate::model::Protection`]. +#[derive(Debug, thiserror::Error)] +pub enum ProtectionError { + /// The protection's `type` isn't one of the five known kinds. + #[error("unknown protection type {0:?}")] + UnknownType(String), + + /// The protection's `value` array doesn't have the number of bounds its + /// `type` requires. + #[error("type {kind:?} needs {expected} bound(s), got {got}")] + WrongBoundCount { + /// The protection's type string. + kind: String, + /// How many bounds that type requires. + expected: u8, + /// How many bounds were actually given. + got: usize, + }, + + /// The protection's `time` string couldn't be parsed as a duration. + #[error("couldn't parse time {0:?}")] + InvalidTime(String), +} diff --git a/backend/crates/core/src/adj/info.rs b/backend/crates/core/src/adj/info.rs new file mode 100644 index 000000000..57ddc175e --- /dev/null +++ b/backend/crates/core/src/adj/info.rs @@ -0,0 +1,58 @@ +//! Backend-session metadata from `general_info.json`. +//! +//! This is not vehicle data — that's [`crate::model::PodData`] — but what +//! *this backend session* needs beyond the vehicle: known ports and +//! addresses, message-id conventions, and how to display a triggered +//! protection. + +use crate::model::{AdjId, MessageLevel, Port}; +use std::collections::HashMap; +use std::net::IpAddr; + +/// A protection type's display text, as declared in `general_info.json`'s +/// `protectionTypes`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ProtectionTypeInfo { + /// Whether this protection type takes an interval (`[min, max]`) + /// instead of a single bound. + pub is_range: bool, + /// The message template shown at the control station; `{}` stands for + /// the protection's bound(s), `%` for the value that triggered it. + pub text: String, + /// The alternative template for time-accumulation protections, if one + /// was declared. + pub text_time: Option, +} + +/// Backend-session metadata parsed from `general_info.json`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AdjInfo { + /// Known port numbers, by name (e.g. `"TCP_SERVER"` -> `Port(50500)`). + pub ports: HashMap, + /// Known addresses, by name (e.g. `"backend"` -> `192.168.0.9`). + pub addresses: HashMap, + /// Known message ids, by name (e.g. `"fault"` -> `AdjId(2)`) — these + /// live in the ADJ's own special reserved id range (`[0, 511]`, see + /// [`AdjId`]'s own docs), not a separate numbering of their own. + /// This is the ADJ's own direction: a severity's *name* to its id. + /// Not every entry here is one of [`MessageLevel`]'s four severities + /// — other reserved names (e.g. `add_state_order`) share this same + /// map — see [`MessageLevel::name`] for which ones are. + pub message_ids: HashMap, + /// The reverse of [`AdjInfo::message_ids`], and only for the four + /// entries that actually are a [`MessageLevel`]: a wire id straight + /// to its severity. + /// + /// Decoding a free-text log frame only ever has the raw id off the + /// wire, not its name — without this, every decoder would have to + /// search `message_ids` by value (or re-derive this same reverse + /// map itself) just to answer "which severity is id 2?". Built once + /// here, when the ADJ is loaded (see `adj::raw::types`'s + /// `TryFrom for AdjInfo`), + /// from `message_ids` and [`MessageLevel::ALL`]/[`MessageLevel::name`] + /// — never constructed by hand, and never out of sync with + /// `message_ids` since it's derived from it. + pub message_levels: HashMap, + /// Declared protection types, by name (e.g. `"Range"`). + pub protection_types: HashMap, +} diff --git a/backend/crates/core/src/adj/raw.rs b/backend/crates/core/src/adj/raw.rs new file mode 100644 index 000000000..7347e48af --- /dev/null +++ b/backend/crates/core/src/adj/raw.rs @@ -0,0 +1,12 @@ +//! Raw ADJ JSON shapes and the directory-walking loader. +//! +//! Kept together as their own submodule — unlike `error`/`info` — because +//! this whole module is the one piece of `adj` meant to be reused as-is by +//! a future non-`station` product (a sniffer, a simulator...). The +//! individual `RawX` shapes stay private; what's reusable is the +//! [`load_from_dir`] entry point, not picking apart its internals. + +mod loader; +mod types; + +pub use loader::load_from_dir; diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs new file mode 100644 index 000000000..32216fc2b --- /dev/null +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -0,0 +1,384 @@ +//! Walks an ADJ directory and builds an [`crate::adj::Adj`] from it. + +use super::types::{RawBoard, RawGeneralInfo, RawMeasurement, RawPacket}; +use crate::adj::error::LoadError; +use crate::adj::{Adj, AdjInfo}; +use crate::model::{ + AdjId, Board, BoardId, MacAddress, Measurement, MessageLevel, PacketDef, PacketType, PodData, +}; +use serde::de::DeserializeOwned; +use std::collections::HashMap; +use std::fs; +use std::net::IpAddr; +use std::path::Path; + +/// Loads an ADJ directory rooted at `path` (containing `general_info.json` +/// and `boards.json`) into an [`Adj`]. +/// +/// A board present on disk but absent from `boards.json` is never read; +/// that isn't an error. +pub fn load_from_dir(path: &Path) -> Result { + let general_info: RawGeneralInfo = read_json(&path.join("general_info.json"))?; + let board_paths: HashMap = read_json(&path.join("boards.json"))?; + + let mut boards = HashMap::new(); + for (name, rel_path) in &board_paths { + let board = load_board(path, name, rel_path)?; + boards.insert(board.id, board); + } + + Ok(Adj { + pod_data: PodData { boards }, + info: AdjInfo::try_from(general_info)?, + }) +} + +/// Reads a JSON file at `path` and deserializes it into `T`, returning a +/// [`LoadError`] on failure. +fn read_json(path: &Path) -> Result { + let text = fs::read_to_string(path).map_err(|source| LoadError::ReadFile { + path: path.to_path_buf(), + source, + })?; + serde_json::from_str(&text).map_err(|source| LoadError::ParseJson { + path: path.to_path_buf(), + source, + }) +} + +pub(crate) fn load_board(root: &Path, name: &str, rel_path: &str) -> Result { + // Read the board's JSON, then its measurements and packets, and build a `Board` from it + let board_file = root.join(rel_path); + let raw_board: RawBoard = read_json(&board_file)?; + let board_id = BoardId(raw_board.board_id); + let board_dir = board_file + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| root.to_path_buf()); + + let mut measurements: HashMap = HashMap::new(); + let mut packets: HashMap = HashMap::new(); + + for measurements_path in &raw_board.measurements { + let raws: Vec = read_json(&board_dir.join(measurements_path))?; + for raw in raws { + let alias = raw.alias.clone(); + let measurement = + Measurement::try_from(raw).map_err(|source| LoadError::Measurement { + board: name.to_string(), + alias: alias.clone(), + source, + })?; + + for (idx, _protection) in measurement.protections.iter().enumerate() { + // The compound id's high 3 bits/low 13 bits split — see + // `AdjId::protection_position`/`AdjId::measurement_id` for + // the inverse of this composition. `pos` starts at 1, not + // 0: that's reserved for a normal, non-protection id. + let pos = idx as u16 + 1; + let protection_packet_id = (pos << 13) | measurement.id.0; + + let packet = PacketDef { + id: AdjId(protection_packet_id), + board: board_id, + name: format!("{}_{}_protection_{}", name, measurement.alias, idx + 1), + packet_type: PacketType::Protection(measurement.clone()), + }; + packets.insert(packet.id, packet); + } + measurements.insert(alias, measurement); + } + } + + for packets_path in &raw_board.packets { + let raws: Vec = read_json(&board_dir.join(packets_path))?; + for raw in raws { + let packet = build_packet_def(name, board_id, raw, &measurements)?; + packets.insert(packet.id, packet); + } + } + + let ip: IpAddr = raw_board + .board_ip + .parse() + .map_err(|source| LoadError::InvalidIp { + board: name.to_string(), + raw: raw_board.board_ip.clone(), + source, + })?; + + let mac_text = raw_board.mac.ok_or_else(|| LoadError::MissingMac { + board: name.to_string(), + })?; + let mac = parse_mac(&mac_text).ok_or_else(|| LoadError::InvalidMac { + board: name.to_string(), + raw: mac_text.clone(), + })?; + + Ok(Board { + id: board_id, + name: name.to_string(), + ip, + mac, + packets, + }) +} + +fn build_packet_def( + board: &str, + board_id: BoardId, + raw: RawPacket, + measurements: &HashMap, +) -> Result { + let packet_type = match raw.packet_type.as_str() { + "data" => PacketType::Data(resolve_measurements( + board, + &raw.name, + &raw.variables, + measurements, + )?), + "order" => PacketType::Order(resolve_measurements( + board, + &raw.name, + &raw.variables, + measurements, + )?), + // The packet's own `name` must be one of `MessageLevel`'s four + // severities (`"fault"`/`"warning"`/`"info"`/`"panic"`) — that's + // the only way a `"message"`-type packet in `packets.json` says + // which severity it is; nothing else in its JSON shape does. + "message" => PacketType::Message(MessageLevel::from_name(&raw.name).ok_or_else(|| { + LoadError::UnknownMessageSeverity { + board: board.to_string(), + name: raw.name.clone(), + } + })?), + other => { + return Err(LoadError::UnknownPacketType { + board: board.to_string(), + name: raw.name.clone(), + type_name: other.to_string(), + }); + } + }; + + Ok(PacketDef { + id: AdjId(raw.id), + board: board_id, + name: raw.name, + packet_type, + }) +} + +/// Resolves `variables` (a packet's declared measurement aliases) against +/// `measurements`, in order. +fn resolve_measurements( + board: &str, + packet_name: &str, + variables: &[String], + measurements: &HashMap, +) -> Result, LoadError> { + let mut resolved = Vec::with_capacity(variables.len()); + for alias in variables { + let measurement = + measurements + .get(alias) + .cloned() + .ok_or_else(|| LoadError::UnknownAlias { + board: board.to_string(), + name: packet_name.to_string(), + alias: alias.clone(), + })?; + resolved.push(measurement); + } + Ok(resolved) +} + +/// Parses a MAC address text form, e.g. `"aa:bb:cc:dd:ee:ff"` or the +/// unpadded `"0:0:0:0:0:0"` real ADJ fixtures actually use — each octet is +/// 1 or 2 hex digits. `None` on anything else. +fn parse_mac(raw: &str) -> Option { + let parts: Vec<&str> = raw.split(':').collect(); + if parts.len() != 6 { + return None; + } + + let mut octets = [0u8; 6]; + for (octet, part) in octets.iter_mut().zip(parts) { + if part.is_empty() || part.len() > 2 { + return None; + } + *octet = u8::from_str_radix(part, 16).ok()?; + } + Some(MacAddress(octets)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adj::ProtectionTypeInfo; + use crate::model::{PacketType, Port, ProtectionKind, Severity}; + use std::path::Path; + use std::time::Duration; + + fn must_load(path: &Path) -> Adj { + let Ok(adj) = load_from_dir(path) else { + unreachable!("fixture directory should load cleanly"); + }; + adj + } + + #[test] + fn mac_address_accepts_padded_and_unpadded_octets() { + assert_eq!( + parse_mac("aa:bb:cc:dd:ee:ff"), + Some(MacAddress([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])) + ); + assert_eq!(parse_mac("0:0:0:0:0:0"), Some(MacAddress([0; 6]))); + } + + #[test] + fn mac_address_rejects_malformed_text() { + assert_eq!(parse_mac("aa:bb:cc:dd:ee"), None); + assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff:00"), None); + assert_eq!(parse_mac("aaa:bb:cc:dd:ee:ff"), None); + assert_eq!(parse_mac("zz:bb:cc:dd:ee:ff"), None); + } + + #[test] + fn build_packet_def_rejects_unknown_packet_type() { + let raw = RawPacket { + id: 1, + packet_type: "status".to_string(), + name: "Status".to_string(), + variables: vec![], + }; + assert!(matches!( + build_packet_def("VCU", BoardId(3), raw, &HashMap::new()), + Err(LoadError::UnknownPacketType { .. }) + )); + } + + #[test] + fn build_packet_def_rejects_unknown_alias() { + let raw = RawPacket { + id: 1, + packet_type: "data".to_string(), + name: "Status".to_string(), + variables: vec!["missing".to_string()], + }; + assert!(matches!( + build_packet_def("VCU", BoardId(3), raw, &HashMap::new()), + Err(LoadError::UnknownAlias { .. }) + )); + } + + #[test] + fn build_packet_def_resolves_a_message_packets_severity_from_its_name() { + let raw = RawPacket { + id: 2, + packet_type: "message".to_string(), + name: "fault".to_string(), + variables: vec![], + }; + let Ok(packet) = build_packet_def("VCU", BoardId(3), raw, &HashMap::new()) else { + unreachable!("\"fault\" is a known message severity"); + }; + assert_eq!(packet.packet_type, PacketType::Message(MessageLevel::Fault)); + } + + #[test] + fn build_packet_def_rejects_a_message_whose_name_isnt_a_severity() { + let raw = RawPacket { + id: 2, + packet_type: "message".to_string(), + name: "not_a_severity".to_string(), + variables: vec![], + }; + assert!(matches!( + build_packet_def("VCU", BoardId(3), raw, &HashMap::new()), + Err(LoadError::UnknownMessageSeverity { .. }) + )); + } + + #[test] + fn loads_the_sample_adj_fixture() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/adj/test/adj"); + let adj = must_load(&path); + + // `HashMap` iteration order is unspecified, so sort before comparing. + let mut board_names: Vec<&str> = adj + .pod_data + .boards + .values() + .map(|b| b.name.as_str()) + .collect(); + board_names.sort(); + assert_eq!(board_names, vec!["HVBMS", "LCU", "PCU", "VCU"]); + + let Some(vcu) = adj.pod_data.boards.get(&BoardId(3)) else { + unreachable!("VCU should be among the loaded boards"); + }; + assert_eq!(vcu.id, BoardId(3)); + assert_eq!(vcu.ip, IpAddr::from([192, 168, 1, 3])); + assert_eq!(vcu.mac, MacAddress([0; 6])); + assert_eq!(vcu.packets.len(), 18); + let Some(order) = vcu.packets.get(&AdjId(600)) else { + unreachable!("VCU should have order 600"); + }; + let PacketType::Order(_) = &order.packet_type else { + unreachable!("order 600 should be an Order"); + }; + assert_eq!(order.board, BoardId(3)); + let Some(data) = vcu.packets.get(&AdjId(625)) else { + unreachable!("VCU should have data packet 625"); + }; + let PacketType::Data(_) = &data.packet_type else { + unreachable!("data packet 625 should be Data"); + }; + + let Some(pcu) = adj.pod_data.boards.get(&BoardId(5)) else { + unreachable!("PCU should be among the loaded boards"); + }; + let Some(protected) = pcu + .packets + .values() + .flat_map(|p| p.packet_type.measurements()) + .find(|m| !m.protections.is_empty()) + else { + unreachable!("PCU should have a measurement with a protection"); + }; + assert_eq!(protected.protections.len(), 1); + let protection = &protected.protections[0]; + assert_eq!(protection.kind, ProtectionKind::Equal { target: 1.0 }); + assert_eq!(protection.severity, Severity::Fault); + assert_eq!(protection.time, Duration::ZERO); + assert_eq!(protection.id, AdjId(1)); + + assert_eq!(adj.info.ports["TCP_SERVER"], Port(50500)); + assert_eq!( + adj.info.addresses["backend"], + IpAddr::from([192, 168, 0, 9]) + ); + assert_eq!(adj.info.message_ids["fault"], AdjId(2)); + assert_eq!( + adj.info.protection_types["Range"], + ProtectionTypeInfo { + is_range: true, + text: "Value out of range {} (measured: %)".to_string(), + text_time: None, + } + ); + + // `protected` has exactly one protection (asserted above), at position 1, + // so its compound wire id is `(1 << 13) | protected.id.0`. + let Some(protection_packet) = pcu.packets.get(&AdjId((1u16 << 13) | protected.id.0)) else { + unreachable!("PCU should have a synthetic protection packet"); + }; + let PacketType::Protection(measurement) = &protection_packet.packet_type else { + unreachable!("protection_packet should be a Protection"); + }; + assert_eq!(measurement, protected); + assert_eq!(protection_packet.board, pcu.id); + } +} diff --git a/backend/crates/core/src/adj/raw/types.rs b/backend/crates/core/src/adj/raw/types.rs new file mode 100644 index 000000000..bac40de9f --- /dev/null +++ b/backend/crates/core/src/adj/raw/types.rs @@ -0,0 +1,646 @@ +//! Raw ADJ JSON shapes and their conversion into `model` types. +//! +//! These shapes mirror the JSON exactly — including the fields this crate +//! intentionally doesn't model yet (`sockets`, `period`, `period_type`, +//! `socket`, `units`, `podUnits`), which are simply absent from these +//! structs and therefore ignored by serde, not rejected. + +use crate::adj::error::{LoadError, MeasurementError, ProtectionError}; +use crate::adj::info::{AdjInfo, ProtectionTypeInfo}; +use crate::model::{ + AdjId, Measurement, MeasurementKind, MessageLevel, NumericKind, Port, Protection, + ProtectionKind, Severity, +}; +use serde::Deserialize; +use std::collections::HashMap; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct RawGeneralInfo { + pub(crate) ports: HashMap, + pub(crate) addresses: HashMap, + #[serde(rename = "protectionTypes", default)] + pub(crate) protection_types: HashMap, + pub(crate) message_ids: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct RawProtectionType { + #[serde(default)] + pub(crate) range: bool, + pub(crate) text: String, + #[serde(default, rename = "textTime")] + pub(crate) text_time: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct RawBoard { + pub(crate) board_id: u16, + #[serde(default)] + pub(crate) mac: Option, + pub(crate) board_ip: String, + #[serde(default)] + pub(crate) measurements: Vec, + #[serde(default)] + pub(crate) packets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct RawMeasurement { + pub(crate) id: u16, + pub(crate) alias: String, + pub(crate) name: String, + #[serde(rename = "type")] + pub(crate) kind: String, + #[serde(default, rename = "displayUnits")] + pub(crate) display_units: String, + #[serde(default, rename = "enumValues")] + pub(crate) enum_values: Vec, + #[serde(default)] + pub(crate) protections: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct RawProtection { + #[serde(rename = "type")] + pub(crate) kind: String, + pub(crate) value: Vec, + #[serde(default = "default_fault")] + pub(crate) fault: bool, + #[serde(default)] + pub(crate) time: Option, +} + +fn default_fault() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct RawPacket { + pub(crate) id: u16, + #[serde(rename = "type")] + pub(crate) packet_type: String, + pub(crate) name: String, + #[serde(default)] + pub(crate) variables: Vec, +} + +impl TryFrom for AdjInfo { + type Error = LoadError; + + fn try_from(raw: RawGeneralInfo) -> Result { + let mut addresses = HashMap::with_capacity(raw.addresses.len()); + for (name, raw_addr) in raw.addresses { + let addr = raw_addr + .parse() + .map_err(|source| LoadError::InvalidAddress { + name: name.clone(), + raw: raw_addr, + source, + })?; + addresses.insert(name, addr); + } + + let message_ids: HashMap = raw + .message_ids + .into_iter() + .map(|(name, id)| (name, AdjId(id))) + .collect(); + + // The reverse of `message_ids`, restricted to the four names + // that are actually a `MessageLevel` — see `AdjInfo::message_levels`'s + // own doc comment for why this is worth precomputing here rather + // than leaving every caller to search `message_ids` by value. + let message_levels: HashMap = MessageLevel::ALL + .into_iter() + .filter_map(|level| message_ids.get(level.name()).map(|&id| (id, level))) + .collect(); + + Ok(AdjInfo { + ports: raw + .ports + .into_iter() + .map(|(name, port)| (name, Port(port))) + .collect(), + addresses, + message_ids, + message_levels, + protection_types: raw + .protection_types + .into_iter() + .map(|(name, kind)| { + ( + name, + ProtectionTypeInfo { + is_range: kind.range, + text: kind.text, + text_time: kind.text_time, + }, + ) + }) + .collect(), + }) + } +} + +impl TryFrom for Measurement { + type Error = MeasurementError; + + fn try_from(raw: RawMeasurement) -> Result { + if raw.protections.len() > 7 { + return Err(MeasurementError::TooManyProtections { + count: raw.protections.len(), + }); + } + + let kind = parse_measurement_kind(&raw.kind, raw.enum_values)?; + + let mut protections = Vec::with_capacity(raw.protections.len()); + for (zero_based, raw_protection) in raw.protections.into_iter().enumerate() { + let position = (zero_based + 1) as u16; + let protection = + Protection::try_from((raw_protection, position)).map_err(|source| { + MeasurementError::Protection { + index: position as usize, + source, + } + })?; + protections.push(protection); + } + + Ok(Measurement { + id: AdjId(raw.id), + alias: raw.alias, + name: raw.name, + display_units: raw.display_units, + kind, + protections, + }) + } +} + +impl TryFrom<(RawProtection, u16)> for Protection { + type Error = ProtectionError; + + fn try_from((raw, position): (RawProtection, u16)) -> Result { + let kind = parse_protection_kind(&raw.kind, &raw.value)?; + let time = match &raw.time { + Some(text) => parse_duration(text)?, + None => Duration::ZERO, + }; + + Ok(Protection { + id: AdjId(position), + kind, + severity: if raw.fault { + Severity::Fault + } else { + Severity::Warning + }, + time, + }) + } +} + +fn parse_measurement_kind( + kind: &str, + enum_values: Vec, +) -> Result { + let numeric = |wire_type| MeasurementKind::Numeric { wire_type }; + + match kind { + "uint8" => Ok(numeric(NumericKind::U8)), + "uint16" => Ok(numeric(NumericKind::U16)), + "uint32" => Ok(numeric(NumericKind::U32)), + "uint64" => Ok(numeric(NumericKind::U64)), + "int8" => Ok(numeric(NumericKind::I8)), + "int16" => Ok(numeric(NumericKind::I16)), + "int32" => Ok(numeric(NumericKind::I32)), + "int64" => Ok(numeric(NumericKind::I64)), + "float32" => Ok(numeric(NumericKind::F32)), + "float64" => Ok(numeric(NumericKind::F64)), + "bool" => Ok(MeasurementKind::Boolean), + "enum" if !enum_values.is_empty() => Ok(MeasurementKind::Enum { + options: enum_values, + }), + "enum" => Err(MeasurementError::MissingEnumValues), + other => Err(MeasurementError::UnknownType(other.to_string())), + } +} + +fn parse_protection_kind(kind: &str, value: &[f64]) -> Result { + fn one(kind: &str, value: &[f64]) -> Result { + match value { + [bound] => Ok(*bound), + other => Err(ProtectionError::WrongBoundCount { + kind: kind.to_string(), + expected: 1, + got: other.len(), + }), + } + } + + fn two(kind: &str, value: &[f64]) -> Result<(f64, f64), ProtectionError> { + match value { + [min, max] => Ok((*min, *max)), + other => Err(ProtectionError::WrongBoundCount { + kind: kind.to_string(), + expected: 2, + got: other.len(), + }), + } + } + + match kind { + "Above" => Ok(ProtectionKind::Above { + limit: one(kind, value)?, + }), + "Below" => Ok(ProtectionKind::Below { + limit: one(kind, value)?, + }), + "Equal" => Ok(ProtectionKind::Equal { + target: one(kind, value)?, + }), + "NotEqual" => Ok(ProtectionKind::NotEqual { + target: one(kind, value)?, + }), + "Range" => { + let (min, max) = two(kind, value)?; + Ok(ProtectionKind::Range { min, max }) + } + other => Err(ProtectionError::UnknownType(other.to_string())), + } +} + +/// Parses a duration string like `"500ms"`, `"3s"` or `"10us"`. +/// +/// Rejects non-finite or negative numbers explicitly before handing them to +/// [`Duration::from_secs_f64`], which panics on either — this reads +/// externally-authored ADJ text, so a malformed `"time"` (e.g. `"-5ms"`) +/// must become an error, not a panic. +fn parse_duration(text: &str) -> Result { + let Some(split_at) = text.find(|c: char| c.is_ascii_alphabetic()) else { + return Err(ProtectionError::InvalidTime(text.to_string())); + }; + let (number, unit) = text.split_at(split_at); + + let value: f64 = number + .parse() + .map_err(|_| ProtectionError::InvalidTime(text.to_string()))?; + + if !value.is_finite() || value < 0.0 { + return Err(ProtectionError::InvalidTime(text.to_string())); + } + + let seconds = match unit { + "s" => value, + "ms" => value / 1_000.0, + "us" => value / 1_000_000.0, + _ => return Err(ProtectionError::InvalidTime(text.to_string())), + }; + + Ok(Duration::from_secs_f64(seconds)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adj::raw::loader::load_board; + use crate::model::PacketType; + use std::net::IpAddr; + + fn raw_protection( + kind: &str, + value: Vec, + fault: bool, + time: Option<&str>, + ) -> RawProtection { + RawProtection { + kind: kind.to_string(), + value, + fault, + time: time.map(str::to_string), + } + } + + fn must_build_protection(raw: RawProtection, position: u16) -> Protection { + let Ok(protection) = Protection::try_from((raw, position)) else { + unreachable!("valid protection should convert cleanly"); + }; + protection + } + + #[test] + fn numeric_measurement_types_map_to_their_numeric_kind() { + let cases = [ + ("uint8", NumericKind::U8), + ("uint16", NumericKind::U16), + ("uint32", NumericKind::U32), + ("uint64", NumericKind::U64), + ("int8", NumericKind::I8), + ("int16", NumericKind::I16), + ("int32", NumericKind::I32), + ("int64", NumericKind::I64), + ("float32", NumericKind::F32), + ("float64", NumericKind::F64), + ]; + + for (type_name, expected) in cases { + assert!(matches!( + parse_measurement_kind(type_name, vec![]), + Ok(MeasurementKind::Numeric { wire_type }) if wire_type == expected + )); + } + } + + #[test] + fn bool_type_maps_to_boolean() { + assert!(matches!( + parse_measurement_kind("bool", vec![]), + Ok(MeasurementKind::Boolean) + )); + } + + #[test] + fn enum_type_with_values_keeps_their_order() { + let options = vec!["Idle".to_string(), "Fault".to_string()]; + assert!(matches!( + parse_measurement_kind("enum", options.clone()), + Ok(MeasurementKind::Enum { options: got }) if got == options + )); + } + + #[test] + fn enum_type_without_values_is_an_error() { + assert!(matches!( + parse_measurement_kind("enum", vec![]), + Err(MeasurementError::MissingEnumValues) + )); + } + + #[test] + fn unknown_measurement_type_is_an_error() { + assert!(matches!( + parse_measurement_kind("potato", vec![]), + Err(MeasurementError::UnknownType(t)) if t == "potato" + )); + } + + #[test] + fn one_bound_protection_kinds_take_a_single_value() { + assert!(matches!( + parse_protection_kind("Above", &[1.0]), + Ok(ProtectionKind::Above { limit }) if limit == 1.0 + )); + assert!(matches!( + parse_protection_kind("Below", &[2.0]), + Ok(ProtectionKind::Below { limit }) if limit == 2.0 + )); + assert!(matches!( + parse_protection_kind("Equal", &[3.0]), + Ok(ProtectionKind::Equal { target }) if target == 3.0 + )); + assert!(matches!( + parse_protection_kind("NotEqual", &[4.0]), + Ok(ProtectionKind::NotEqual { target }) if target == 4.0 + )); + } + + #[test] + fn range_protection_takes_two_values() { + assert!(matches!( + parse_protection_kind("Range", &[0.0, 48.0]), + Ok(ProtectionKind::Range { min, max }) if min == 0.0 && max == 48.0 + )); + } + + #[test] + fn wrong_bound_count_is_an_error() { + assert!(matches!( + parse_protection_kind("Above", &[1.0, 2.0]), + Err(ProtectionError::WrongBoundCount { + expected: 1, + got: 2, + .. + }) + )); + assert!(matches!( + parse_protection_kind("Range", &[1.0]), + Err(ProtectionError::WrongBoundCount { + expected: 2, + got: 1, + .. + }) + )); + } + + #[test] + fn unknown_protection_type_is_an_error() { + assert!(matches!( + parse_protection_kind("Potato", &[1.0]), + Err(ProtectionError::UnknownType(t)) if t == "Potato" + )); + } + + #[test] + fn fault_flag_selects_severity_default_true() { + let protection = must_build_protection(raw_protection("Equal", vec![1.0], true, None), 1); + assert_eq!(protection.severity, Severity::Fault); + + let protection = must_build_protection(raw_protection("Equal", vec![1.0], false, None), 1); + assert_eq!(protection.severity, Severity::Warning); + } + + #[test] + fn protection_id_is_its_position_not_a_global_id() { + let protection = must_build_protection(raw_protection("Equal", vec![1.0], true, None), 3); + assert_eq!(protection.id, AdjId(3)); + } + + #[test] + fn missing_time_defaults_to_zero() { + let protection = must_build_protection(raw_protection("Equal", vec![1.0], true, None), 1); + assert_eq!(protection.time, Duration::ZERO); + } + + #[test] + fn duration_strings_parse_with_their_unit() { + assert!(matches!(parse_duration("500ms"), Ok(d) if d == Duration::from_millis(500))); + assert!(matches!(parse_duration("3s"), Ok(d) if d == Duration::from_secs(3))); + assert!(matches!(parse_duration("10us"), Ok(d) if d == Duration::from_micros(10))); + } + + #[test] + fn invalid_duration_strings_are_rejected() { + assert!(matches!( + parse_duration("banana"), + Err(ProtectionError::InvalidTime(_)) + )); + assert!(matches!( + parse_duration("-5ms"), + Err(ProtectionError::InvalidTime(_)) + )); + assert!(matches!( + parse_duration("5"), + Err(ProtectionError::InvalidTime(_)) + )); + } + + #[test] + fn general_info_converts_into_adj_info() { + let mut protection_types = HashMap::new(); + protection_types.insert( + "Range".to_string(), + RawProtectionType { + range: true, + text: "Value out of range {} (measured: %)".to_string(), + text_time: None, + }, + ); + let raw = RawGeneralInfo { + ports: HashMap::from([("TCP_SERVER".to_string(), 50500u16)]), + addresses: HashMap::from([("backend".to_string(), "192.168.0.9".to_string())]), + protection_types, + message_ids: HashMap::from([ + ("fault".to_string(), 2u16), + // Not a `MessageLevel` — shares `message_ids` with the + // four severities, but shouldn't appear in `message_levels`. + ("add_state_order".to_string(), 5u16), + ]), + }; + + let Ok(info) = AdjInfo::try_from(raw) else { + unreachable!("a well-formed RawGeneralInfo should convert cleanly"); + }; + + assert_eq!(info.ports["TCP_SERVER"], Port(50500)); + assert_eq!(info.addresses["backend"], IpAddr::from([192, 168, 0, 9])); + assert_eq!(info.message_ids["fault"], AdjId(2)); + assert_eq!(info.message_ids["add_state_order"], AdjId(5)); + assert!(info.protection_types["Range"].is_range); + + // `message_levels` is the reverse of `message_ids`, but only for + // the one entry that's actually a `MessageLevel`. + assert_eq!(info.message_levels.len(), 1); + assert_eq!(info.message_levels[&AdjId(2)], MessageLevel::Fault); + } + + #[test] + fn general_info_rejects_an_invalid_address() { + let raw = RawGeneralInfo { + ports: HashMap::new(), + addresses: HashMap::from([("backend".to_string(), "not-an-ip".to_string())]), + protection_types: HashMap::new(), + message_ids: HashMap::new(), + }; + + assert!(matches!( + AdjInfo::try_from(raw), + Err(LoadError::InvalidAddress { .. }) + )); + } + + #[test] + fn measurement_with_too_many_protections() { + let mut raw = RawMeasurement { + id: 100, + alias: "too_many".to_string(), + name: "Too Many Protections".to_string(), + kind: "float32".to_string(), + display_units: "V".to_string(), + enum_values: vec![], + protections: vec![], + }; + + for _ in 0..8 { + raw.protections + .push(raw_protection("Equal", vec![1.0], true, None)); + } + + assert!(matches!( + Measurement::try_from(raw), + Err(MeasurementError::TooManyProtections { count: 8 }) + )) + } + + #[test] + fn board_incorporates_synthetic_protection_packets() { + // A scratch directory, unique per test invocation (pid + a counter) + // so parallel test runs never collide on the same path. + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "software_core_adj_test_{}_{unique}", + std::process::id() + )); + + if std::fs::create_dir_all(&dir).is_err() { + unreachable!("should be able to create a scratch test directory"); + } + + let board_json = r#"{ + "board_id": 99, + "mac": "aa:bb:cc:dd:ee:ff", + "board_ip": "127.0.0.1", + "measurements": ["measurements.json"], + "packets": ["packets.json"] + }"#; + let measurements_json = r#"[ + { + "id": 100, + "alias": "mock_alias", + "name": "Mock Measurement", + "type": "float32", + "protections": [ + { "type": "Equal", "value": [1.0], "fault": true }, + { "type": "Equal", "value": [2.0], "fault": true } + ] + } + ]"#; + + if std::fs::write(dir.join("test_board.json"), board_json).is_err() { + unreachable!("should be able to write the mock board file"); + } + if std::fs::write(dir.join("measurements.json"), measurements_json).is_err() { + unreachable!("should be able to write the mock measurements file"); + } + if std::fs::write(dir.join("packets.json"), "[]").is_err() { + unreachable!("should be able to write the mock packets file"); + } + + let board_name = "H12_Test_Board"; + let result = load_board(&dir, board_name, "test_board.json"); + let _ = std::fs::remove_dir_all(&dir); + + let board = match result { + Ok(board) => board, + Err(err) => unreachable!("board loading should succeed: {err}"), + }; + + // We provided 0 standard packets and 2 protections, so the total + // synthetic packets injected into the board must equal 2. + let protection_count = board + .packets + .values() + .filter(|p| matches!(p.packet_type, PacketType::Protection(_))) + .count(); + assert_eq!( + protection_count, 2, + "board should contain exactly 2 protection packets" + ); + + // For protection index 0 (pos = 1): (1 << 13) | 100 = 8192 | 100 = 8292 + let Some(first) = board.packets.get(&AdjId(8292)) else { + unreachable!("first synthetic protection packet should be at id 8292"); + }; + assert_eq!(first.board, board.id); + assert_eq!(first.name, format!("{board_name}_mock_alias_protection_1")); + + // For protection index 1 (pos = 2): (2 << 13) | 100 = 16384 | 100 = 16484 + let Some(second) = board.packets.get(&AdjId(16484)) else { + unreachable!("second synthetic protection packet should be at id 16484"); + }; + assert_eq!(second.name, format!("{board_name}_mock_alias_protection_2")); + } +} diff --git a/backend/crates/core/src/adj/test/adj/boards.json b/backend/crates/core/src/adj/test/adj/boards.json new file mode 100644 index 000000000..9de2c650a --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards.json @@ -0,0 +1,6 @@ +{ + "VCU": "boards/VCU/VCU.json", + "LCU": "boards/LCU/LCU.json", + "PCU": "boards/PCU/PCU.json", + "HVBMS": "boards/HVBMS/HVBMS.json" +} diff --git a/backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU.json b/backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU.json new file mode 100644 index 000000000..2c98964c1 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU.json @@ -0,0 +1,12 @@ +{ + "board_id": 27, + "board_ip": "192.168.0.27", + "measurements": ["BLCU_measurements.json"], + "packets": [ + "packets.json", + "orders.json" + ], + "sockets": [ + "sockets.json" + ] +} diff --git a/backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU_measurements.json b/backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU_measurements.json new file mode 100644 index 000000000..f44182d73 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU_measurements.json @@ -0,0 +1,34 @@ +[ + { + "id": "target", + "name": "target", + "type": "enum", + "enumValues": [ + "VCU", + "HVBMS", + "LVBMS", + "PCU", + "LCU" + ] + }, + { + "id": "general_state_machine", + "name": "General State Machine", + "type": "enum", + "enumValues": [ + "Connecting", + "Operational", + "Fault" + ] + }, + { + "id": "operational_state_machine", + "name": "Operational state machine", + "type": "enum", + "enumValues": [ + "Idle", + "Flashing" + ] + } + +] diff --git a/backend/crates/core/src/adj/test/adj/boards/BLCU/orders.json b/backend/crates/core/src/adj/test/adj/boards/BLCU/orders.json new file mode 100644 index 000000000..1a7167d60 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/BLCU/orders.json @@ -0,0 +1,15 @@ +[ + { + "id": 701, + "type": "order", + "name": "Reset All" + }, + { + "id":700, + "type": "order", + "name":"Write Program", + "variables":[ + "target" + ] + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/BLCU/packets.json b/backend/crates/core/src/adj/test/adj/boards/BLCU/packets.json new file mode 100644 index 000000000..4674afdc2 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/BLCU/packets.json @@ -0,0 +1,28 @@ +[ + { + "id":3, + "type": "packet", + "name":"ack" + }, + { + "id":4, + "type": "packet", + "name":"nack" + }, + + { + "id": 1, + "type": "packet", + "name": "state_machine_state", + "variables": + [ + "general_state_machine", + "operational_state_machine" + ], + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + + } + +] diff --git a/backend/crates/core/src/adj/test/adj/boards/BLCU/sockets.json b/backend/crates/core/src/adj/test/adj/boards/BLCU/sockets.json new file mode 100644 index 000000000..173e674e2 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/BLCU/sockets.json @@ -0,0 +1,14 @@ +[ + { + "type": "ServerSocket", + "name": "control_station_tcp", + "port": 50500 + }, + + { + "type": "DatagramSocket", + "name": "control_station_udp", + "remote_ip":"backend", + "port": 50420 + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS.json b/backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS.json new file mode 100644 index 000000000..0022eb5ec --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS.json @@ -0,0 +1,15 @@ +{ + "board_id": 7, + "mac": "0:0:0:0:0:0", + "board_ip": "192.168.1.7", + "measurements": [ + "HVBMS_measurements.json" + ], + "packets": [ + "packets.json", + "orders.json" + ], + "sockets": [ + "sockets.json" + ] +} \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS_measurements.json b/backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS_measurements.json new file mode 100644 index 000000000..d31af6972 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS_measurements.json @@ -0,0 +1,1102 @@ +[ + { + "id": 2005, + "alias": "battery1_cell1", + "name": "Battery 1 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2006, + "alias": "battery1_cell2", + "name": "Battery 1 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2007, + "alias": "battery1_cell3", + "name": "Battery 1 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2008, + "alias": "battery1_cell4", + "name": "Battery 1 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2009, + "alias": "battery1_cell5", + "name": "Battery 1 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2010, + "alias": "battery1_cell6", + "name": "Battery 1 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2011, + "alias": "battery1_cell7", + "name": "Battery 1 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2012, + "alias": "battery1_cell8", + "name": "Battery 1 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2013, + "alias": "battery1_cell9", + "name": "Battery 1 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2014, + "alias": "battery1_cell10", + "name": "Battery 1 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2015, + "alias": "battery1_cell11", + "name": "Battery 1 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2016, + "alias": "battery1_cell12", + "name": "Battery 1 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2017, + "alias": "battery1_total_voltage", + "name": "Battery 1 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2018, + "alias": "battery1_temp1", + "name": "Battery 1 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2019, + "alias": "battery1_temp2", + "name": "Battery 1 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2020, + "alias": "battery1_temp3", + "name": "Battery 1 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2021, + "alias": "battery1_temp4", + "name": "Battery 1 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2022, + "alias": "battery2_cell1", + "name": "Battery 2 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2023, + "alias": "battery2_cell2", + "name": "Battery 2 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2024, + "alias": "battery2_cell3", + "name": "Battery 2 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2025, + "alias": "battery2_cell4", + "name": "Battery 2 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2026, + "alias": "battery2_cell5", + "name": "Battery 2 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2027, + "alias": "battery2_cell6", + "name": "Battery 2 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2028, + "alias": "battery2_cell7", + "name": "Battery 2 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2029, + "alias": "battery2_cell8", + "name": "Battery 2 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2030, + "alias": "battery2_cell9", + "name": "Battery 2 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2031, + "alias": "battery2_cell10", + "name": "Battery 2 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2032, + "alias": "battery2_cell11", + "name": "Battery 2 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2033, + "alias": "battery2_cell12", + "name": "Battery 2 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2034, + "alias": "battery2_total_voltage", + "name": "Battery 2 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2035, + "alias": "battery2_temp1", + "name": "Battery 2 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2036, + "alias": "battery2_temp2", + "name": "Battery 2 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2037, + "alias": "battery2_temp3", + "name": "Battery 2 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2038, + "alias": "battery2_temp4", + "name": "Battery 2 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2039, + "alias": "battery3_cell1", + "name": "Battery 3 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2040, + "alias": "battery3_cell2", + "name": "Battery 3 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2041, + "alias": "battery3_cell3", + "name": "Battery 3 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2042, + "alias": "battery3_cell4", + "name": "Battery 3 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2043, + "alias": "battery3_cell5", + "name": "Battery 3 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2044, + "alias": "battery3_cell6", + "name": "Battery 3 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2045, + "alias": "battery3_cell7", + "name": "Battery 3 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2046, + "alias": "battery3_cell8", + "name": "Battery 3 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2047, + "alias": "battery3_cell9", + "name": "Battery 3 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2048, + "alias": "battery3_cell10", + "name": "Battery 3 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2049, + "alias": "battery3_cell11", + "name": "Battery 3 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2050, + "alias": "battery3_cell12", + "name": "Battery 3 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2051, + "alias": "battery3_total_voltage", + "name": "Battery 3 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2052, + "alias": "battery3_temp1", + "name": "Battery 3 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2053, + "alias": "battery3_temp2", + "name": "Battery 3 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2054, + "alias": "battery3_temp3", + "name": "Battery 3 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2055, + "alias": "battery3_temp4", + "name": "Battery 3 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2056, + "alias": "battery4_cell1", + "name": "Battery 4 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2057, + "alias": "battery4_cell2", + "name": "Battery 4 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2058, + "alias": "battery4_cell3", + "name": "Battery 4 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2059, + "alias": "battery4_cell4", + "name": "Battery 4 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2060, + "alias": "battery4_cell5", + "name": "Battery 4 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2061, + "alias": "battery4_cell6", + "name": "Battery 4 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2062, + "alias": "battery4_cell7", + "name": "Battery 4 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2063, + "alias": "battery4_cell8", + "name": "Battery 4 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2064, + "alias": "battery4_cell9", + "name": "Battery 4 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2065, + "alias": "battery4_cell10", + "name": "Battery 4 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2066, + "alias": "battery4_cell11", + "name": "Battery 4 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2067, + "alias": "battery4_cell12", + "name": "Battery 4 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2068, + "alias": "battery4_total_voltage", + "name": "Battery 4 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2069, + "alias": "battery4_temp1", + "name": "Battery 4 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2070, + "alias": "battery4_temp2", + "name": "Battery 4 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2071, + "alias": "battery4_temp3", + "name": "Battery 4 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2072, + "alias": "battery4_temp4", + "name": "Battery 4 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2073, + "alias": "battery5_cell1", + "name": "Battery 5 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2074, + "alias": "battery5_cell2", + "name": "Battery 5 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2075, + "alias": "battery5_cell3", + "name": "Battery 5 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2076, + "alias": "battery5_cell4", + "name": "Battery 5 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2077, + "alias": "battery5_cell5", + "name": "Battery 5 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2078, + "alias": "battery5_cell6", + "name": "Battery 5 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2079, + "alias": "battery5_cell7", + "name": "Battery 5 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2080, + "alias": "battery5_cell8", + "name": "Battery 5 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2081, + "alias": "battery5_cell9", + "name": "Battery 5 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2082, + "alias": "battery5_cell10", + "name": "Battery 5 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2083, + "alias": "battery5_cell11", + "name": "Battery 5 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2084, + "alias": "battery5_cell12", + "name": "Battery 5 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2085, + "alias": "battery5_total_voltage", + "name": "Battery 5 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2086, + "alias": "battery5_temp1", + "name": "Battery 5 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2087, + "alias": "battery5_temp2", + "name": "Battery 5 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2088, + "alias": "battery5_temp3", + "name": "Battery 5 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2089, + "alias": "battery5_temp4", + "name": "Battery 5 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2090, + "alias": "battery6_cell1", + "name": "Battery 6 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2091, + "alias": "battery6_cell2", + "name": "Battery 6 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2092, + "alias": "battery6_cell3", + "name": "Battery 6 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2093, + "alias": "battery6_cell4", + "name": "Battery 6 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2094, + "alias": "battery6_cell5", + "name": "Battery 6 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2095, + "alias": "battery6_cell6", + "name": "Battery 6 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2096, + "alias": "battery6_cell7", + "name": "Battery 6 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2097, + "alias": "battery6_cell8", + "name": "Battery 6 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2098, + "alias": "battery6_cell9", + "name": "Battery 6 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2099, + "alias": "battery6_cell10", + "name": "Battery 6 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2100, + "alias": "battery6_cell11", + "name": "Battery 6 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2101, + "alias": "battery6_cell12", + "name": "Battery 6 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2102, + "alias": "battery6_total_voltage", + "name": "Battery 6 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2103, + "alias": "battery6_temp1", + "name": "Battery 6 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2104, + "alias": "battery6_temp2", + "name": "Battery 6 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2105, + "alias": "battery6_temp3", + "name": "Battery 6 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2106, + "alias": "battery6_temp4", + "name": "Battery 6 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2107, + "alias": "battery7_cell1", + "name": "Battery 7 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2108, + "alias": "battery7_cell2", + "name": "Battery 7 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2109, + "alias": "battery7_cell3", + "name": "Battery 7 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2110, + "alias": "battery7_cell4", + "name": "Battery 7 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2111, + "alias": "battery7_cell5", + "name": "Battery 7 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2112, + "alias": "battery7_cell6", + "name": "Battery 7 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2113, + "alias": "battery7_cell7", + "name": "Battery 7 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2114, + "alias": "battery7_cell8", + "name": "Battery 7 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2115, + "alias": "battery7_cell9", + "name": "Battery 7 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2116, + "alias": "battery7_cell10", + "name": "Battery 7 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2117, + "alias": "battery7_cell11", + "name": "Battery 7 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2118, + "alias": "battery7_cell12", + "name": "Battery 7 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2119, + "alias": "battery7_total_voltage", + "name": "Battery 7 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2120, + "alias": "battery7_temp1", + "name": "Battery 7 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2121, + "alias": "battery7_temp2", + "name": "Battery 7 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2122, + "alias": "battery7_temp3", + "name": "Battery 7 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2123, + "alias": "battery7_temp4", + "name": "Battery 7 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2124, + "alias": "battery8_cell1", + "name": "Battery 8 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2125, + "alias": "battery8_cell2", + "name": "Battery 8 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2126, + "alias": "battery8_cell3", + "name": "Battery 8 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2127, + "alias": "battery8_cell4", + "name": "Battery 8 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2128, + "alias": "battery8_cell5", + "name": "Battery 8 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2129, + "alias": "battery8_cell6", + "name": "Battery 8 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2130, + "alias": "battery8_cell7", + "name": "Battery 8 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2131, + "alias": "battery8_cell8", + "name": "Battery 8 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2132, + "alias": "battery8_cell9", + "name": "Battery 8 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2133, + "alias": "battery8_cell10", + "name": "Battery 8 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2134, + "alias": "battery8_cell11", + "name": "Battery 8 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2135, + "alias": "battery8_cell12", + "name": "Battery 8 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2136, + "alias": "battery8_total_voltage", + "name": "Battery 8 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2137, + "alias": "battery8_temp1", + "name": "Battery 8 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2138, + "alias": "battery8_temp2", + "name": "Battery 8 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2139, + "alias": "battery8_temp3", + "name": "Battery 8 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2140, + "alias": "battery8_temp4", + "name": "Battery 8 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2141, + "alias": "batteries_voltage_reading", + "name": "Batteries Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2142, + "alias": "voltage_reading", + "name": "Voltage Sensor", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2143, + "alias": "current_reading", + "name": "Current Sensor", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 2144, + "alias": "sdc_status", + "name": "SDC Status", + "type": "enum", + "enumValues": [ + "ENGAGED", + "DISENGAGED" + ] + }, + { + "id": 2145, + "alias": "voltage_min", + "name": "Minimum Cell Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2146, + "alias": "voltage_max", + "name": "Maximum Cell Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2147, + "alias": "temp_min", + "name": "Minimum Temperature", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2148, + "alias": "temp_max", + "name": "Maximum Temperature", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2149, + "alias": "imd_status", + "name": "IMD Status", + "type": "enum", + "enumValues": [ + "SHORTCIRCUIT", + "NORMAL", + "UNDERVOLTAGE", + "FAST_EVAL", + "EQUIPMENT_FAULT", + "GROUNDING_FAULT" + ] + }, + { + "id": 2150, + "alias": "imd_resistance", + "name": "IMD Resistance", + "type": "float32", + "displayUnits": "MOhm" + }, + { + "id": 2151, + "alias": "imd_is_ok", + "name": "IMD OK", + "type": "bool" + }, + { + "id": 2152, + "alias": "imd_duty", + "name": "IMD Duty", + "type": "float32" + }, + { + "id": 2153, + "alias": "imd_freq", + "name": "IMD Frequency", + "type": "float32" + }, + { + "id": 2154, + "alias": "contactor_precharge", + "name": "Contactor Precharge", + "type": "bool" + }, + { + "id": 2155, + "alias": "contactor_discharge", + "name": "Contactor Discharge", + "type": "bool" + }, + { + "id": 2156, + "alias": "contactor_high", + "name": "Contactor High", + "type": "bool" + }, + { + "id": 2157, + "alias": "contactor_low", + "name": "Contactor Low", + "type": "bool" + }, + { + "id": 2158, + "alias": "contactor_common_high", + "name": "Contactor Common High", + "type": "bool" + }, + { + "id": 2159, + "alias": "soc", + "name": "SOC", + "type": "float32" + }, + { + "id": 2160, + "alias": "sm_status", + "name": "State Machine Status", + "type": "enum", + "enumValues": [ + "Connecting", + "Idle", + "Ready To Precharge", + "Precharging", + "Energized", + "FAULT" + ] + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/HVBMS/orders.json b/backend/crates/core/src/adj/test/adj/boards/HVBMS/orders.json new file mode 100644 index 000000000..fe54b536a --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/HVBMS/orders.json @@ -0,0 +1,32 @@ +[ + { + "type": "order", + "name": "FAULT", + "variables": [], + "id": 2000 + }, + { + "type": "order", + "name": "Open Contactors", + "variables": [], + "id": 2001 + }, + { + "type": "order", + "name": "Cell Balance", + "variables": [], + "id": 2002 + }, + { + "type": "order", + "name": "Start Precharge", + "variables": [], + "id": 2003 + }, + { + "type": "order", + "name": "Check Faults", + "variables": [], + "id": 2004 + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/HVBMS/packets.json b/backend/crates/core/src/adj/test/adj/boards/HVBMS/packets.json new file mode 100644 index 000000000..01a841af3 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/HVBMS/packets.json @@ -0,0 +1,218 @@ +[ + { + "type": "data", + "name": "High Voltage System", + "variables": [ + "voltage_min", + "voltage_max", + "temp_min", + "temp_max", + "current_reading", + "voltage_reading", + "batteries_voltage_reading", + "sm_status" + ], + "id": 2161, + "period": 10, + "period_type": "ms", + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "SOC", + "variables": [ + "soc" + ], + "id": 2162, + "period": 50, + "period_type": "ms", + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "SDC", + "variables": [ + "sdc_status" + ], + "id": 2163, + "period": 50, + "period_type": "ms", + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "High Voltage Batteries", + "variables": [ + "battery1_cell1", + "battery1_cell2", + "battery1_cell3", + "battery1_cell4", + "battery1_cell5", + "battery1_cell6", + "battery1_cell7", + "battery1_cell8", + "battery1_cell9", + "battery1_cell10", + "battery1_cell11", + "battery1_cell12", + "battery1_total_voltage", + "battery1_temp1", + "battery1_temp2", + "battery1_temp3", + "battery1_temp4", + "battery2_cell1", + "battery2_cell2", + "battery2_cell3", + "battery2_cell4", + "battery2_cell5", + "battery2_cell6", + "battery2_cell7", + "battery2_cell8", + "battery2_cell9", + "battery2_cell10", + "battery2_cell11", + "battery2_cell12", + "battery2_total_voltage", + "battery2_temp1", + "battery2_temp2", + "battery2_temp3", + "battery2_temp4", + "battery3_cell1", + "battery3_cell2", + "battery3_cell3", + "battery3_cell4", + "battery3_cell5", + "battery3_cell6", + "battery3_cell7", + "battery3_cell8", + "battery3_cell9", + "battery3_cell10", + "battery3_cell11", + "battery3_cell12", + "battery3_total_voltage", + "battery3_temp1", + "battery3_temp2", + "battery3_temp3", + "battery3_temp4", + "battery4_cell1", + "battery4_cell2", + "battery4_cell3", + "battery4_cell4", + "battery4_cell5", + "battery4_cell6", + "battery4_cell7", + "battery4_cell8", + "battery4_cell9", + "battery4_cell10", + "battery4_cell11", + "battery4_cell12", + "battery4_total_voltage", + "battery4_temp1", + "battery4_temp2", + "battery4_temp3", + "battery4_temp4", + "battery5_cell1", + "battery5_cell2", + "battery5_cell3", + "battery5_cell4", + "battery5_cell5", + "battery5_cell6", + "battery5_cell7", + "battery5_cell8", + "battery5_cell9", + "battery5_cell10", + "battery5_cell11", + "battery5_cell12", + "battery5_total_voltage", + "battery5_temp1", + "battery5_temp2", + "battery5_temp3", + "battery5_temp4", + "battery6_cell1", + "battery6_cell2", + "battery6_cell3", + "battery6_cell4", + "battery6_cell5", + "battery6_cell6", + "battery6_cell7", + "battery6_cell8", + "battery6_cell9", + "battery6_cell10", + "battery6_cell11", + "battery6_cell12", + "battery6_total_voltage", + "battery6_temp1", + "battery6_temp2", + "battery6_temp3", + "battery6_temp4", + "battery7_cell1", + "battery7_cell2", + "battery7_cell3", + "battery7_cell4", + "battery7_cell5", + "battery7_cell6", + "battery7_cell7", + "battery7_cell8", + "battery7_cell9", + "battery7_cell10", + "battery7_cell11", + "battery7_cell12", + "battery7_total_voltage", + "battery7_temp1", + "battery7_temp2", + "battery7_temp3", + "battery7_temp4", + "battery8_cell1", + "battery8_cell2", + "battery8_cell3", + "battery8_cell4", + "battery8_cell5", + "battery8_cell6", + "battery8_cell7", + "battery8_cell8", + "battery8_cell9", + "battery8_cell10", + "battery8_cell11", + "battery8_cell12", + "battery8_total_voltage", + "battery8_temp1", + "battery8_temp2", + "battery8_temp3", + "battery8_temp4" + ], + "id": 2164, + "period": 50, + "period_type": "ms", + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "IMD", + "variables": [ + "imd_status", + "imd_resistance", + "imd_is_ok", + "imd_duty", + "imd_freq" + ], + "id": 2165, + "period": 50, + "period_type": "ms", + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Contactor Status", + "variables": [ + "contactor_discharge", + "contactor_precharge", + "contactor_low", + "contactor_high", + "contactor_common_high" + ], + "id": 2166, + "period": 50, + "period_type": "ms", + "socket": "control_station_udp" + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/HVBMS/sockets.json b/backend/crates/core/src/adj/test/adj/boards/HVBMS/sockets.json new file mode 100644 index 000000000..6c6e6b86f --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/HVBMS/sockets.json @@ -0,0 +1,13 @@ +[ + { + "type": "ServerSocket", + "name": "control_station_tcp", + "port": 50500 + }, + { + "type": "DatagramSocket", + "name": "control_station_udp", + "remote_ip": "192.168.0.9", + "port": 50400 + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/LCU/LCU.json b/backend/crates/core/src/adj/test/adj/boards/LCU/LCU.json new file mode 100644 index 000000000..2617f5ce6 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/LCU/LCU.json @@ -0,0 +1,15 @@ +{ + "board_id": 4, + "mac": "0:0:0:0:0:0", + "board_ip": "192.168.1.4", + "measurements": [ + "LCU_measurements.json" + ], + "packets": [ + "packets.json", + "orders.json" + ], + "sockets": [ + "sockets.json" + ] +} diff --git a/backend/crates/core/src/adj/test/adj/boards/LCU/LCU_measurements.json b/backend/crates/core/src/adj/test/adj/boards/LCU/LCU_measurements.json new file mode 100644 index 000000000..6e1ca1ec5 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/LCU/LCU_measurements.json @@ -0,0 +1,752 @@ +[ + { + "id": 819, + "alias": "master_state_machine", + "name": "Master SM", + "type": "enum", + "enumValues": [ + "Connecting", + "Idle", + "Levitating", + "Current Control", + "Debug", + "Fault" + ] + }, + { + "id": 820, + "alias": "slave_state_machine", + "name": "Slave SM", + "type": "enum", + "enumValues": [ + "SPI Connecting", + "Idle", + "Levitating", + "Current Control", + "Debug", + "Fault" + ] + }, + { + "id": 821, + "alias": "desired_current", + "name": "Desired Current", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 822, + "alias": "fixed_vbat", + "name": "Fixed VBAT", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 823, + "alias": "desired_distance", + "name": "Desired Distance", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 824, + "alias": "lpu_id", + "name": "LPU ID", + "type": "uint32" + }, + { + "id": 825, + "alias": "fixed_pwm_duty_cycle", + "name": "Fixed PWM Duty Cycle", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 826, + "alias": "pwm_duty_cycle_1", + "name": "Duty Cycle 1", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 827, + "alias": "pwm_duty_cycle_2", + "name": "Duty Cycle 2", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 828, + "alias": "pwm_duty_cycle_3", + "name": "Duty Cycle 3", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 829, + "alias": "pwm_duty_cycle_4", + "name": "Duty Cycle 4", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 830, + "alias": "pwm_duty_cycle_5", + "name": "Duty Cycle 5", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 831, + "alias": "pwm_duty_cycle_6", + "name": "Duty Cycle 6", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 832, + "alias": "pwm_duty_cycle_7", + "name": "Duty Cycle 7", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 833, + "alias": "pwm_duty_cycle_8", + "name": "Duty Cycle 8", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 834, + "alias": "pwm_duty_cycle_9", + "name": "Duty Cycle 9", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 835, + "alias": "pwm_duty_cycle_10", + "name": "Duty Cycle 10", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 836, + "alias": "coil_current_1", + "name": "Coil Current 1", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 837, + "alias": "coil_current_2", + "name": "Coil Current 2", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 838, + "alias": "coil_current_3", + "name": "Coil Current 3", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 839, + "alias": "coil_current_4", + "name": "Coil Current 4", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 840, + "alias": "coil_current_5", + "name": "Coil Current 5", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 841, + "alias": "coil_current_6", + "name": "Coil Current 6", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 842, + "alias": "coil_current_7", + "name": "Coil Current 7", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 843, + "alias": "coil_current_8", + "name": "Coil Current 8", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 844, + "alias": "coil_current_9", + "name": "Coil Current 9", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 845, + "alias": "coil_current_10", + "name": "Coil Current 10", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 846, + "alias": "airgap_1", + "name": "Airgap 1", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 847, + "alias": "airgap_2", + "name": "Airgap 2", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 848, + "alias": "airgap_3", + "name": "Airgap 3", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 849, + "alias": "airgap_4", + "name": "Airgap 4", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 850, + "alias": "airgap_5", + "name": "Airgap 5", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 851, + "alias": "airgap_6", + "name": "Airgap 6", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 852, + "alias": "airgap_7", + "name": "Airgap 7", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 853, + "alias": "airgap_8", + "name": "Airgap 8", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 854, + "alias": "cinema_current", + "name": "Cinema Current", + "type": "float32" + }, + { + "id": 855, + "alias": "ctrl_voltage_1", + "name": "Control Voltage 1", + "type": "float32" + }, + { + "id": 856, + "alias": "ctrl_voltage_2", + "name": "Control Voltage 2", + "type": "float32" + }, + { + "id": 857, + "alias": "ctrl_voltage_3", + "name": "Control Voltage 3", + "type": "float32" + }, + { + "id": 858, + "alias": "ctrl_voltage_4", + "name": "Control Voltage 4", + "type": "float32" + }, + { + "id": 859, + "alias": "ctrl_voltage_5", + "name": "Control Voltage 5", + "type": "float32" + }, + { + "id": 860, + "alias": "ctrl_voltage_6", + "name": "Control Voltage 6", + "type": "float32" + }, + { + "id": 861, + "alias": "ctrl_voltage_7", + "name": "Control Voltage 7", + "type": "float32" + }, + { + "id": 862, + "alias": "ctrl_voltage_8", + "name": "Control Voltage 8", + "type": "float32" + }, + { + "id": 863, + "alias": "ctrl_voltage_9", + "name": "Control Voltage 9", + "type": "float32" + }, + { + "id": 864, + "alias": "ctrl_voltage_10", + "name": "Control Voltage 10", + "type": "float32" + }, + { + "id": 865, + "alias": "ctrl_gap_local_1", + "name": "Control Gap Local 1", + "type": "float32" + }, + { + "id": 866, + "alias": "ctrl_gap_local_2", + "name": "Control Gap Local 2", + "type": "float32" + }, + { + "id": 867, + "alias": "ctrl_gap_local_3", + "name": "Control Gap Local 3", + "type": "float32" + }, + { + "id": 868, + "alias": "ctrl_gap_local_4", + "name": "Control Gap Local 4", + "type": "float32" + }, + { + "id": 869, + "alias": "ctrl_state_1", + "name": "Control State 1", + "type": "float32" + }, + { + "id": 870, + "alias": "ctrl_state_2", + "name": "Control State 2", + "type": "float32" + }, + { + "id": 871, + "alias": "ctrl_state_3", + "name": "Control State 3", + "type": "float32" + }, + { + "id": 872, + "alias": "ctrl_state_4", + "name": "Control State 4", + "type": "float32" + }, + { + "id": 873, + "alias": "ctrl_state_5", + "name": "Control State 5", + "type": "float32" + }, + { + "id": 874, + "alias": "ctrl_current_ref_1", + "name": "Control Current Ref 1", + "type": "float32" + }, + { + "id": 875, + "alias": "ctrl_current_ref_2", + "name": "Control Current Ref 2", + "type": "float32" + }, + { + "id": 876, + "alias": "ctrl_current_ref_3", + "name": "Control Current Ref 3", + "type": "float32" + }, + { + "id": 877, + "alias": "ctrl_current_ref_4", + "name": "Control Current Ref 4", + "type": "float32" + }, + { + "id": 878, + "alias": "ctrl_current_ref_5", + "name": "Control Current Ref 5", + "type": "float32" + }, + { + "id": 879, + "alias": "ctrl_current_ref_6", + "name": "Control Current Ref 6", + "type": "float32" + }, + { + "id": 880, + "alias": "ctrl_current_ref_7", + "name": "Control Current Ref 7", + "type": "float32" + }, + { + "id": 881, + "alias": "ctrl_current_ref_8", + "name": "Control Current Ref 8", + "type": "float32" + }, + { + "id": 882, + "alias": "ctrl_current_ref_9", + "name": "Control Current Ref 9", + "type": "float32" + }, + { + "id": 883, + "alias": "ctrl_current_ref_10", + "name": "Control Current Ref 10", + "type": "float32" + }, + { + "id": 884, + "alias": "ctrl_fe_1", + "name": "Control Fe 1", + "type": "float32" + }, + { + "id": 885, + "alias": "ctrl_fe_2", + "name": "Control Fe 2", + "type": "float32" + }, + { + "id": 886, + "alias": "ctrl_fe_3", + "name": "Control Fe 3", + "type": "float32" + }, + { + "id": 887, + "alias": "ctrl_fa_1", + "name": "Control Fa 1", + "type": "float32" + }, + { + "id": 888, + "alias": "ctrl_fa_2", + "name": "Control Fa 2", + "type": "float32" + }, + { + "id": 889, + "alias": "ctrl_fa_3", + "name": "Control Fa 3", + "type": "float32" + }, + { + "id": 890, + "alias": "ctrl_fa_4", + "name": "Control Fa 4", + "type": "float32" + }, + { + "id": 891, + "alias": "ctrl_ef_1", + "name": "Control Ef 1", + "type": "float32" + }, + { + "id": 892, + "alias": "ctrl_ef_2", + "name": "Control Ef 2", + "type": "float32" + }, + { + "id": 893, + "alias": "ctrl_ef_3", + "name": "Control Ef 3", + "type": "float32" + }, + { + "id": 894, + "alias": "ctrl_p_1", + "name": "Control P 1", + "type": "float32" + }, + { + "id": 895, + "alias": "ctrl_p_2", + "name": "Control P 2", + "type": "float32" + }, + { + "id": 896, + "alias": "ctrl_p_3", + "name": "Control P 3", + "type": "float32" + }, + { + "id": 897, + "alias": "ctrl_r_1", + "name": "Control R 1", + "type": "float32" + }, + { + "id": 898, + "alias": "ctrl_r_2", + "name": "Control R 2", + "type": "float32" + }, + { + "id": 899, + "alias": "ctrl_r_3", + "name": "Control R 3", + "type": "float32" + }, + { + "id": 900, + "alias": "ctrl_zz_1", + "name": "Control Zz 1", + "type": "float32" + }, + { + "id": 901, + "alias": "ctrl_zz_2", + "name": "Control Zz 2", + "type": "float32" + }, + { + "id": 902, + "alias": "ctrl_zz_3", + "name": "Control Zz 3", + "type": "float32" + }, + { + "id": 903, + "alias": "ctrl_fe_l_1", + "name": "Control Fe L 1", + "type": "float32" + }, + { + "id": 904, + "alias": "ctrl_fe_l_2", + "name": "Control Fe L 2", + "type": "float32" + }, + { + "id": 905, + "alias": "ctrl_fe_l_3", + "name": "Control Fe L 3", + "type": "float32" + }, + { + "id": 906, + "alias": "ctrl_referencia", + "name": "Control Referencia", + "type": "float32" + }, + { + "id": 907, + "alias": "ctrl_i_hems_out_1", + "name": "Control I HEMS Out 1", + "type": "float32" + }, + { + "id": 908, + "alias": "ctrl_i_hems_out_2", + "name": "Control I HEMS Out 2", + "type": "float32" + }, + { + "id": 909, + "alias": "ctrl_i_hems_out_3", + "name": "Control I HEMS Out 3", + "type": "float32" + }, + { + "id": 910, + "alias": "ctrl_i_hems_out_4", + "name": "Control I HEMS Out 4", + "type": "float32" + }, + { + "id": 911, + "alias": "ctrl_a_1", + "name": "Control A 1", + "type": "float32" + }, + { + "id": 912, + "alias": "ctrl_a_2", + "name": "Control A 2", + "type": "float32" + }, + { + "id": 913, + "alias": "ctrl_a_3", + "name": "Control A 3", + "type": "float32" + }, + { + "id": 914, + "alias": "ctrl_a_4", + "name": "Control A 4", + "type": "float32" + }, + { + "id": 915, + "alias": "ctrl_a_5", + "name": "Control A 5", + "type": "float32" + }, + { + "id": 916, + "alias": "ctrl_a_6", + "name": "Control A 6", + "type": "float32" + }, + { + "id": 917, + "alias": "ctrl_a_7", + "name": "Control A 7", + "type": "float32" + }, + { + "id": 918, + "alias": "ctrl_a_8", + "name": "Control A 8", + "type": "float32" + }, + { + "id": 919, + "alias": "ctrl_ak_1", + "name": "Control Ak 1", + "type": "float32" + }, + { + "id": 920, + "alias": "ctrl_ak_2", + "name": "Control Ak 2", + "type": "float32" + }, + { + "id": 921, + "alias": "ctrl_ak_3", + "name": "Control Ak 3", + "type": "float32" + }, + { + "id": 922, + "alias": "ctrl_ak_4", + "name": "Control Ak 4", + "type": "float32" + }, + { + "id": 923, + "alias": "ctrl_bk_1", + "name": "Control Bk 1", + "type": "float32" + }, + { + "id": 924, + "alias": "ctrl_bk_2", + "name": "Control Bk 2", + "type": "float32" + }, + { + "id": 925, + "alias": "ctrl_bk_3", + "name": "Control Bk 3", + "type": "float32" + }, + { + "id": 926, + "alias": "ctrl_pi_hems_1", + "name": "Control PI HEMS 1", + "type": "float32" + }, + { + "id": 927, + "alias": "ctrl_pi_hems_2", + "name": "Control PI HEMS 2", + "type": "float32" + }, + { + "id": 928, + "alias": "ctrl_pi_hems_3", + "name": "Control PI HEMS 3", + "type": "float32" + }, + { + "id": 929, + "alias": "ctrl_pi_hems_4", + "name": "Control PI HEMS 4", + "type": "float32" + }, + { + "id": 930, + "alias": "ctrl_pi_ems_1", + "name": "Control PI EMS 1", + "type": "float32" + }, + { + "id": 931, + "alias": "ctrl_pi_ems_2", + "name": "Control PI EMS 2", + "type": "float32" + }, + { + "id": 932, + "alias": "ctrl_pi_ems_3", + "name": "Control PI EMS 3", + "type": "float32" + }, + { + "id": 933, + "alias": "ctrl_pi_ems_4", + "name": "Control PI EMS 4", + "type": "float32" + }, + { + "id": 934, + "alias": "ctrl_pi_ems_5", + "name": "Control PI EMS 5", + "type": "float32" + }, + { + "id": 935, + "alias": "ctrl_pi_ems_6", + "name": "Control PI EMS 6", + "type": "float32" + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/LCU/orders.json b/backend/crates/core/src/adj/test/adj/boards/LCU/orders.json new file mode 100644 index 000000000..ad42d50c9 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/LCU/orders.json @@ -0,0 +1,142 @@ +[ + { + "type": "order", + "name": "FAULT", + "variables": [], + "id": 800 + }, + { + "type": "order", + "name": "Stop", + "variables": [], + "id": 801 + }, + { + "type": "order", + "name": "Set Fixed VBAT", + "variables": [ + "fixed_vbat" + ], + "id": 802 + }, + { + "type": "order", + "name": "Unset Fixed VBAT", + "variables": [], + "id": 803 + }, + { + "type": "order", + "name": "Levitate", + "variables": [ + "desired_distance" + ], + "id": 804 + }, + { + "type": "order", + "name": "Levitate Ramp", + "variables": [ + "desired_distance" + ], + "id": 805 + }, + { + "type": "order", + "name": "Set Desired Distance", + "variables": [ + "desired_distance" + ], + "id": 806 + }, + { + "type": "order", + "name": "Set Desired Distance Ramp", + "variables": [ + "desired_distance" + ], + "id": 807 + }, + { + "type": "order", + "name": "Stop Ramp", + "variables": [], + "id": 808 + }, + { + "type": "order", + "name": "Current Control", + "variables": [ + "lpu_id", + "desired_current" + ], + "id": 809 + }, + { + "type": "order", + "name": "All Current Control", + "variables": [ + "desired_current" + ], + "id": 810 + }, + { + "type": "order", + "name": "Stop Current Control", + "variables": [ + "lpu_id" + ], + "id": 811 + }, + { + "type": "order", + "name": "PWM", + "variables": [ + "lpu_id", + "fixed_pwm_duty_cycle" + ], + "id": 812 + }, + { + "type": "order", + "name": "All PWM", + "variables": [ + "fixed_pwm_duty_cycle" + ], + "id": 813 + }, + { + "type": "order", + "name": "Stop PWM", + "variables": [ + "lpu_id" + ], + "id": 814 + }, + { + "type": "order", + "name": "Reset All", + "variables": [], + "id": 815 + }, + { + "type": "order", + "name": "Reset Slave", + "variables": [], + "id": 816 + }, + { + "type": "order", + "name": "Cinema", + "variables": [ + "cinema_current" + ], + "id": 817 + }, + { + "type": "order", + "name": "Stop Cinema", + "variables": [], + "id": 818 + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/LCU/packets.json b/backend/crates/core/src/adj/test/adj/boards/LCU/packets.json new file mode 100644 index 000000000..6cfaf8900 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/LCU/packets.json @@ -0,0 +1,163 @@ +[ + { + "id": 936, + "type": "data", + "name": "LPU PWM Duties", + "variables": [ + "pwm_duty_cycle_1", + "pwm_duty_cycle_2", + "pwm_duty_cycle_3", + "pwm_duty_cycle_4", + "pwm_duty_cycle_5", + "pwm_duty_cycle_6", + "pwm_duty_cycle_7", + "pwm_duty_cycle_8", + "pwm_duty_cycle_9", + "pwm_duty_cycle_10" + ], + "period_type": "ms", + "period": 10.0, + "socket": "control_station_udp" + }, + { + "id": 937, + "type": "data", + "name": "LPU Coil Currents", + "variables": [ + "coil_current_1", + "coil_current_2", + "coil_current_3", + "coil_current_4", + "coil_current_5", + "coil_current_6", + "coil_current_7", + "coil_current_8", + "coil_current_9", + "coil_current_10" + ], + "period_type": "ms", + "period": 10.0, + "socket": "control_station_udp" + }, + { + "id": 938, + "type": "data", + "name": "Airgaps", + "variables": [ + "airgap_1", + "airgap_2", + "airgap_3", + "airgap_4", + "airgap_5", + "airgap_6", + "airgap_7", + "airgap_8" + ], + "period_type": "ms", + "period": 10.0, + "socket": "control_station_udp" + }, + { + "id": 939, + "type": "data", + "name": "State Machine", + "variables": [ + "master_state_machine", + "slave_state_machine" + ], + "period_type": "ms", + "period": 10.0, + "socket": "control_station_udp" + }, + { + "id": 940, + "type": "data", + "name": "General State", + "variables": [ + "ctrl_voltage_1", + "ctrl_voltage_2", + "ctrl_voltage_3", + "ctrl_voltage_4", + "ctrl_voltage_5", + "ctrl_voltage_6", + "ctrl_voltage_7", + "ctrl_voltage_8", + "ctrl_voltage_9", + "ctrl_voltage_10", + "ctrl_gap_local_1", + "ctrl_gap_local_2", + "ctrl_gap_local_3", + "ctrl_gap_local_4", + "ctrl_state_1", + "ctrl_state_2", + "ctrl_state_3", + "ctrl_state_4", + "ctrl_state_5", + "ctrl_current_ref_1", + "ctrl_current_ref_2", + "ctrl_current_ref_3", + "ctrl_current_ref_4", + "ctrl_current_ref_5", + "ctrl_current_ref_6", + "ctrl_current_ref_7", + "ctrl_current_ref_8", + "ctrl_current_ref_9", + "ctrl_current_ref_10", + "ctrl_fe_1", + "ctrl_fe_2", + "ctrl_fe_3", + "ctrl_fa_1", + "ctrl_fa_2", + "ctrl_fa_3", + "ctrl_fa_4", + "ctrl_ef_1", + "ctrl_ef_2", + "ctrl_ef_3", + "ctrl_p_1", + "ctrl_p_2", + "ctrl_p_3", + "ctrl_r_1", + "ctrl_r_2", + "ctrl_r_3", + "ctrl_zz_1", + "ctrl_zz_2", + "ctrl_zz_3", + "ctrl_fe_l_1", + "ctrl_fe_l_2", + "ctrl_fe_l_3", + "ctrl_referencia", + "ctrl_i_hems_out_1", + "ctrl_i_hems_out_2", + "ctrl_i_hems_out_3", + "ctrl_i_hems_out_4", + "ctrl_a_1", + "ctrl_a_2", + "ctrl_a_3", + "ctrl_a_4", + "ctrl_a_5", + "ctrl_a_6", + "ctrl_a_7", + "ctrl_a_8", + "ctrl_ak_1", + "ctrl_ak_2", + "ctrl_ak_3", + "ctrl_ak_4", + "ctrl_bk_1", + "ctrl_bk_2", + "ctrl_bk_3", + "ctrl_pi_hems_1", + "ctrl_pi_hems_2", + "ctrl_pi_hems_3", + "ctrl_pi_hems_4", + "ctrl_pi_ems_1", + "ctrl_pi_ems_2", + "ctrl_pi_ems_3", + "ctrl_pi_ems_4", + "ctrl_pi_ems_5", + "ctrl_pi_ems_6" + ], + "period_type": "ms", + "period": 10.0, + "socket": "control_station_udp" + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/LCU/sockets.json b/backend/crates/core/src/adj/test/adj/boards/LCU/sockets.json new file mode 100644 index 000000000..6c6e6b86f --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/LCU/sockets.json @@ -0,0 +1,13 @@ +[ + { + "type": "ServerSocket", + "name": "control_station_tcp", + "port": 50500 + }, + { + "type": "DatagramSocket", + "name": "control_station_udp", + "remote_ip": "192.168.0.9", + "port": 50400 + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/PCU/PCU.json b/backend/crates/core/src/adj/test/adj/boards/PCU/PCU.json new file mode 100644 index 000000000..ffacc86ef --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/PCU/PCU.json @@ -0,0 +1,15 @@ +{ + "board_id": 5, + "mac": "0:0:0:0:0:0", + "board_ip": "192.168.1.5", + "measurements": [ + "PCU_measurements.json" + ], + "packets": [ + "packets.json", + "orders.json" + ], + "sockets": [ + "sockets.json" + ] +} \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/PCU/PCU_measurements.json b/backend/crates/core/src/adj/test/adj/boards/PCU/PCU_measurements.json new file mode 100644 index 000000000..cd68d8b95 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/PCU/PCU_measurements.json @@ -0,0 +1,304 @@ +[ + { + "id": 559, + "alias": "frequency", + "name": "Frequency Commutation hz", + "type": "uint32", + "displayUnits": "Hz" + }, + { + "id": 560, + "alias": "modulation_frequency", + "name": "Modulation Frequency", + "type": "float32", + "displayUnits": "Hz" + }, + { + "id": 561, + "alias": "duty_u", + "name": "Duty U", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 562, + "alias": "duty_v", + "name": "Duty V", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 563, + "alias": "duty_w", + "name": "Duty W", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 564, + "alias": "voltage_battery_a", + "name": "Voltage Battery A", + "type": "float32" + }, + { + "id": 565, + "alias": "voltage_battery_b", + "name": "Voltage Battery B", + "type": "float32" + }, + { + "id": 566, + "alias": "v_ref", + "name": "Reference Voltage", + "type": "float32" + }, + { + "id": 567, + "alias": "frequency_to_send_svpwm", + "name": "Frequency Modulation hz", + "type": "float32", + "displayUnits": "Hz" + }, + { + "id": 568, + "alias": "frequency_to_send_pwm", + "name": "Frequency Commutation hz", + "type": "float32", + "displayUnits": "Hz" + }, + { + "id": 569, + "alias": "current_sensor_u_a", + "name": "Current Sensor U Connector A", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 570, + "alias": "current_sensor_u_b", + "name": "Current Sensor U Connector B", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 571, + "alias": "current_sensor_v_a", + "name": "Current Sensor V Connector A", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 572, + "alias": "current_sensor_v_b", + "name": "Current Sensor V Connector B", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 573, + "alias": "current_sensor_w_a", + "name": "Current Sensor W Connector A", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 574, + "alias": "current_sensor_w_b", + "name": "Current Sensor W Connector B", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 575, + "alias": "state", + "name": "State", + "type": "enum", + "enumValues": [ + "Connecting", + "Idle", + "Accelerating", + "Fault" + ] + }, + { + "id": 576, + "alias": "current_ref", + "name": "Current Ref", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 577, + "alias": "freq_to_send_svpwm", + "name": "Frequency Modulation hz", + "type": "float32", + "displayUnits": "Hz" + }, + { + "id": 578, + "alias": "freq_to_send_pwm", + "name": "Frequency Commutation hz", + "type": "float32", + "displayUnits": "Hz" + }, + { + "id": 579, + "alias": "current_Peak", + "name": "Max Current Peak", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 580, + "alias": "error_pi", + "name": "Actual Current Error", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 581, + "alias": "vf_control", + "name": "Variable frecuency control", + "type": "bool" + }, + { + "id": 582, + "alias": "slip_motor", + "name": "Slip Motor", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 583, + "alias": "imu_speed_km_h", + "name": "Speed IMU", + "type": "float64", + "displayUnits": "km/h" + }, + { + "id": 584, + "alias": "imu_position_m", + "name": "IMU Position", + "type": "float32", + "displayUnits": "m" + }, + { + "id": 585, + "alias": "target_voltage", + "name": "Target Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 586, + "alias": "vmax", + "name": "Max Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 587, + "alias": "speed_ref", + "name": "Reference Speed", + "type": "float32", + "displayUnits": "km/h" + }, + { + "id": 588, + "alias": "svpwm_time", + "name": "SVPWM Time", + "type": "float32", + "displayUnits": "s" + }, + { + "id": 589, + "alias": "target_speed", + "name": "Target Speed KM/H", + "type": "float32", + "displayUnits": "km/h" + }, + { + "id": 590, + "alias": "speed_error", + "name": "Speed Error", + "type": "float64", + "displayUnits": "km/h" + }, + { + "id": 591, + "alias": "actual_current_ref", + "name": "Control Current ref", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 592, + "alias": "imod", + "name": "Modulation Index", + "type": "float32" + }, + { + "id": 593, + "alias": "gd_fault_a", + "name": "GateDriver A Fault", + "type": "bool", + "displayUnits": "", + "protections": [ + { + "type": "Equal", + "value": [ + 1 + ], + "fault": true + } + ] + }, + { + "id": 594, + "alias": "gd_fault_b", + "name": "GateDriver B Fault", + "type": "bool", + "displayUnits": "", + "protections": [ + { + "type": "Equal", + "value": [ + 1 + ], + "fault": true + } + ] + }, + { + "id": 595, + "alias": "gd_ready_a", + "name": "GateDriver A Ready", + "type": "bool", + "displayUnits": "", + "protections": [ + { + "type": "Equal", + "value": [ + 1 + ], + "fault": true + } + ] + }, + { + "id": 596, + "alias": "gd_ready_b", + "name": "GateDriver B Ready", + "type": "bool", + "displayUnits": "", + "protections": [ + { + "type": "Equal", + "value": [ + 1 + ], + "fault": true + } + ] + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/PCU/orders.json b/backend/crates/core/src/adj/test/adj/boards/PCU/orders.json new file mode 100644 index 000000000..180437e93 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/PCU/orders.json @@ -0,0 +1,53 @@ +[ + { + "type": "order", + "name": "FAULT", + "variables": [], + "id": 512 + }, + { + "type": "order", + "name": "Start SVPWM", + "variables": [ + "frequency_to_send_svpwm", + "frequency_to_send_pwm", + "v_ref", + "vmax" + ], + "id": 513 + }, + { + "type": "order", + "name": "Stop Motor", + "variables": [], + "id": 514 + }, + { + "type": "order", + "name": "Send Reference Current", + "variables": [ + "freq_to_send_svpwm", + "freq_to_send_pwm", + "current_ref", + "vmax", + "vf_control" + ], + "id": 515 + }, + { + "type": "order", + "name": "Zeroing", + "variables": [], + "id": 516 + }, + { + "type": "order", + "name": "Send Reference Speed", + "variables": [ + "speed_ref", + "freq_to_send_pwm", + "vmax" + ], + "id": 517 + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/PCU/packets.json b/backend/crates/core/src/adj/test/adj/boards/PCU/packets.json new file mode 100644 index 000000000..64a4254e3 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/PCU/packets.json @@ -0,0 +1,91 @@ +[ + { + "type": "data", + "name": "PWM", + "variables": [ + "frequency", + "modulation_frequency", + "duty_u", + "duty_v", + "duty_w" + ], + "id": 550, + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Batteries Voltage", + "variables": [ + "voltage_battery_a", + "voltage_battery_b" + ], + "id": 551, + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Current Sensors", + "variables": [ + "current_sensor_u_a", + "current_sensor_v_a", + "current_sensor_w_a", + "current_sensor_u_b", + "current_sensor_v_b", + "current_sensor_w_b", + "current_Peak", + "error_pi", + "target_voltage", + "svpwm_time", + "imod" + ], + "id": 552, + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "State Machine States", + "variables": [ + "state" + ], + "id": 553, + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Speed Data", + "variables": [ + "target_speed", + "speed_error", + "actual_current_ref", + "slip_motor", + "imu_speed_km_h", + "imu_position_m" + ], + "id": 555, + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Gate Driver Reporting", + "variables": [ + "gd_fault_a", + "gd_fault_b", + "gd_ready_a", + "gd_ready_b" + ], + "id": 558, + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/PCU/sockets.json b/backend/crates/core/src/adj/test/adj/boards/PCU/sockets.json new file mode 100644 index 000000000..6c6e6b86f --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/PCU/sockets.json @@ -0,0 +1,13 @@ +[ + { + "type": "ServerSocket", + "name": "control_station_tcp", + "port": 50500 + }, + { + "type": "DatagramSocket", + "name": "control_station_udp", + "remote_ip": "192.168.0.9", + "port": 50400 + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/VCU/VCU.json b/backend/crates/core/src/adj/test/adj/boards/VCU/VCU.json new file mode 100644 index 000000000..7203dedeb --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/VCU/VCU.json @@ -0,0 +1,16 @@ +{ + "board_id": 3, + "mac": "0:0:0:0:0:0", + "board_ip": "192.168.1.3", + "measurements": [ + "VCU_measurements.json" + ], + "packets": [ + "orders.json", + "packets.json" + ], + "sockets":[ + "sockets.json" + ] + +} \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/VCU/VCU_measurements.json b/backend/crates/core/src/adj/test/adj/boards/VCU/VCU_measurements.json new file mode 100644 index 000000000..d80dbeea9 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/VCU/VCU_measurements.json @@ -0,0 +1,95 @@ +[ + { + "id": 613, + "alias": "state", + "name": "State", + "type": "enum", + "enumValues": [ + "Idle", + "Connected", + "Maintenance", + "Precharging", + "HVActive", + "Ready", + "Propulsion", + "Static Levitation", + "Dynamic Levitation", + "Fault" + ] + }, + { + "id": 614, + "alias": "sdc_closed", + "name": "SDC Closed", + "type": "bool" + }, + { + "id": 615, + "alias": "high_pressure", + "name": "High Pressure", + "type": "float32", + "displayUnits": "bar" + }, + { + "id": 616, + "alias": "low_pressure", + "name": "Low Pressure", + "type": "float32", + "displayUnits": "bar" + }, + { + "id": 617, + "alias": "pressure_regulator_feedback", + "name": "Pressure Regulator Feedback", + "type": "float32", + "displayUnits": "bar" + }, + { + "id": 618, + "alias": "brakes_status", + "name": "Brakes Status", + "type": "enum", + "enumValues": [ + "BRAKED", + "UNBRAKED" + ] + }, + { + "id": 619, + "alias": "electrovalve_enabled", + "name": "Electrovalve Enabled", + "type": "bool" + }, + { + "id": 620, + "alias": "hvbms_connected", + "name": "HVBMS Connected", + "type": "bool" + }, + { + "id": 621, + "alias": "pcu_connected", + "name": "PCU Connected", + "type": "bool" + }, + { + "id": 622, + "alias": "lcu_connected", + "name": "LCU Connected", + "type": "bool" + }, + { + "id": 623, + "alias": "propulsion_current_reference", + "name": "Propulsion Current Reference", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 624, + "alias": "levitation_target_height", + "name": "Levitation Target Height", + "type": "float32", + "displayUnits": "mm" + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/VCU/orders.json b/backend/crates/core/src/adj/test/adj/boards/VCU/orders.json new file mode 100644 index 000000000..338eb3cc8 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/VCU/orders.json @@ -0,0 +1,87 @@ +[ + { + "id": 600, + "name": "FAULT", + "type": "order", + "variables": [] + }, + { + "id": 601, + "name": "Stop", + "type": "order", + "variables": [] + }, + { + "id": 602, + "name": "Maintenance", + "type": "order", + "variables": [] + }, + { + "id": 603, + "name": "Precharge", + "type": "order", + "variables": [] + }, + { + "id": 604, + "name": "Open Contactors", + "type": "order", + "variables": [] + }, + { + "id": 605, + "name": "Unbrake", + "type": "order", + "variables": [] + }, + { + "id": 606, + "name": "Brake", + "type": "order", + "variables": [] + }, + { + "id": 607, + "name": "Static Levitation", + "type": "order", + "variables": [] + }, + { + "id": 608, + "name": "Propulsion", + "type": "order", + "variables": [] + }, + { + "id": 609, + "name": "Dynamic Levitation", + "type": "order", + "variables": [] + }, + { + "id": 610, + "name": "Propulsion Parameterized", + "type": "order", + "variables": [ + "propulsion_current_reference" + ] + }, + { + "id": 611, + "name": "Static Levitation Parameterized", + "type": "order", + "variables": [ + "levitation_target_height" + ] + }, + { + "id": 612, + "name": "Dynamic Levitation Parameterized", + "type": "order", + "variables": [ + "propulsion_current_reference", + "levitation_target_height" + ] + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/VCU/packets.json b/backend/crates/core/src/adj/test/adj/boards/VCU/packets.json new file mode 100644 index 000000000..c270f8762 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/VCU/packets.json @@ -0,0 +1,62 @@ +[ + { + "type": "data", + "name": "VCU State", + "variables": [ + "state" + ], + "id": 625, + "period_type": "ms", + "period": 10, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Pressures", + "variables": [ + "high_pressure", + "low_pressure", + "pressure_regulator_feedback" + ], + "id": 626, + "period_type": "ms", + "period": 10, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Brake Status", + "variables": [ + "brakes_status" + ], + "id": 627, + "period_type": "ms", + "period": 10, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Outputs", + "variables": [ + "electrovalve_enabled" + ], + "id": 628, + "period_type": "ms", + "period": 10, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Safety", + "variables": [ + "sdc_closed", + "hvbms_connected", + "pcu_connected", + "lcu_connected" + ], + "id": 629, + "period_type": "ms", + "period": 10, + "socket": "control_station_udp" + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/VCU/sockets.json b/backend/crates/core/src/adj/test/adj/boards/VCU/sockets.json new file mode 100644 index 000000000..20af9231b --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/VCU/sockets.json @@ -0,0 +1,13 @@ +[ + { + "type": "ServerSocket", + "name": "control_station_tcp", + "port": 50500 + }, + { + "type": "DatagramSocket", + "name": "control_station_udp", + "remote_ip":"192.168.0.9", + "port": 50400 + } +] diff --git a/backend/crates/core/src/adj/test/adj/general_info.json b/backend/crates/core/src/adj/test/adj/general_info.json new file mode 100644 index 000000000..c0dfc21ba --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/general_info.json @@ -0,0 +1,61 @@ +{ + "ports": { + "TCP_CLIENT": 50401, + "TCP_SERVER": 50500, + "UDP": 50400, + "SNTP": 123, + "TFTP": 69, + "BLCU_UDP": 50420, + "BLCU_TCP_CLIENT": 50421 + }, + "addresses": { + "backend": "192.168.0.9", + "hypervisor": "192.168.0.13" + }, + "protectionTypes": { + "Equal": { + "range": false, + "text": "Value is equal to {} (measured: %)" + }, + "Range": { + "range": true, + "text": "Value out of range {} (measured: %)" + }, + "Below": { + "range": false, + "text": "Value below {} (measured: %)" + } + }, + "units": { + "m/ss": "*1", + "Hz": "*1", + "MOhm": "*1000000", + "ºC": "+273.15", + "deg": "/57.2957", + "km/h": "*3.6", + "ns": "*1", + "m/s": "*1", + "rad": "*1", + "A": "*1", + "%": "/100", + "m": "*1", + "mrad": "/1000", + "KOhm": "*1000", + "V": "*1", + "mV": "/1000", + "Ohm": "*1", + "mm": "/1000", + "G": "*9.8", + "bar": "*1", + "s": "*1" + }, + "message_ids": { + "state_space": 7, + "fault": 2, + "info": 1, + "blcu_ack": 4, + "remove_state_order": 6, + "add_state_order": 5, + "warning": 3 + } +} diff --git a/backend/crates/core/src/hello_world.rs b/backend/crates/core/src/hello_world.rs new file mode 100644 index 000000000..2c8b7da23 --- /dev/null +++ b/backend/crates/core/src/hello_world.rs @@ -0,0 +1,50 @@ +//! Demo de una API documentada con eventos y contexto de `tracing`. +//! +//! El ejecutable configura el subscriber; este módulo solo emite eventos. + +/// Construye un saludo para `name`, eliminando espacios exteriores. +/// +/// Si el nombre está vacío o solo contiene espacios, saluda a `World`. +/// Emite detalles a nivel `debug` y el saludo a nivel `info`, dentro de un +/// span que identifica al destinatario. +/// +/// # Ejemplo +/// +/// ``` +/// use software_core::hello_world::greet; +/// +/// assert_eq!(greet("Hyperloop"), "Hello, Hyperloop!"); +/// assert_eq!(greet(""), "Hello, World!"); +/// ``` +#[tracing::instrument(name = "hello_world", skip(name), fields(recipient = %name))] +pub fn greet(name: &str) -> String { + let trimmed = name.trim(); + let recipient = if trimmed.is_empty() { "World" } else { trimmed }; + + tracing::debug!(used_default = trimmed.is_empty(), "destinatario preparado"); + let greeting = format!("Hello, {recipient}!"); + tracing::info!(greeting = %greeting, "saludo generado"); + greeting +} + +#[cfg(test)] +mod tests { + use super::greet; + + #[test] + fn greets_named_recipient() { + assert_eq!(greet("Hyperloop"), "Hello, Hyperloop!"); + } + + #[test] + fn trims_whitespace_and_preserves_unicode() { + assert_eq!(greet(" Lucía 🚄 \n"), "Hello, Lucía 🚄!"); + } + + #[test] + fn defaults_to_world_for_empty_or_whitespace_names() { + for name in ["", " \t\n"] { + assert_eq!(greet(name), "Hello, World!"); + } + } +} diff --git a/backend/crates/core/src/lib.rs b/backend/crates/core/src/lib.rs new file mode 100644 index 000000000..fb8d888bf --- /dev/null +++ b/backend/crates/core/src/lib.rs @@ -0,0 +1,20 @@ +//! Componentes reutilizables de Hyperloop UPV. + +pub mod hello_world; + +/// NTP server +#[cfg(feature = "ntp")] +pub mod ntp; +/// Basic domain types: identifiers, values, measurements, protections, +/// packets and boards. +pub mod model; + +/// Parses an ADJ (Architecture Description JSON) directory into +/// [`model::PodData`], plus backend-session metadata not modeled as +/// vehicle data. See [`adj::load_from_dir`]. +pub mod adj; + +/// Translates bytes into typed packets and back, using the shapes +/// [`adj`] declares as the schema. See [`protocol::wire`], +/// [`protocol::tcp`], [`protocol::udp`]. +pub mod protocol; diff --git a/backend/crates/core/src/model.rs b/backend/crates/core/src/model.rs new file mode 100644 index 000000000..75cdb319e --- /dev/null +++ b/backend/crates/core/src/model.rs @@ -0,0 +1,28 @@ +//! Basic domain types: identifiers, packets, values, measurements, +//! protections and boards. No I/O and no networking here, just data and +//! the operations performed on it. +//! +//! - `id` — `AdjId`, `BoardId`, `MacAddress`, `Port`. +//! - `value` — `Value`, the result of decoding a measurement. +//! - `measurement` — `Measurement`: what a field of a packet looks like. +//! - `protections` — `Protection`: bounds declared for a measurement. +//! - `packet` — `Packet` and its variants: data, order, +//! protection packet, message. +//! - `board` — `PodData`, `Board`, `PacketDef`: the whole vehicle. + +mod board; +mod id; +mod measurement; +mod packet; +mod protections; +mod value; + +pub use board::{Board, PacketDef, PacketType, PodData}; +pub use id::{AdjId, BoardId, MacAddress, Port}; +pub use measurement::{Measurement, MeasurementKind, NumericKind, Range}; +pub use packet::{ + Data, DataOrderPacket, DataPacket, MessageLevel, MessagePacket, Order, OrderPacket, Packet, + PacketTypeTag, ProtectionPacket, ShortTimestamp, +}; +pub use protections::{Protection, ProtectionKind, Severity}; +pub use value::Value; diff --git a/backend/crates/core/src/model/README.md b/backend/crates/core/src/model/README.md new file mode 100644 index 000000000..20db8642d --- /dev/null +++ b/backend/crates/core/src/model/README.md @@ -0,0 +1,270 @@ +# Model Module + +## Introduction + +This module is meant to store and organize all the different structs, enums and other data structures necessary for the backend project. + +## 1. `board.rs` +All structs and enums needed for the Pod can be found defined in this file. +### 1.1 `PacketType` +Enum used to define what type of packet a `PacketDef` declares. Not every type carries the same shape, so each variant holds whatever measurement data is actually meaningful for it, instead of every packet being forced through one shared field. + +#### `Data(Vec)` +Telemetry sent by a board: one value per measurement, decoded positionally in this order. +#### `Protection(Measurement)` +The single measurement one of a board's declared protections watches. Always exactly one — never a list. +#### `Order(Vec)` +An order sent to a board. Many orders take no parameters at all, so this can be empty. +#### `Message(MessageLevel)` +A free-text log line of this severity. Never carries measurement data — the severity is the only thing that tells one `Message` `PacketDef` apart from another. Lives here, on `PacketType`, rather than as a separate field on `PacketDef`: a message's severity is as much part of "what type of packet is this" as a `Data` packet's measurement list is. + +`PacketType` also exposes a `measurements()` method that flattens any variant into a `&[Measurement]` slice, for code that just wants every measurement a packet references regardless of its type. + +### 1.2 `PacketDef` +Struct used to define the structure of a packet a board can send or recieve. + +#### `id` +ID of a packet, using the AdjId type which is a u16. Structure of ID is defined by the ADJ documentation. +#### `board` +The owning board's `BoardId`. Carried here too (not just reachable through the owning `Board`) so anything holding just a `&PacketDef` — e.g. a lookup table indexed by `AdjId` — knows which board it belongs to without a separate index back to it. +#### `name` +Human-readable String shown to the user to identify packet. +#### `packet_type` +`PacketType` enum: both what type of packet this is, and the measurement shape — if any — specific to that type. + +### 1.3 `Board` +Struct used to identify a single board in the vehicle. + +#### `id` +Board's unique Id in u16 format. +#### `name` +Boards name. Eg. "BSU". +#### `ip` +Board's IpAddress used to open TCP/UDP connections. +#### `mac` +Board's MAC address +#### `packets` +The packets this board can send or receive, as a `HashMap` keyed by id — the ADJ gives packets no meaningful order, so this is a map, not a list. + +### 1.4 `PodData` +Struct used to package all the structs of a vehicle. +#### `boards` +Every board of the vehicle, as a `HashMap` keyed by id — the ADJ doesn't give boards any meaningful order either. + + +## 2. `id.rs` +Defined as their own types (wrapping a `u16`) instead of using a plain `u16`, so the compiler won't let an `AdjId` be confused with a `BoardId` by mistake, even though both are just numbers underneath +### 2.1 `AdjId` +ID of an ADJ packet in u16. Structure of ID can be found in the ADJ documentation. + +For a protection's compound id (the high 3 bits are the protection's position, the low 13 bits the watched measurement's id — see the struct's own docs for the full range table), `AdjId` exposes the split as two methods instead of leaving every caller to redo the shift/mask: +- `protection_position() -> u8`: the high 3 bits (`0` for a normal, non-protection id). +- `measurement_id() -> AdjId`: the id with its high 3 bits zeroed out — the watched measurement's own id. +### 2.2 `BoardId` +Unique identifier of a board, defined by the ADJ documentation. +### 2.3 `MacAddress` +A board's physical MAC address, as 6 raw bytes. + + +## 3. `measurement.rs` +A `Measurement` describes a single field of a packet, as defined by the ADJ: whether it holds a number, a boolean, or the variant of an enum, and, for numeric measurements, its wire type and its safe and warning ranges. This is a *description*, not a decoded value — see`super::Value` for the value itself, once decoded. + +### 3.1 `NumericKind` +Enum used to classify every numerical value type a measurement can use. +- Unsigned 8-bit integer (`U8`) +- Unsigned 16-bit integer (`U16`) +- Unsigned 32-bit integer (`U32`) +- Unsigned 64-bit integer (`U64`) +- Signed 8-bit integer (`I8`) +- Signed 16-bit integer (`I16`) +- Signed 32-bit integer (`I32`) +- Signed 64-bit integer (`I64`) +- 32-bit floating point number (`F32`) +- 64-bit floating point number (`F64`) + +### 3.2 `Range` +Struct used to define a maximum and minimum value of any measurement. +#### `min` +#### `max` + +### 3.3 `MeasurementKind` +Enum used to classify the kind of measurement. +#### `Numeric` +#### `Boolean` +#### `Enum` + +### 3.4 `Measurement` +Struct used to define a measurement. +#### `id` +The measurement's numeric ADJ id. +#### `alias` +The legacy textual identifier, used by code generation +#### `name` +The human-readable string shown to the user. +#### `display_units` +Name of the unit of measurement to display to user. Eg. "ºC". +#### `kind` +Usees MeasurementKind enum to classify what type this measurement holds. +#### `protections` +Vector list of protections. Maximum of 7 per measurement. + +## 4. `packet.rs` +- `Packet` is a single message actually sent or received over the wire, once decoded. This is the *instance* — as opposed to `super::Measurement` and `super::Protection`, which describe what a packet or a protection *can* look like, or `super::PacketDef`, which describes a whole packet. + +### 4.1 `ShortTimestamp` +A short timestamp carrying only the time of day: hour, minute, second and a sub-second fraction, each one byte. Used by `ProtectionPacket` and `MessagePacket`. Only records *when today* something happened; the date is assumed to be the day the packet was received. + +#### `hour` +Hour of Timestamp of values 0–23. +#### `minute` +Minute of Timestamp of values 0–59. +#### `second` +Second of Timestamp of values 0–59. +#### `subsecond` +The sub-second fraction, as the raw byte on the wire. Its exact unit (e.g. milliseconds, or 1/256ths of a second) hasn't been confirmed yet, so it is kept as a raw `u8` here instead of converted to something like a `Duration` — convert it once the unit is settled. + +### 4.2 `DataOrderPacket` — a phantom type +`DataPacket` and `OrderPacket` hold the exact same shape (`id: AdjId`, `values: Vec`, both positional — see below), so they're both built on one generic struct, `DataOrderPacket`, instead of being copy-pasted. The two used to just be `type` aliases for the very same concrete struct (`type OrderPacket = DataOrderPacket; type DataPacket = DataOrderPacket;`) — which meant they weren't actually distinct types at all: nothing stopped a `DataPacket` just decoded off the UDP socket from being passed to `protocol::tcp::encode_order` (which wants an *order* to send) by mistake, the compiler had no way to tell them apart. + +`Kind` fixes that. It's a *phantom* type parameter — `DataOrderPacket` doesn't actually store a `Kind` value (there's nothing to store, see `Data`/`Order` below), it only exists so the compiler tracks which one a given packet was built as: +```rust +pub struct DataOrderPacket { + pub id: AdjId, + pub values: Vec, + _marker: PhantomData, +} +``` +`_marker` is private, so a `DataOrderPacket` can't be built with struct-literal syntax from outside `packet.rs` — use `DataOrderPacket::new(id, values)` (or, through the aliases below, `DataPacket::new(...)`/`OrderPacket::new(...)`) instead. + +#### `Data` / `Order` +Two empty marker structs — the only two types `Kind` is ever instantiated with. Neither is ever actually constructed; they only ever appear as a type argument. That's what makes the whole thing *phantom*: the type parameter has no representation in the compiled struct, it's erased after type-checking, the same way `id.rs` (§2) uses distinct `AdjId`/`BoardId` types so the compiler won't confuse two different kinds of `u16`. + +#### `PacketTypeTag` +The compiler erases `Kind` after type-checking, so nothing at runtime can look at a `DataOrderPacket` and ask "is this actually a `Data` one?" — `PacketTypeTag` is how code that's generic over `Kind` answers that anyway, by asking the question of the *type* instead of the *value*: +```rust +pub trait PacketTypeTag { + fn matches(packet_type: &PacketType) -> bool; +} +``` +`Data`/`Order` each implement it by checking which `PacketType` (§1.1) variant they stand for (`matches!(packet_type, PacketType::Data(_))` / `PacketType::Order(_)`). This is the only thing `PacketTypeTag` decides — once a `PacketDef`'s type is confirmed to match, the actual measurement list still comes from the existing `PacketType::measurements()` (§1.1), not from `PacketTypeTag` itself. + +`protocol::packet::data_order`'s `encode`/`decode` use this to be generic over `Kind`: written once, they work for both `DataPacket` and `OrderPacket` instead of needing a near-duplicate pair of functions per type. + +#### `DataPacket` / `OrderPacket` +```rust +pub type DataPacket = DataOrderPacket; +pub type OrderPacket = DataOrderPacket; +``` +Now genuinely different types: a function that takes an `OrderPacket` cannot be called with a `DataPacket`, even though both just wrap the same `(AdjId, Vec)` underneath — the mix-up is a compile error instead of a bug waiting to happen. `DataOrderPacket`'s own doc comment has a `compile_fail` doctest proving exactly that (run by `cargo test`, like any other doctest). + +`DataPacket` holds the decoded values of the measurements a board just sent — positional, not individually tagged with their measurement ID, just written in the order their `Measurement`s appear in this packet's `PacketType::Data` list. + +`OrderPacket` holds an order sent from the backend to a board — positional the same way, matching `PacketType::Order`'s measurement list. + +#### `id` +Which packet/order definition this is an instance of (`AdjId`). +#### `values` +Vector list of decoded values (`Vec`) corresponding positionally to the packet definition's measurements. + +### 4.3 `ProtectionPacket` +Sent by a board when one of its declared protections triggers. + +#### `id` +The compound wire id this protection triggered under (`AdjId`). Use `id.measurement_id()` for the measurement it watches and `id.protection_position()` for which of that measurement's protections this is (see `Protection::id`). Kept as a single field rather than storing both halves separately — they're never independent of each other, so a second field would only add a second place for them to disagree. +#### `timestamp` +When the protection triggered (`ShortTimestamp`). +#### `value` +The value that triggered the protection (`Value`), decoded with the same wire type as the watched measurement. + +### 4.4 `MessageLevel` +Enum indicating how severe a message is (reusing `general_info.json`'s `message_ids`). Distinct from `Severity` because message and protection formats are separate in ADJv3. + +#### `Fault` +Stops the vehicle automatically. +#### `Warning` +Flags something unexpected, without stopping the vehicle. +#### `Info` +Informational message, no action implied. +#### `Panic` +An unrecoverable firmware error. + +`MessageLevel` also exposes: +- `ALL: [MessageLevel; 4]` — every severity, for code that needs to go through each one (e.g. building a lookup from a loaded ADJ's `message_ids`). +- `name(self) -> &'static str` — this severity's key in `general_info.json`'s `message_ids` (`"fault"`, `"warning"`, `"info"`, `"panic"`). +- `from_name(name: &str) -> Option` — the inverse of `name()`; `None` if `name` isn't one of the four. + +This is deliberately where the name↔severity association lives — in `model`, not `adj` or `protocol` — since it's domain knowledge about the severity itself. `adj::AdjInfo::message_levels` (see `adj/README.md`) calls into `name()`/`ALL` to build its id→severity lookup rather than hardcoding `"fault"`/`"warning"`/`"info"`/`"panic"` itself. + +### 4.5 `MessagePacket` +A free-text message sent by a board. + +#### `id` +The wire id this message was sent under — one of the severities declared in `general_info.json`'s `message_ids`. Not a `MessageLevel` directly, the same way `ProtectionPacket::id` isn't a `Protection`: resolving it to a severity means looking it up (`PacketType::Message`/`protocol::Index`), not storing a second, redundant copy of it here. +#### `timestamp` +When the message was sent (`ShortTimestamp`). +#### `origin` +Source identifier, such as a source file path (`String`). +#### `message` +The text contents of the message (`String`). + +### 4.6 `Packet` +Enum representing any packet actually sent or received over the wire, once decoded. + +#### `Data` +Wraps a `DataPacket` (measurement data sent by a board). +#### `Order` +Wraps an `OrderPacket` (command sent to a board). +#### `Protection` +Wraps a `ProtectionPacket` (triggered protection event). +#### `Message` +Wraps a `MessagePacket` (free-text log/diagnostic message). + +## 5. `protection.rs` +A `Protection` is a bound checked against a measurement's decoded value, as declared in the ADJ (`_measurements.json`). This defines the *declaration* (what to watch for, thresholds, and severity) rather than the runtime wire packet emitted when triggered (`ProtectionPacket` in `packet.rs`). In ADJv3, protections and messages are distinct formats with separate lifecycles. + +### 5.1 `ProtectionKind` +Enum defining the condition a protection evaluates and the associated threshold bound(s). Modeled as data-carrying variants to ensure compile-time validity instead of untyped lists. + +#### `Above` +Triggers when the value exceeds `limit` (`f64`). +#### `Below` +Triggers when the value drops below `limit` (`f64`). +#### `Equal` +Triggers when the value equals forbidden `target` (`f64`). +#### `NotEqual` +Triggers when the value deviates from expected `target` (`f64`). +#### `Range` +Triggers when the value leaves the closed `[min, max]` interval (`min: f64`, `max: f64`). Both bounds are strictly required. + +### 5.2 `Severity` +Enum indicating the operational severity level of a triggered protection. + +#### `Fault` +Critical error that stops the vehicle automatically. +#### `Warning` +Flags the condition as anomalous without halting vehicle operation. + +### 5.3 `Protection` +Struct defining a single protection rule attached to a measurement. + +#### `id` +The protection's relative slot index within its parent measurement's protection list, stored as an `AdjId` in the `1–7` range. This is a local index rather than a global ID; it forms the upper 3 bits of the compound wire identifier alongside the 13-bit measurement ID. +#### `kind` +The evaluation rule and threshold bounds (`ProtectionKind`). +#### `severity` +The operational impact level when triggered (`Severity`). +#### `time` +Duration the measured value must remain out of bounds before the protection trips (`Duration`). A value of `Duration::ZERO` indicates an instantaneous trigger. + +## 6. `value.rs` +Defines how the backend represents a piece of data once decoded, regardless of its original wire representation. While the ADJ supports various integer and floating-point types on the wire (captured during decoding by `NumericKind`), all numeric values normalize to `f64` once inside the backend. + +### 6.1 `Value` +Enum representing a single decoded measurement or field value. + +#### `Number(f64)` +A numeric measurement, regardless of its original wire type (`u8`, `i32`, `f32`, etc.). +#### `Bool(bool)` +A boolean measurement (`true` or `false`). +#### `Enum(String)` +The selected variant name of an enumerated measurement (e.g. `"Operational"`). \ No newline at end of file diff --git a/backend/crates/core/src/model/board.rs b/backend/crates/core/src/model/board.rs new file mode 100644 index 000000000..2dc56b6b7 --- /dev/null +++ b/backend/crates/core/src/model/board.rs @@ -0,0 +1,150 @@ +//! Boards and their packet definitions. +//! +//! [`PodData`] is the whole vehicle as described by the ADJ: every board, +//! and every packet each board can send or receive. +//! +//! This module only carries fields we have solid grounds for: `id`, +//! `name`, whether a packet is data or an order, and its +//! measurements. Framing details that might still apply per packet (cycle +//! time, raw byte count...) are not confirmed for ADJv3 yet, so they are +//! left out rather than guessed — add them once the packet-level JSON +//! shape is settled. + +use super::{AdjId, BoardId, MacAddress, Measurement, MessageLevel}; +use std::collections::HashMap; +use std::net::IpAddr; + +/// What type of packet a [`PacketDef`] declares, carrying whatever +/// measurement shape — if any — is specific to that type. Not every type +/// has measurements, so this says so in the type instead of forcing every +/// [`PacketDef`] through one shared `measurements: Vec` field +/// regardless of whether that's meaningful for it. +#[derive(Debug, Clone, PartialEq)] +pub enum PacketType { + /// A board's periodic telemetry: one value per measurement, decoded + /// positionally in this order. + Data(Vec), + /// The single measurement one of a board's declared protections + /// watches. + Protection(Measurement), + /// A command sent to a board. Many orders take no parameters at all. + Order(Vec), + /// A free-text log line of this severity. Never carries measurement + /// data — the severity is the only thing that tells one `Message` + /// `PacketDef` apart from another. + Message(MessageLevel), +} + +impl PacketType { + /// Every measurement this packet's type references, flattened — for + /// code that doesn't care which type this is, just what measurements + /// it touches. + pub fn measurements(&self) -> &[Measurement] { + match self { + PacketType::Data(measurements) | PacketType::Order(measurements) => measurements, + PacketType::Protection(measurement) => std::slice::from_ref(measurement), + PacketType::Message(_) => &[], + } + } +} + +/// The declaration of a single packet a board can send or receive, as +/// defined by the ADJ. +/// +/// This is the *declaration* — see [`super::Packet`] for a decoded +/// instance actually sent or received over the wire. +#[derive(Debug, Clone, PartialEq)] +pub struct PacketDef { + /// The packet's numeric ADJ id. + pub id: AdjId, + /// The board this packet belongs to. A `PacketDef` is always reached + /// through its owning [`Board`] already, but carrying the id here too + /// means anything holding just a `&PacketDef` (e.g. a lookup table + /// indexed by [`AdjId`]) knows which board it is without a second, + /// separate index back to it. + pub board: BoardId, + /// The human-readable name shown to the user. + pub name: String, + /// Whether this is data, order, protection or message — and the + /// measurement shape specific to that type, if any. + pub packet_type: PacketType, +} + +/// A single board of the vehicle, as defined by the ADJ. +#[derive(Debug, Clone, PartialEq)] +pub struct Board { + /// The board's unique id. + pub id: BoardId, + /// The board's name, e.g. `"BCU"`. + pub name: String, + /// The board's IP address, used to open its TCP/UDP connections. + pub ip: IpAddr, + /// The board's MAC address (ADJv3 spec, section 5). + pub mac: MacAddress, + /// The packets this board can send or receive, keyed by id — same + /// reasoning as [`PodData::boards`], the ADJ gives this list no + /// meaningful order either. + pub packets: HashMap, +} + +/// The whole vehicle, as described by the ADJ: every board and every +/// packet each one can send or receive. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct PodData { + /// Every board of the vehicle, keyed by its id — the ADJ doesn't give + /// boards any meaningful order, so iterating this in some particular + /// sequence is never something to rely on. + pub boards: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::MeasurementKind; + + fn sample_measurement() -> Measurement { + Measurement { + id: AdjId(3000), + alias: "temperature_1".to_string(), + name: "Temperature 1".to_string(), + display_units: "ºC".to_string(), + kind: MeasurementKind::Boolean, + protections: vec![], + } + } + + #[test] + fn an_empty_pod_data_has_no_boards() { + let pod_data = PodData::default(); + assert!(pod_data.boards.is_empty()); + } + + #[test] + fn a_board_groups_its_packets_and_their_measurements() { + let board = Board { + id: BoardId(1), + name: "BCU".to_string(), + ip: IpAddr::from([192, 168, 0, 10]), + mac: MacAddress([0x00, 0x11, 0x22, 0x33, 0x44, 0x55]), + packets: HashMap::from([( + AdjId(1000), + PacketDef { + id: AdjId(1000), + board: BoardId(1), + name: "bcu_data".to_string(), + packet_type: PacketType::Data(vec![sample_measurement()]), + }, + )]), + }; + + assert_eq!(board.packets.len(), 1); + let Some(packet) = board.packets.get(&AdjId(1000)) else { + unreachable!("the packet we just inserted should be there"); + }; + let PacketType::Data(measurements) = &packet.packet_type else { + unreachable!("packet should be Data"); + }; + assert_eq!(measurements.len(), 1); + assert_eq!(board.mac.to_string(), "00:11:22:33:44:55"); + } +} diff --git a/backend/crates/core/src/model/id.rs b/backend/crates/core/src/model/id.rs new file mode 100644 index 000000000..948e71b98 --- /dev/null +++ b/backend/crates/core/src/model/id.rs @@ -0,0 +1,117 @@ +//! Domain identifiers. +//! +//! Defined as their own types (wrapping a `u16`) instead of using a plain +//! `u16`, so the compiler won't let an [`AdjId`] be confused with a +//! [`BoardId`] by mistake, even though both are just numbers underneath. + +/// Unique identifier of a packet, order or protection, defined by the ADJ. +/// Ranges +/// - `[0, 511]` | `[0x0000, 0x01FF]` for special range +/// - `[512, 8191]` | `[0x0200, 0x1FFF]` for packets, orders and measurements +/// - `[8192, 65535]` | `[0x2000, 0xFFFF]` for protections: each protection is associated with a measurement, so the low 13 bits of the compound id are the measurement id, and the high 3 bits are the protection's position for that measurement (1-7; 0 is reserved for a normal, non-protection id) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AdjId(pub u16); + +impl AdjId { + /// The high 3 bits: `0` for a normal `packet`/`order`/`measurement` + /// id, `1`-`7` for which of the watched measurement's protections this + /// is. + pub fn protection_position(self) -> u8 { + (self.0 >> 13) as u8 + } + + /// The watched measurement's id: this id with its high 3 bits (the + /// protection's position) zeroed out. + /// + /// For a normal, non-protection id (`protection_position() == 0`), + /// this is just the id itself. + pub fn measurement_id(self) -> AdjId { + AdjId(self.0 & 0x1FFF) + } +} + +/// Unique identifier of a board, defined by the ADJ. +/// +/// `BoardId(0)` is reserved: no real board uses it. [`crate::protocol::Index`] +/// uses it as the `board` for a synthesized [`super::PacketDef`] that +/// isn't owned by any one board at all — a message severity, a +/// session-wide id any board's free-text log frame can reuse. Not +/// validated anywhere; just a convention, the same way `0` is excluded +/// from [`AdjId`]'s own special range above. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BoardId(pub u16); + +/// A TCP/UDP port number, as declared in `general_info.json`'s `ports`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Port(pub u16); + +/// A board's physical MAC address, as 6 raw bytes. +/// +/// Kept as a fixed-size byte array instead of a `String` on purpose: a +/// malformed or miswritten address (the ADJv3 meeting notes mention a real +/// `67:67:67:67:67:67` that caused hours of debugging) should not be able +/// to reach this type at all. Parsing the usual `"aa:bb:cc:dd:ee:ff"` text +/// form is `adj`'s job, not this type's. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacAddress(pub [u8; 6]); + +impl std::fmt::Debug for MacAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "MacAddress({self})") + } +} + +impl std::fmt::Display for MacAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let [a, b, c, d, e, g] = self.0; + write!(f, "{a:02x}:{b:02x}:{c:02x}:{d:02x}:{e:02x}:{g:02x}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_packet_ids_with_the_same_number_are_equal() { + assert_eq!(AdjId(1000), AdjId(1000)); + } + + #[test] + fn a_port_and_a_board_id_cannot_be_compared_to_each_other() { + // This wouldn't compile if you tried it: Port(1) == BoardId(1) + let port = Port(50500); + let board = BoardId(50500); + assert_eq!(port.0, board.0); // only the inner numbers can be compared + } + + #[test] + fn a_mac_address_displays_as_colon_separated_lowercase_hex() { + let mac = MacAddress([0xaa, 0xbb, 0xcc, 0x0d, 0x0e, 0xff]); + assert_eq!(mac.to_string(), "aa:bb:cc:0d:0e:ff"); + } + + #[test] + fn packet_id_and_board_id_cannot_be_compared_to_each_other() { + // This wouldn't compile if you tried it: AdjId(1) == BoardId(1) + // That's the whole point of this file: the compiler protects us. + let packet = AdjId(1); + let board = BoardId(1); + assert_eq!(packet.0, board.0); // only the inner numbers can be compared + } + + #[test] + fn a_normal_ids_position_is_zero_and_it_is_its_own_measurement_id() { + let id = AdjId(3000); + assert_eq!(id.protection_position(), 0); + assert_eq!(id.measurement_id(), AdjId(3000)); + } + + #[test] + fn a_protections_compound_id_splits_into_position_and_measurement() { + // (3 << 13) | 100 = 24576 | 100 = 24676 + let compound = AdjId((3u16 << 13) | 100); + assert_eq!(compound.protection_position(), 3); + assert_eq!(compound.measurement_id(), AdjId(100)); + } +} diff --git a/backend/crates/core/src/model/measurement.rs b/backend/crates/core/src/model/measurement.rs new file mode 100644 index 000000000..198bed4ec --- /dev/null +++ b/backend/crates/core/src/model/measurement.rs @@ -0,0 +1,162 @@ +//! Measurement descriptions. +//! +//! A [`Measurement`] describes a single field of a packet, as defined by +//! the ADJ: whether it holds a number, a boolean, or the variant of an +//! enum, and, for numeric measurements, its wire type and its safe and +//! warning ranges. This is a *description*, not a decoded value — see +//! [`super::Value`] for the value itself, once decoded. + +use super::protections::Protection; +use crate::model::AdjId; + +/// The wire type of a numeric measurement: how many bytes it takes on the +/// wire and whether it is signed. +/// +/// This only matters while decoding. Once a number is decoded it is always +/// stored as a [`super::Value::Number`], regardless of its original wire +/// type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NumericKind { + /// Unsigned 8-bit integer. + U8, + /// Unsigned 16-bit integer. + U16, + /// Unsigned 32-bit integer. + U32, + /// Unsigned 64-bit integer. + U64, + /// Signed 8-bit integer. + I8, + /// Signed 16-bit integer. + I16, + /// Signed 32-bit integer. + I32, + /// Signed 64-bit integer. + I64, + /// 32-bit floating point number. + F32, + /// 64-bit floating point number. + F64, +} + +/// An inclusive range with an optional lower and upper bound. +/// +/// Either bound can be missing, meaning that side of the range is +/// unconstrained. For example, `Range { min: Some(0.0), max: None }` means +/// "any value of 0.0 or above". +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Range { + /// The lower bound, if any. + pub min: Option, + /// The upper bound, if any. + pub max: Option, +} + +/// What kind of data a measurement holds, and the details specific to that +/// kind. +#[derive(Debug, Clone, PartialEq)] +pub enum MeasurementKind { + /// A numeric measurement. + Numeric { + /// How the value is encoded on the wire. + wire_type: NumericKind, + }, + /// A boolean measurement. + Boolean, + /// A measurement whose value is one of a fixed set of named variants. + Enum { + /// The variants this measurement can take, in ADJ order. + options: Vec, + }, +} + +/// The description of a single field of a packet, as defined by the ADJ. +#[derive(Debug, Clone, PartialEq)] +pub struct Measurement { + /// The measurement's numeric ADJ id, e.g. `3000`. + pub id: AdjId, + + /// The legacy textual identifier, used by code generation. + pub alias: String, + + /// The human-readable name shown to the user. + pub name: String, + + /// The name of the unit to display at the frontend, e.g. `"ºC"`. + pub display_units: String, + + /// What kind of data this measurement holds. + pub kind: MeasurementKind, + + /// The protections that are checked for this measurement, in ADJ + /// order. The ADJ limits this list to 7 entries per measurement, but + /// that limit is enforced by the ADJ validator, not by this type. + pub protections: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base(kind: MeasurementKind) -> Measurement { + Measurement { + id: AdjId(3000), + alias: "temperature_1".to_string(), + name: "Temperature 1".to_string(), + display_units: "ºC".to_string(), + kind, + protections: vec![], + } + } + + #[test] + fn a_numeric_measurement_carries_its_wire_type() { + let measurement = base(MeasurementKind::Numeric { + wire_type: NumericKind::F32, + }); + + let MeasurementKind::Numeric { wire_type } = measurement.kind else { + unreachable!("measurement was just constructed as Numeric above"); + }; + + assert_eq!(wire_type, NumericKind::F32); + } + + #[test] + fn an_enum_measurement_lists_its_options_in_adj_order() { + let measurement = base(MeasurementKind::Enum { + options: vec![ + "Idle".to_string(), + "Operational".to_string(), + "Fault".to_string(), + ], + }); + + let MeasurementKind::Enum { options } = measurement.kind else { + unreachable!("measurement was just constructed as Enum above"); + }; + + assert_eq!(options, vec!["Idle", "Operational", "Fault"]); + } + + #[test] + fn two_measurements_with_the_same_data_are_equal() { + let a = base(MeasurementKind::Boolean); + let b = a.clone(); + assert_eq!(a, b); + } + + #[test] + fn measurements_with_different_protections_are_not_equal() { + let a = base(MeasurementKind::Boolean); + let mut b = a.clone(); + b.protections.push(Protection { + id: AdjId(1), + kind: crate::model::ProtectionKind::Above { limit: 100.0 }, + severity: crate::model::Severity::Fault, + time: std::time::Duration::ZERO, + }); + + assert_ne!(a, b); + } +} diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs new file mode 100644 index 000000000..c3dda408d --- /dev/null +++ b/backend/crates/core/src/model/packet.rs @@ -0,0 +1,374 @@ +//! Wire packets. +//! +//! A [`Packet`] is a single message actually sent or received over the +//! wire, once decoded. This is the *instance* — as opposed to +//! [`super::Measurement`] and [`super::Protection`], which describe what a +//! packet or a protection *can* look like, or [`super::PacketDef`], which +//! describes a whole packet. + +use super::{AdjId, PacketType, Value}; +use std::marker::PhantomData; + +/// A short timestamp carrying only the time of day: hour, minute, second +/// and a sub-second fraction, each one byte. +/// +/// Used by [`ProtectionPacket`] and [`MessagePacket`]. Unlike a full +/// calendar timestamp, this only records *when today* something happened; +/// the date is assumed to be the day the packet was received. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ShortTimestamp { + /// The hour, 0–23. + pub hour: u8, + /// The minute, 0–59. + pub minute: u8, + /// The second, 0–59. + pub second: u8, + /// The sub-second fraction, as the raw byte on the wire. + /// + /// This byte was labeled `resv` ("reserved for alignment") at first, + /// but it is in fact used for sub-second resolution. Its exact unit + /// (e.g. milliseconds, or 1/256ths of a second) hasn't been confirmed + /// yet, so it is kept as a raw `u8` here instead of converted to + /// something like a `Duration` — convert it once the unit is settled. + pub subsecond: u8, +} + +/// Tags a [`DataOrderPacket`] as a board's periodic telemetry +/// ([`DataPacket`]) rather than a command sent to one. +/// +/// Carries no data — see [`DataOrderPacket`] for why it exists at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Data; + +/// Tags a [`DataOrderPacket`] as a command sent to a board +/// ([`OrderPacket`]) rather than telemetry received from one. +/// +/// Carries no data — see [`DataOrderPacket`] for why it exists at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Order; + +/// Bridges a [`DataOrderPacket`]'s phantom `Kind` to the runtime +/// [`PacketType`] variant it stands for. +/// +/// `protocol::packet::data_order`'s `encode`/`decode` are generic over +/// `Kind`: they're written once and work for both [`Data`] and [`Order`], +/// instead of needing a near-identical copy per type. This trait is the +/// one yes/no question that generic code can't answer on its own — +/// "is this `PacketDef`'s type actually the one I was asked to +/// encode/decode?" — everything after that (pulling out the +/// measurements) is already handled by [`PacketType::measurements`], +/// type-agnostic, so there's no need to redo that here. +pub trait PacketTypeTag { + /// Whether `packet_type` is the variant this `Kind` stands for. + fn matches(packet_type: &PacketType) -> bool; +} + +impl PacketTypeTag for Data { + fn matches(packet_type: &PacketType) -> bool { + matches!(packet_type, PacketType::Data(_)) + } +} + +impl PacketTypeTag for Order { + fn matches(packet_type: &PacketType) -> bool { + matches!(packet_type, PacketType::Order(_)) + } +} + +/// A Data/Order packet: the decoded values of the measurements a board just +/// sent, or that an order is about to send to one. +/// +/// The wire protocol is positional: values are not individually tagged +/// with their measurement's id, just written back to back in the order +/// their [`super::Measurement`]s appear in this packet's +/// [`super::PacketType::Data`] list. `values[i]` is the decoded value of +/// that list's `i`-th measurement — matching them up by position is +/// `protocol`'s job, this type just holds the result in that same order. +/// +/// `Kind` is a phantom type parameter (either [`Data`] or [`Order`], never +/// anything else — see [`DataPacket`]/[`OrderPacket`]): the struct stores +/// no `Kind` value, nothing to store (`Data`/`Order` carry no data), it +/// only exists so the compiler can tell a [`DataPacket`] apart from an +/// [`OrderPacket`], the same way [`super::AdjId`]/[`super::BoardId`] +/// (`id.rs`) are distinct types so the compiler won't let one be confused +/// with the other, even though both are just a `u16` underneath. Before +/// this, [`DataPacket`] and [`OrderPacket`] were both plain aliases for +/// the same concrete struct — nothing stopped a `DataPacket`, decoded off +/// the UDP socket, from being passed to [`crate::protocol::tcp::encode_order`] +/// by mistake; that mix-up is now a compile error: +/// +/// ```compile_fail +/// use software_core::model::{AdjId, DataPacket, OrderPacket}; +/// +/// fn send_order(_order: OrderPacket) {} +/// +/// let data = DataPacket::new(AdjId(1000), vec![]); +/// send_order(data); // doesn't compile: `DataPacket` isn't an `OrderPacket` +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct DataOrderPacket { + /// The packet's id. + pub id: AdjId, + /// Each measurement's decoded value, in the same order as the + /// matching [`super::PacketType::Data`]/[`super::PacketType::Order`] + /// list. + pub values: Vec, + _marker: PhantomData, +} + +impl DataOrderPacket { + /// Builds a packet tagged as `Kind`, e.g. `DataPacket::new(id, values)` + /// or `OrderPacket::new(id, values)`. + pub fn new(id: AdjId, values: Vec) -> Self { + Self { + id, + values, + _marker: PhantomData, + } + } +} + +/// An order sent from the backend to a board. +/// +/// Positional, the same way [`DataPacket::values`] is: see its docs. +pub type OrderPacket = DataOrderPacket; +/// A set of measurements sent by a board. +/// +/// Positional, the same way [`DataOrderPacket::values`] is: see its docs. +pub type DataPacket = DataOrderPacket; + +/// A protection packet: sent by a board when one of its declared +/// [`super::Protection`]s triggers. +/// +/// `id` is the compound wire id: [`AdjId::measurement_id`] gives the +/// watched measurement, [`AdjId::protection_position`] which of that +/// measurement's protections triggered (see [`super::Protection::id`]). +/// Kept as a single field rather than storing both halves separately — +/// they're never independent of each other, so there's nothing a second +/// field would add beyond a second place for them to disagree. +#[derive(Debug, Clone, PartialEq)] +pub struct ProtectionPacket { + /// The compound id this protection triggered under. + pub id: AdjId, + /// When it triggered. + pub timestamp: ShortTimestamp, + /// The value that triggered it, decoded with the same wire type as the + /// watched measurement. + pub value: Value, +} + +/// How severe a message is (reusing `general_info.json`'s `message_ids`). +/// +/// Unlike [`super::Severity`], which only distinguishes fault from warning +/// for protections, a message has two extra levels: `messages` and +/// `protections` are unrelated wire formats as of ADJv3, so this is a +/// separate type on purpose, not a reuse of [`super::Severity`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MessageLevel { + /// Stops the vehicle automatically. + Fault, + /// Flags something unexpected, without stopping the vehicle. + Warning, + /// Informational, no action implied. + Info, + /// An unrecoverable firmware error. + Panic, +} + +impl MessageLevel { + /// All four severities, in no particular order — for code that needs + /// to go through each one, e.g. building a lookup from a loaded + /// ADJ's `general_info.json` `message_ids` (see [`MessageLevel::name`]). + pub const ALL: [MessageLevel; 4] = [ + MessageLevel::Fault, + MessageLevel::Warning, + MessageLevel::Info, + MessageLevel::Panic, + ]; + + /// This severity's key in `general_info.json`'s `message_ids` map + /// (ADJv3: `"fault"`, `"warning"`, `"info"`, `"panic"`). That map + /// only gives a severity's *name*, not which `MessageLevel` it is — + /// this is the one place that association is spelled out, so + /// nothing else has to hardcode these four strings again. + pub fn name(self) -> &'static str { + match self { + MessageLevel::Fault => "fault", + MessageLevel::Warning => "warning", + MessageLevel::Info => "info", + MessageLevel::Panic => "panic", + } + } + + /// The inverse of [`MessageLevel::name`]: the severity named `name`, + /// or `None` if `name` isn't one of the four (e.g. `general_info.json` + /// declares other, unrelated reserved names in that same map). + pub fn from_name(name: &str) -> Option { + MessageLevel::ALL + .into_iter() + .find(|level| level.name() == name) + } +} + +/// A free-text message sent by a board. +#[derive(Debug, Clone, PartialEq)] +pub struct MessagePacket { + /// The wire id this message was sent under — one of the severities + /// declared in `general_info.json`'s `message_ids`. + /// + /// Not a [`MessageLevel`] directly, the same way [`ProtectionPacket::id`] + /// isn't a [`super::Protection`]: this is what was actually on the + /// wire, and resolving it to a severity means looking it up (see + /// [`PacketType::Message`]/[`crate::protocol::Index`]), not storing + /// a second, redundant copy of it here. + pub id: AdjId, + /// When it was sent. + pub timestamp: ShortTimestamp, + /// Where it came from, e.g. a source file path. + pub origin: String, + /// The message's contents. + pub message: String, +} + +/// Any packet actually sent or received over the wire, once decoded. +#[derive(Debug, Clone, PartialEq)] +pub enum Packet { + /// A set of measurements sent by a board. + Data(DataPacket), + /// An order sent to a board. + Order(OrderPacket), + /// A protection that triggered. + Protection(ProtectionPacket), + /// A free-text message sent by a board. + Message(MessagePacket), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_timestamps_with_the_same_time_of_day_are_equal() { + let a = ShortTimestamp { + hour: 10, + minute: 30, + second: 0, + subsecond: 0, + }; + let b = ShortTimestamp { + hour: 10, + minute: 30, + second: 0, + subsecond: 0, + }; + assert_eq!(a, b); + } + + #[test] + fn two_timestamps_that_only_differ_in_subsecond_are_not_equal() { + let a = ShortTimestamp { + hour: 10, + minute: 30, + second: 0, + subsecond: 0, + }; + let b = ShortTimestamp { + hour: 10, + minute: 30, + second: 0, + subsecond: 128, + }; + assert_ne!(a, b); + } + + #[test] + fn a_protection_packets_id_splits_into_its_measurement_and_its_own_position() { + // (1 << 13) | 3000 = 8192 | 3000 = 11192 + let packet = ProtectionPacket { + id: AdjId((1u16 << 13) | 3000), + timestamp: ShortTimestamp { + hour: 12, + minute: 0, + second: 5, + subsecond: 0, + }, + value: Value::Number(53.2), + }; + + assert_eq!(packet.id.measurement_id(), AdjId(3000)); + assert_eq!(packet.id.protection_position(), 1); + } + + #[test] + fn message_level_has_four_distinct_values() { + let levels = [ + MessageLevel::Fault, + MessageLevel::Warning, + MessageLevel::Info, + MessageLevel::Panic, + ]; + + for (i, a) in levels.iter().enumerate() { + for (j, b) in levels.iter().enumerate() { + assert_eq!(a == b, i == j); + } + } + } + + #[test] + fn each_message_levels_name_matches_the_adj_and_is_distinct() { + let names: Vec<&str> = MessageLevel::ALL.iter().map(|level| level.name()).collect(); + assert_eq!(names, vec!["fault", "warning", "info", "panic"]); + } + + #[test] + fn from_name_is_the_inverse_of_name() { + for level in MessageLevel::ALL { + assert_eq!(MessageLevel::from_name(level.name()), Some(level)); + } + assert_eq!(MessageLevel::from_name("not_a_severity"), None); + } + + #[test] + fn data_packet_values_are_positional_not_tagged_with_an_id() { + // values[0] is whatever measurements[0] is in this packet's + // PacketDef — there is no id stored alongside each value here. + let packet = DataPacket::new(AdjId(1000), vec![Value::Number(53.2), Value::Bool(true)]); + + assert_eq!(packet.values[0], Value::Number(53.2)); + assert_eq!(packet.values[1], Value::Bool(true)); + } + + #[test] + fn packet_wraps_each_kind_without_mixing_them_up() { + let data = Packet::Data(DataPacket::new(AdjId(1000), vec![])); + let order = Packet::Order(OrderPacket::new(AdjId(2000), vec![])); + + assert_ne!(data, order); + match data { + Packet::Data(_) => {} + _ => unreachable!("this is a Packet::Data"), + } + } + + #[test] + fn two_data_packets_with_the_same_id_and_values_are_equal() { + let a = DataPacket::new(AdjId(1000), vec![Value::Number(1.0)]); + let b = DataPacket::new(AdjId(1000), vec![Value::Number(1.0)]); + assert_eq!(a, b); + } + + #[test] + fn an_order_packet_and_a_data_packet_built_from_the_same_id_and_values_are_different_types() { + // This is the whole point of tagging `DataOrderPacket` with a + // phantom `Kind`: `DataPacket` and `OrderPacket` used to be plain + // aliases for the same concrete struct, so nothing here would even + // have been a type error before. There's no runtime assertion to + // make for that — see this module's `compile_fail` doctest on + // `DataOrderPacket`, which proves the mix-up doesn't compile. + let data = DataPacket::new(AdjId(1000), vec![]); + let order = OrderPacket::new(AdjId(1000), vec![]); + assert_eq!(data.id, order.id); + assert_eq!(data.values, order.values); + } +} diff --git a/backend/crates/core/src/model/protections.rs b/backend/crates/core/src/model/protections.rs new file mode 100644 index 000000000..3159c107c --- /dev/null +++ b/backend/crates/core/src/model/protections.rs @@ -0,0 +1,171 @@ +//! Protection declarations. +//! +//! A [`Protection`] is a bound checked against a measurement's decoded +//! value, as declared for that measurement in the ADJ (see +//! `_measurements.json`, `protections` array). This describes the +//! *declaration*: what to watch for and how +//! severe it is. It is not the packet actually received over the wire when +//! a protection triggers; that belongs with the rest of the wire packets, +//! once `packet` exists. +//! +//! In ADJv2, a protection was just a `message` with a specific kind. +//! ADJv3 splits them into two unrelated wire formats, so from here on +//! `Protection` and a future `Message` type are not related at all. + +use crate::model::AdjId; +use std::time::Duration; + +/// What a protection checks, and the bound(s) it needs. +/// +/// Modeled as data-carrying variants instead of a flat `value: Vec` +/// plus a `range: bool` flag (as the ADJ JSON represents it), so a +/// protection with the wrong number of bounds for its kind — e.g. an +/// `Above` with two values, or a `Range` with only one — cannot be +/// represented at all. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ProtectionKind { + /// Triggers when the value goes above `limit`. + Above { + /// The upper limit. + limit: f64, + }, + /// Triggers when the value goes below `limit`. + Below { + /// The lower limit. + limit: f64, + }, + /// Triggers when the value equals `target`. + Equal { + /// The value that must not occur. + target: f64, + }, + /// Triggers when the value is different from `target`. + NotEqual { + /// The value that is expected. + target: f64, + }, + /// Triggers when the value leaves the `[min, max]` interval. + /// + /// Unlike [`super::Range`], both bounds are mandatory here: the ADJ + /// always provides exactly two values for a `Range` protection, so + /// there is no "open-ended" case to represent. + Range { + /// The lower bound of the interval. + min: f64, + /// The upper bound of the interval. + max: f64, + }, +} + +/// How severe a triggered protection is. +/// +/// Only two values, unlike a message's severity: a protection is either a +/// [`Severity::Fault`], which stops the vehicle, or a [`Severity::Warning`], +/// which does not. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Severity { + /// Stops the vehicle automatically. + Fault, + /// Flags the value as unexpected, without stopping the vehicle. + Warning, +} + +/// A single protection watching a measurement, as declared in the ADJ. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Protection { + /// This protection's position within its measurement's `protections` + /// list, encoded as an [`AdjId`] in the 1–7 range. + /// + /// This is *not* an independently assigned id from the global ADJ id + /// space (`[0, 8191]`): a protection is only ever + /// addressed relative to the measurement that owns it. On the wire, + /// this position becomes the 3-bit `pos` field that, combined with the + /// owning measurement's 13-bit id, forms the compound id — that + /// combination is `protocol`'s job, not this type's. + pub id: AdjId, + /// What this protection checks and its bound(s). + pub kind: ProtectionKind, + /// How severe it is once triggered. + pub severity: Severity, + /// How long the value must stay out of bounds before this protection + /// triggers. `Duration::ZERO` (the ADJ's default) means it triggers + /// immediately. + pub time: Duration, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_range_protection_always_carries_both_bounds() { + let protection = Protection { + id: AdjId(1), + kind: ProtectionKind::Range { + min: 0.0, + max: 48.0, + }, + severity: Severity::Fault, + time: Duration::ZERO, + }; + + let ProtectionKind::Range { min, max } = protection.kind else { + unreachable!("protection was just constructed as Range above"); + }; + + assert_eq!(min, 0.0); + assert_eq!(max, 48.0); + } + + #[test] + fn two_protections_with_the_same_data_are_equal() { + let a = Protection { + id: AdjId(2), + kind: ProtectionKind::Above { limit: 100.0 }, + severity: Severity::Warning, + time: Duration::from_millis(500), + }; + let b = a; + assert_eq!(a, b); + } + + #[test] + fn zero_time_means_it_triggers_immediately() { + let protection = Protection { + id: AdjId(3), + kind: ProtectionKind::Equal { target: 0.0 }, + severity: Severity::Fault, + time: Duration::ZERO, + }; + + assert_eq!(protection.time, Duration::ZERO); + } + + #[test] + fn a_protections_id_is_its_position_not_a_global_id() { + // Two different measurements can each have a protection at + // position 1 — they are unrelated protections that happen to + // share the same "slot" id, disambiguated by their measurement. + let first_measurement_protection = Protection { + id: AdjId(1), + kind: ProtectionKind::Below { limit: -10.0 }, + severity: Severity::Warning, + time: Duration::ZERO, + }; + let second_measurement_protection = Protection { + id: AdjId(1), + kind: ProtectionKind::Above { limit: 90.0 }, + severity: Severity::Fault, + time: Duration::ZERO, + }; + + assert_eq!( + first_measurement_protection.id, + second_measurement_protection.id + ); + assert_ne!( + first_measurement_protection.kind, + second_measurement_protection.kind + ); + } +} diff --git a/backend/crates/core/src/model/value.rs b/backend/crates/core/src/model/value.rs new file mode 100644 index 000000000..5dcd7e62d --- /dev/null +++ b/backend/crates/core/src/model/value.rs @@ -0,0 +1,35 @@ +//! Decoded values. +//! +//! [`Value`] is how the backend represents a piece of data once it has been +//! decoded, regardless of what it looked like on the wire. Even though the +//! ADJ defines several numeric wire types (`uint8`, `float32`...), once a +//! number is decoded it is always stored as an `f64`: what matters from +//! here on is the number itself, not how many bytes it took on the wire. +//! The wire type only matters while decoding, and lives in `NumericKind` +//! instead. + +/// A single decoded value, of whichever shape the measurement defines. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + /// A numeric measurement, regardless of its original wire type. + Number(f64), + /// A boolean measurement. + Bool(bool), + /// The selected variant of an enum measurement, e.g. `"Operational"`. + Enum(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_numbers_with_the_same_value_are_equal() { + assert_eq!(Value::Number(1.5), Value::Number(1.5)); + } + + #[test] + fn different_variants_are_never_equal_even_with_similar_data() { + assert_ne!(Value::Bool(true), Value::Enum("true".to_string())); + } +} diff --git a/backend/crates/core/src/ntp.rs b/backend/crates/core/src/ntp.rs new file mode 100644 index 000000000..7de54a6e5 --- /dev/null +++ b/backend/crates/core/src/ntp.rs @@ -0,0 +1,167 @@ +use crate::model::Port; +use ntp_server::{protocol::Stratum, server::NtpServer}; +use std::{ + io, + net::{Ipv4Addr, SocketAddrV4}, +}; +use tracing::{debug, info, instrument}; + +const NTP_DEFAULT_PORT: Port = Port(8123); + +/// Starts the NTP server with an interface and a port. +/// +/// Arguments: +/// - `interface`: IPv4 interface to bind to. If `None`, the default interface (`0.0.0.0`) is used. +/// - `port`: Port to listen on. If `None`, the default NTP port (`8123`) is used. +#[instrument(level = "info")] +pub async fn start_ntp_server(interface: Option, port: Option) -> io::Result<()> { + run_ntp_server(interface, port).await +} + +/// Starts the NTP server with a socket. +/// +/// Arguments: +/// - `socket`: Complete IPv4 socket address. If `None` the default `SocketAddrV4` is used (`0.0.0.0:8123`). +#[instrument(level = "info")] +pub async fn start_ntp_server_with_socket(socket: Option) -> io::Result<()> { + let (interface, port) = match socket { + Some(socket) => (Some(*socket.ip()), Some(Port(socket.port()))), + None => (None, None), + }; + + run_ntp_server(interface, port).await +} + +#[instrument(level = "info")] +async fn run_ntp_server(interface: Option, port: Option) -> io::Result<()> { + let server = build_ntp_server(interface, port).await?; + info!("Starting NTP server loop"); + // Any internal log of server.run() will be wrapped within this function Span + server.run().await +} + +async fn build_ntp_server( + interface: Option, + port: Option, +) -> io::Result { + let interface = interface.unwrap_or(Ipv4Addr::UNSPECIFIED); // Same as Ipv4Addr::new(0,0,0,0); + + let port = port.unwrap_or(NTP_DEFAULT_PORT); + + let socket_addr_v4 = SocketAddrV4::new(interface, port.0); + + // % -> use structured variables + debug!(%socket_addr_v4, "Building NTP server"); + + let server = NtpServer::builder() + .listen(socket_addr_v4.to_string()) + // Stratum indicates the server's distance from the source of the reference clock. + // This server synchronizes from a stratum 1 source, and advertises stratum 2 to its clients. + .stratum(Stratum(2)) + .build() + .await?; + + info!(%socket_addr_v4, "NTP server built successfully"); + + Ok(server) +} + +#[cfg(test)] +mod test { + + use super::*; + use ntp_server::protocol::{ + ConstPackedSizeBytes, FromBytes, Mode, Packet, TimestampFormat, ToBytes, Version, + }; + use std::time::Duration; + use tokio::net::UdpSocket; + use tracing_subscriber; + + // Initializes the subscriber for tests + fn init_tracing_for_tests() { + let _ = tracing_subscriber::fmt() + .with_env_filter("debug") + .with_test_writer() // Ensures that the logs are shown by cargo test + .try_init(); // Avoids the test failure if there is another test in parallel + } + + #[tokio::test] + // Verifies if the NTP server responds correctly to a client request + async fn ntp_server_responds_to_client_request() { + init_tracing_for_tests(); + + // Create the NTP server + let server = build_ntp_server(Some(Ipv4Addr::LOCALHOST), Some(Port(0))) + .await + .expect("NTP server should bind to an ephemeral local port"); + + // Get the address assigned by the OS + let server_addr = server + .local_addr() + .expect("NTP server should expose its bound address"); + + // Start the server on a thread + let server_task = tokio::spawn(server.run()); + + // Bind a UDP socket to send NTP requests + let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("NTP client socket should bind to an ephemeral local port"); + + // Build NTP request packet for testing requests + let request = Packet { + transmit_timestamp: TimestampFormat { + // Random data for test purpose only, just to verify the server responds correctly + seconds: 0xE0000000, + fraction: 0x12345678, + }, + ..Packet::default() // Fills the rest of the packet with the default values + }; + + // Serialize the request into a fixed-size byte array. + let mut request_bytes = [0; Packet::PACKED_SIZE_BYTES]; + request + .to_bytes(&mut request_bytes) + .expect("NTP client request should serialize"); + + // Send the request + socket + .send_to(&request_bytes, server_addr) + .await + .expect("NTP client request should be sent"); + + // Waiting for the server to respond + let mut response_bytes = [0; 2048]; + let (response_len, source_addr) = tokio::time::timeout( + Duration::from_secs(2), + socket.recv_from(&mut response_bytes), + ) + .await + .expect("NTP server should respond before timeout") + .expect("NTP response should be received"); + + // Response should come from the same server address + assert_eq!(source_addr, server_addr); + // Response should have the same packet length + assert_eq!(response_len, Packet::PACKED_SIZE_BYTES); + + // Deserialize response and validate contents + let (response, bytes_read) = Packet::from_bytes(&response_bytes[..response_len]) + .expect("server response should be a valid NTP packet"); + assert_eq!(bytes_read, Packet::PACKED_SIZE_BYTES); + assert_eq!(response.mode, Mode::Server); + assert_eq!(response.version, Version::V4); + assert_eq!(response.stratum, Stratum(2)); + assert_eq!(response.origin_timestamp, request.transmit_timestamp); + assert_ne!(response.transmit_timestamp, TimestampFormat::default()); + + // Stop the server task and verify that it was cancelled. + server_task.abort(); + assert!( + server_task + .await + .expect_err("server task should be aborted") + .is_cancelled() + ); + } +} diff --git a/backend/crates/core/src/ntp/README.md b/backend/crates/core/src/ntp/README.md new file mode 100644 index 000000000..682784f52 --- /dev/null +++ b/backend/crates/core/src/ntp/README.md @@ -0,0 +1,31 @@ +# NTP module + +NTP (**Network Time Protocol**) is a protocol used to synchronize clocks between devices over a network. In this project, it is used to synchronize the boards in the pod, with the *backend*. + +There are two related concepts worth mentioning: + +- **SNTP (Simple Network Time Protocol):** a simplified subset of NTP intended for simpler synchronization scenarios, such as clients using a single time server. +- **NTS (Network Time Security):** an extension to NTP that adds authentication and cryptographic security to time synchronization (not used in this project). + +The boards in this project use **SNTP**. However, this module implements an **NTP server**, since NTP and SNTP use compatible protocol messages and are interoperable. An SNTP client can therefore communicate with this NTP server as defined by RFC 5905. + +## Implementation + +The server is implemented using the [`ntp_usg-server`](https://crates.io/crates/ntp_usg-server) crate with the **Tokio** runtime. It also uses [`tracing`](https://crates.io/crates/tracing) crate for logging. + +The crate handles the NTP protocol logic, including request parsing and response generation, while this module is responsible for configuring and starting the server. + +The server interface and port can be configured when starting it. By default, the server listens on: + +- Interface: `0.0.0.0` +- Port: `8123` + +The standard NTP port is `123`, but this project uses `8123` as its default port. + +## Documentation + +The module documentation can be generated and opened with: + +```bash +cargo doc --open -p software-core -F ntp --no-deps +``` \ No newline at end of file diff --git a/backend/crates/core/src/protocol.rs b/backend/crates/core/src/protocol.rs new file mode 100644 index 000000000..e89fe6064 --- /dev/null +++ b/backend/crates/core/src/protocol.rs @@ -0,0 +1,24 @@ +//! Translates bytes <-> typed packets ([`crate::model::Packet`] and +//! friends), using the shapes [`crate::adj`] already declared +//! (`PacketDef`, `Measurement`) as the schema. Never touches a real +//! socket — that's `core::net`'s job, which calls into this module's +//! functions and owns the actual `TcpStream`/`UdpSocket`. +//! +//! The TCP/UDP split lives in the *types* each submodule can produce: +//! [`crate::protocol::udp::decode_datagram`] can only ever return a +//! `DataPacket`, [`crate::protocol::tcp::decode_next`] only a +//! [`crate::protocol::tcp::TcpFrame`] (`Protection`/`Message`) — the +//! compiler enforces the separation the ADJ itself draws between what +//! travels over which transport, not runtime convention. `tcp`/`udp` +//! themselves are thin wrappers around [`crate::protocol::packet`], which +//! does the actual per-packet-type work independent of transport. + +mod error; +mod index; +pub mod packet; +pub mod tcp; +pub mod udp; +pub mod wire; + +pub use error::{DecodeError, EncodeError}; +pub use index::Index; diff --git a/backend/crates/core/src/protocol/README.md b/backend/crates/core/src/protocol/README.md new file mode 100644 index 000000000..f107da5d2 --- /dev/null +++ b/backend/crates/core/src/protocol/README.md @@ -0,0 +1,213 @@ +# Protocol Module + +This module handles encoding and decoding of packets for communication with hardware boards over TCP and UDP transports. It implements the ADJ protocol for telemetry data, commands, messages, and protection events. + +## Architecture + +The protocol module follows a layered architecture: + +``` +┌─────────────────────────────────────────┐ +│ Transport Layer │ +│ (tcp.rs, udp.rs) │ +├─────────────────────────────────────────┤ +│ Packet Layer │ +│ (packet/data_order.rs, │ +│ packet/message.rs, │ +│ packet/protection.rs) │ +├─────────────────────────────────────────┤ +│ Wire Layer │ +│ (wire.rs) │ +└─────────────────────────────────────────┘ +``` + +## Directory Structure + +``` +protocol/ +├── index.rs # Packet definition lookup table +├── error.rs # Decode and encode error types +├── wire.rs # Low-level byte ↔ value conversion +├── packet.rs # Packet-level encoding/decoding module +│ ├── data_order.rs # Data (telemetry) & Order (command) packets +│ ├── message.rs # Free-text log message packets +│ └── protection.rs # Protection trigger packets +├── tcp.rs # TCP transport framing +└── udp.rs # UDP transport framing +``` + +## Modules + +### `index.rs` - Packet Definition Lookup + +Provides a fast lookup table from packet IDs (`AdjId`) to their declarations (`PacketDef`). Built once from loaded ADJ configuration data (`PodData`), it owns cloned copies of packet definitions so it can be shared across async tasks via `Arc`. + +A message severity is not a separate lookup bolted onto `Index` — it's just another `PacketDef`. `Index::new(pod_data, message_levels)` takes `message_levels` as `Option<&HashMap>` (an ADJ's `adj::AdjInfo::message_levels` — `None` for callers with no `AdjInfo` at all, e.g. most tests here) and, for each entry, synthesizes a `PacketDef { packet_type: PacketType::Message(level), board: BoardId(0), .. }` into the very same `AdjId -> PacketDef` table as every board's own packets. `BoardId(0)` (reserved, not validated — see `AdjId`/`BoardId`'s own docs) because a severity isn't owned by any one board. Resolving a message is then `Index::packet(id)` + matching `PacketType::Message(level)`, exactly like `packet::protection` already does for `PacketType::Protection`. + +**Key types:** +- `Index` - HashMap-based lookup from `AdjId` to `PacketDef` (synthesized `Message` entries included) + +**Key functions:** +- `Index::new(pod_data, message_levels)` - Build an index from a loaded ADJ's `PodData` and, optionally, its `AdjInfo::message_levels` +- `Index::packet(id)` - Look up a packet's declaration by id — `Data`, `Order`, `Protection` or `Message` alike + +### `error.rs` - Error Types + +Defines errors that can occur during packet encoding and decoding. + +**Key types:** +- `DecodeError` - Errors when converting bytes to packets + - `UnknownId` - No packet declared with this ID + - `UnexpectedEof` - Ran out of bytes during decode + - `Io` - I/O error reading from stream (TCP only) + - `InvalidEnumValue` - Enum index out of range + - `UnterminatedString` - String field not null-terminated + - `InvalidUtf8` - String field's bytes weren't valid UTF-8 + - `WrongPacketType` - Packet exists but isn't expected type + +- `EncodeError` - Errors when converting packets to bytes + - `UnknownId` - No packet declared with this ID + - `WrongFieldCount` - Missing or extra fields in order + - `TypeMismatch` - Value doesn't match declared type + - `OutOfRange` - Numeric value doesn't fit wire type + - `WrongPacketType` - Packet exists but isn't expected type + - `StringTooLong` - String field + terminator doesn't fit the max length + - `EmbeddedNulByte` - String field contains a `\0` byte + +### `wire.rs` - Low-Level Byte Codec + +Handles conversion between raw bytes and `Value` types for individual measurements, based on their declared `MeasurementKind`. All numeric types use little-endian encoding. + +**Key functions:** +- `decode_value(bytes, kind)` - Decode a single value from bytes +- `encode_value(value, kind)` - Encode a single value to bytes +- `byte_width(kind)` - Get the wire width in bytes for a measurement kind + +**Supported types:** +- **Numeric** (U8, U16, U32, U64, I8, I16, I32, I64, F32, F64) - ✓ Implemented +- **Enum** (single byte index) - ✓ Implemented +- **Boolean** (single byte, zero = `false`, nonzero = `true`) - ✓ Implemented + +### `packet/` - Packet-Level Encoding/Decoding + +Contains per-packet-type encoding and decoding logic, independent of +transport. **Hard rule for this directory: all byte-level parsing for a +packet type lives in its own file here, never in `tcp.rs`/`udp.rs`.** +See **[`packet/README.md`](packet/README.md)** for the full design +rationale (the `Option` pattern below, and exactly what `tcp.rs` +is and isn't allowed to do). + +#### `data_order.rs` + +Handles the positional Data/Order wire shape shared by both packet types +(see `model::DataOrderPacket`'s phantom `Kind`). `encode`/`decode` are +generic over `Kind`, written once for both: +- **Order packets** (commands sent to boards) - sent over TCP +- **Data packets** (periodic telemetry from boards) - received over UDP + +Self-contained both ways: they always read/write the leading 2-byte id +themselves (there's no dispatch ambiguity to resolve first here). The +backend itself only ever calls `encode` with an Order packet (via +`tcp::encode_order`) and `decode::` (via `udp::decode_datagram`) - +that direction restriction lives at the transport layer, not here. + +**Key functions:** +- `encode(packet, index)` - Encode a Data or Order packet into bytes (`Kind` inferred from `packet`'s type) +- `decode::(bytes, index)` - Decode bytes into a Data or Order packet + +#### `message.rs` + +Decodes free-text log messages from boards over TCP (decode only — in +production this only ever travels board → backend). `encode` also +exists, but purely for test symmetry; nothing in the real backend calls +it. + +Unlike `data_order.rs`, a TCP dispatcher needs this frame's leading id +*before* it even knows to call this file (to tell a message apart from +a protection) — so, same as `protection.rs`, `id` is `Option`: +`Some` if the caller already read it (and `reader` starts right after +the id), `None` to have `decode` read it itself. Either way, `id` is +resolved against `index` to confirm it's actually a declared +`PacketType::Message` (`UnknownId`/`WrongPacketType` otherwise) — but unlike +`protection.rs`, the severity itself is never stored on the decoded +`MessagePacket`; it's metadata about the id, not about this particular +instance, so look it up again via `index.packet(id)` if it's needed. + +**Key functions:** +- `decode(reader, id: Option, index)` - Decode a message frame, resolving its id itself if not given one +- `encode(packet, index)` - Test-only: encode a complete `MessagePacket` frame, id included + +#### `protection.rs` + +Decodes protection trigger events from boards over TCP (decode only — +in production this only ever travels board → backend). `encode` also +exists, but purely for test symmetry; nothing in the real backend calls +it. + +Same `Option` pattern as `message.rs`, for the same reason: a TCP +dispatcher needs the leading id first to tell a protection apart from a +message. `compound_id: Option` - `Some` if the caller already +read it, `None` to have `decode` read it itself off `reader`'s first 2 +bytes. + +**Key functions:** +- `decode(reader, compound_id: Option, index)` - Decode a protection frame, resolving its id itself if not given one +- `encode(packet, index)` - Test-only: encode a complete `ProtectionPacket` frame, id included + +### `tcp.rs` - TCP Transport Framing + +Handles the dedicated TCP connection to each board. A TCP connection carries: +- Protection events (board → backend) +- Free-text messages (board → backend) +- Order commands (backend → board) + +`decode_next` reads the frame's leading 16-bit id itself (via `packet::read_id`, `pub(super)` so `tcp` — a sibling of `packet`, not a descendant — can reach it), then dispatches purely on the raw id: `id.0 >= 8192` (`1 << 13` — a protection's compound id always has its high 3 bits set, see `AdjId`'s own docs) goes to `packet::protection::decode(reader, Some(id), index)`, anything below that to `packet::message::decode(reader, Some(id), index)`. Neither branch does its own lookup first — each `decode` already resolves `UnknownId`/`WrongPacketType` against `index` itself. + +**Key types:** +- `TcpFrame` - Enum of frames that can be received over TCP (Protection, Message) + +**Key functions:** +- `decode_next(reader, index)` - ✓ Implemented - Decode the next frame from a TCP stream +- `encode_order(order, index)` - ✓ Implemented - Encode an order for sending + +### `udp.rs` - UDP Transport Framing + +Handles the shared UDP socket for periodic telemetry. UDP datagrams are complete, already-delimited buffers containing Data packets. + +**Key functions:** +- `decode_datagram(bytes, index)` - ✓ Implemented - Decode a UDP datagram into a DataPacket + +## Packet Types Summary + +| Packet Type | Direction | Transport | Status | +|-------------|-----------|-----------|--------| +| Data (telemetry) | Board → Backend | UDP | ✓ Implemented | +| Order (command) | Backend → Board | TCP | ✓ Implemented | +| Message (log) | Board → Backend | TCP | ✓ Implemented (decode is the only real direction; `encode` is test-only) | +| Protection (event) | Board → Backend | TCP | ✓ Implemented (decode is the only real direction; `encode` is test-only) | + +## Implementation Status + +- ✅ **Data packets** - Full encode/decode with tests +- ✅ **Order packets** - Full encode/decode with tests +- ✅ **Wire-level numeric types** - All numeric kinds supported with tests +- ✅ **Wire-level enum types** - Full encode/decode with tests +- ✅ **Wire-level boolean types** - Single byte, confirmed with firmware +- ✅ **Message packets** - decode implemented, with tests (encode exists for test symmetry only) +- ✅ **Protection packets** - decode implemented, with tests (encode exists for test symmetry only) +- ✅ **TCP framing** - `decode_next` implemented and tested (dispatches to protection/message by `id.0 >= 8192`) + +## Testing + +The module includes comprehensive unit tests for: +- Wire-level encoding/decoding for all numeric, boolean and enum types +- Order/Data packet encoding and decoding, including round trips +- Message/Protection packet decoding (timestamps, strings, values) and + their test-only encoding, including round trips +- Error propagation for unknown IDs, wrong packet types, type mismatches, + unterminated/invalid strings, and out-of-range values + +Run tests with: +```bash +cargo test -p core protocol +``` diff --git a/backend/crates/core/src/protocol/error.rs b/backend/crates/core/src/protocol/error.rs new file mode 100644 index 000000000..b6de39dea --- /dev/null +++ b/backend/crates/core/src/protocol/error.rs @@ -0,0 +1,135 @@ +//! Errors produced while translating bytes into packets and back. + +use crate::model::AdjId; + +/// An error decoding bytes into a packet. +#[derive(Debug, thiserror::Error)] +pub enum DecodeError { + /// No packet is declared with this id. + #[error("unknown packet id {0:?}")] + UnknownId(AdjId), + + /// Ran out of bytes before a complete value could be decoded. + #[error("not enough bytes to decode a value")] + UnexpectedEof, + + /// An I/O error while reading from a stream (TCP only — a UDP + /// datagram is decoded from an in-memory buffer, never a stream). + #[error("I/O error reading a frame")] + Io(#[from] std::io::Error), + + /// A decoded enum value's index doesn't match any of its declared + /// options. + #[error("enum value {index} is out of range for {options} option(s)")] + InvalidEnumValue { + /// The decoded index. + index: u32, + /// How many options the measurement declares. + options: usize, + }, + + /// A free-text field (`origin`/`message`) had no terminating `0` byte + /// within its maximum length. + #[error("string field wasn't terminated within {max} bytes")] + UnterminatedString { + /// The field's maximum length. + max: usize, + }, + + /// A free-text field's bytes weren't valid UTF-8. + #[error("string field wasn't valid UTF-8: {0:?}")] + InvalidUtf8(Vec), + + /// A looked-up packet exists, but isn't the type this function expects + /// (e.g. `packet::data::decode` found an id that resolves to an + /// `Order`). + #[error("packet {id:?} isn't the expected type")] + WrongPacketType { + /// The packet whose type didn't match. + id: AdjId, + }, +} + +impl PartialEq for DecodeError { + // `Io`'s `std::io::Error` doesn't implement `PartialEq`, so this can't + // be derived — compare by `ErrorKind` instead for that variant. + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::UnknownId(a), Self::UnknownId(b)) => a == b, + (Self::UnexpectedEof, Self::UnexpectedEof) => true, + (Self::Io(a), Self::Io(b)) => a.kind() == b.kind(), + ( + Self::InvalidEnumValue { + index: a_index, + options: a_options, + }, + Self::InvalidEnumValue { + index: b_index, + options: b_options, + }, + ) => a_index == b_index && a_options == b_options, + (Self::UnterminatedString { max: a }, Self::UnterminatedString { max: b }) => a == b, + (Self::InvalidUtf8(a), Self::InvalidUtf8(b)) => a == b, + (Self::WrongPacketType { id: a }, Self::WrongPacketType { id: b }) => a == b, + _ => false, + } + } +} + +/// An error encoding a packet into bytes. +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum EncodeError { + /// No packet is declared with this id. + #[error("unknown packet id {0:?}")] + UnknownId(AdjId), + + /// The order didn't provide a value for every field its declaration + /// expects. + #[error("order needs {expected} field(s), got {got}")] + WrongFieldCount { + /// How many fields the order's `PacketDef` declares. + expected: usize, + /// How many fields were actually given. + got: usize, + }, + + /// A field's [`crate::model::Value`] doesn't match the kind its + /// [`crate::model::Measurement`] declares (e.g. a `Value::Bool` for a + /// numeric field). + #[error("value doesn't match its declared type")] + TypeMismatch, + + /// A numeric value doesn't fit the wire type's representable range + /// (or is NaN). + #[error("value {value} is out of range for this wire type ({min}..={max})")] + OutOfRange { + /// The value that didn't fit. + value: f64, + /// The wire type's minimum representable value. + min: f64, + /// The wire type's maximum representable value. + max: f64, + }, + + /// A looked-up packet exists, but isn't the type this function expects + /// (e.g. `packet::order::encode` found an id that resolves to a + /// `Data` packet). + #[error("packet {id:?} isn't the expected type")] + WrongPacketType { + /// The packet whose type didn't match. + id: AdjId, + }, + + /// A string field's content plus its terminator wouldn't fit in the + /// wire format's maximum length. + #[error("string content plus its terminator doesn't fit in {max} byte(s)")] + StringTooLong { + /// The field's maximum length, terminator included. + max: usize, + }, + + /// A string field contains an embedded NUL byte, which would be + /// misread as an early terminator on the wire. + #[error("string contains an embedded NUL byte")] + EmbeddedNulByte, +} diff --git a/backend/crates/core/src/protocol/index.rs b/backend/crates/core/src/protocol/index.rs new file mode 100644 index 000000000..0f9d5a5bd --- /dev/null +++ b/backend/crates/core/src/protocol/index.rs @@ -0,0 +1,127 @@ +//! The `id -> PacketDef` lookup `tcp`/`udp` decode against. + +use crate::model::{AdjId, BoardId, MessageLevel, PacketDef, PacketType, PodData}; +use std::collections::HashMap; + +/// A lookup table from a packet's [`AdjId`] to its declaration, built once +/// from a loaded ADJ's [`PodData`] — and, optionally, its message +/// severities too. +/// +/// Owns its own copy of every [`PacketDef`] (cloned out of `PodData` at +/// construction) rather than borrowing, so it carries no lifetime and can +/// be shared across `tokio::spawn`ed tasks via `Arc` without a +/// fight with the borrow checker. Knows nothing about networking (no +/// `IpAddr`/`SocketAddr`) — that bookkeeping belongs to `core::net`, not +/// here. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Index { + by_id: HashMap, +} + +impl Index { + /// Builds an index over every packet declared by every board in + /// `pod_data`, plus one synthesized [`PacketDef`] per entry in + /// `message_levels` (an ADJ's `general_info.json`-derived + /// [`crate::adj::AdjInfo::message_levels`]). + /// + /// `message_levels` is `Option` rather than a plain reference so + /// that callers with no [`crate::adj::AdjInfo`] at hand — most tests + /// in this crate, which only ever build a bare [`PodData`] — can + /// just pass `None` instead of having to construct an empty map. + /// + /// A message severity isn't declared by any one board (any board's + /// free-text log frame can reuse the same `"fault"` id), so its + /// synthesized `PacketDef` gets the reserved `BoardId(0)` — no real + /// board uses that id — instead of a real one. + pub fn new(pod_data: &PodData, message_levels: Option<&HashMap>) -> Self { + let mut by_id: HashMap = pod_data + .boards + .values() + .flat_map(|board| board.packets.values()) + .map(|packet| (packet.id, packet.clone())) + .collect(); + + if let Some(message_levels) = message_levels { + for (&id, &level) in message_levels { + by_id.insert( + id, + PacketDef { + id, + board: BoardId(0), + name: level.name().to_string(), + packet_type: PacketType::Message(level), + }, + ); + } + } + + Self { by_id } + } + + /// Looks up a packet's declaration by id — a message severity + /// resolves through here too, as a synthesized `PacketDef` with + /// `packet_type: PacketType::Message(level)`; see this type's doc + /// comment. + pub fn packet(&self, id: AdjId) -> Option<&PacketDef> { + self.by_id.get(&id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Board, BoardId, MacAddress, PacketType}; + use std::net::IpAddr; + + fn sample_pod_data() -> PodData { + let packet = PacketDef { + id: AdjId(1000), + board: BoardId(1), + name: "bcu_data".to_string(), + packet_type: PacketType::Data(vec![]), + }; + let board = Board { + id: BoardId(1), + name: "BCU".to_string(), + ip: IpAddr::from([192, 168, 0, 10]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet.id, packet)]), + }; + PodData { + boards: HashMap::from([(board.id, board)]), + } + } + + #[test] + fn finds_a_packet_declared_on_any_board() { + let index = Index::new(&sample_pod_data(), None); + let Some(packet) = index.packet(AdjId(1000)) else { + unreachable!("packet 1000 was declared in the sample pod data"); + }; + assert_eq!(packet.name, "bcu_data"); + } + + #[test] + fn unknown_id_is_not_found() { + let index = Index::new(&sample_pod_data(), None); + assert!(index.packet(AdjId(9999)).is_none()); + } + + #[test] + fn a_message_severity_resolves_as_a_synthesized_packet_def() { + let message_levels = HashMap::from([(AdjId(2), MessageLevel::Fault)]); + let index = Index::new(&sample_pod_data(), Some(&message_levels)); + + let Some(packet) = index.packet(AdjId(2)) else { + unreachable!("the \"fault\" severity was declared at id 2"); + }; + assert_eq!(packet.packet_type, PacketType::Message(MessageLevel::Fault)); + assert_eq!(packet.board, BoardId(0)); + } + + #[test] + fn with_no_message_levels_given_only_board_packets_resolve() { + let index = Index::new(&sample_pod_data(), None); + assert_eq!(index.packet(AdjId(2)), None); + } +} diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs new file mode 100644 index 000000000..3e061e44d --- /dev/null +++ b/backend/crates/core/src/protocol/packet.rs @@ -0,0 +1,41 @@ +//! Encode/decode for one specific type of packet, independent of which +//! transport carries it — that mapping lives in [`super::tcp`]/ +//! [`super::udp`], which are thin wrappers around these. +//! +//! [`data_order`]'s `encode`/`decode` are generic over `Kind` +//! ([`crate::model::PacketTypeTag`]), so they work for both `Data` and +//! `Order` alike; the backend itself only ever exercises one direction per +//! type (an order is only ever sent, data only ever received), but that's +//! a restriction `tcp`/`udp` apply, not something baked into the codec. +//! +//! [`message`]/[`protection`] are genuinely one-directional in +//! production — a board only ever sends these, never receives them — +//! but both also expose an `encode`, purely so tests can build +//! well-formed frames without hand-written byte arrays; see each +//! module's own doc comment. + +pub mod data_order; +pub mod message; +pub mod protection; + +use crate::model::AdjId; +use crate::protocol::DecodeError; +use std::io::Read; + +/// Reads a raw little-endian 16-bit id off the front of `reader`. +/// +/// Shared by [`message`]/[`protection`] — the only two types whose +/// `decode` ever needs to read its own leading id, when the caller +/// doesn't already know it (see each module's own doc comment on why) — +/// and by [`super::tcp::decode_next`], which needs the id *before* it +/// can even decide which of the two to call. `pub(super)` (visible to +/// `protocol` and everything under it) rather than private, for that +/// last caller: `tcp` is `packet`'s sibling, not its descendant, so +/// private wouldn't reach it. [`data_order`] doesn't need this: it reads +/// its id a different way, straight out of an in-memory byte slice, +/// never a `Read` stream. +pub(super) fn read_id(reader: &mut impl Read) -> Result { + let mut bytes = [0u8; 2]; + reader.read_exact(&mut bytes)?; + Ok(AdjId(u16::from_le_bytes(bytes))) +} diff --git a/backend/crates/core/src/protocol/packet/README.md b/backend/crates/core/src/protocol/packet/README.md new file mode 100644 index 000000000..b55d7556b --- /dev/null +++ b/backend/crates/core/src/protocol/packet/README.md @@ -0,0 +1,94 @@ +# `packet/` — Per-Packet-Type Codecs + +Each file here owns **all** the byte-level parsing for one packet type — +how many bytes a field takes, in what order, what terminates a string, +what the leading id means. That parsing never gets split out into +`tcp.rs`/`udp.rs` (the transport layer) or anywhere else. A transport +module is only ever allowed to do the one thing it structurally has no +choice about — see "The dispatcher's one unavoidable read" below — and +then hands the rest straight to one of these files. + +## `data_order.rs` + +Self-contained both ways: `encode`/`decode` always read/write the +leading 2-byte id themselves, since there's no dispatch ambiguity to +resolve first — a UDP datagram is unambiguously `Data`, and an order is +only ever something the backend already decided to send. Generic over +`Kind` (`model::PacketTypeTag`), written once for both `Data` and `Order` — +see its own module doc and `model/README.md`'s §4.2 for why. + +## `message.rs` / `protection.rs` + +These two are different: over TCP, a `Protection` frame and a `Message` +frame share the same leading 16-bit id space, so **something** has to +read that id before it even knows which of the two decoders to call. +That something is `tcp::decode_next`. To keep that reality from leaking +actual decoding logic into `tcp.rs`, both files take their id as +`Option` — the exact same shape: + +```rust +// protection.rs +pub fn decode(reader: impl Read, compound_id: Option, index: &Index) -> Result; +pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, EncodeError>; + +// message.rs +pub fn decode(reader: impl Read, id: Option, index: &Index) -> Result; +pub fn encode(packet: &MessagePacket, index: &Index) -> Result, EncodeError>; +``` + +- **`Some(known_value)`** — the caller already read it, typically + because it had to read the leading id anyway to decide which of these + two functions to call in the first place. In this case `reader` must + already be positioned *right after* the id (byte 2 onward). +- **`None`** — the caller doesn't know it and isn't going to try. In + this case `reader` must start at byte 0 of the frame, id included, and + `decode` reads those 2 bytes itself. Either way, the id is then + resolved against `index` — `index.packet(id)` + matching + `PacketType::Protection`/`PacketType::Message` — to confirm it's + actually the declared type (`UnknownId`/`WrongPacketType` otherwise). A + message's severity is just another `PacketDef` by the time it gets + here (see `index.rs`'s own docs) — there's no separate lookup for it. + +One real difference between the two: `ProtectionPacket` stores its +`id` *and* the triggering `value` (which needs the watched measurement's +wire type, from `PacketType::Protection(measurement)`, to decode at +all). `MessagePacket` only stores `id` — its severity is metadata +*about* that id, not part of the frame's own byte layout, so `decode` +only uses `PacketType::Message(level)` to confirm the type and discards +`level` immediately; nothing about `timestamp`/`origin`/`message`'s +bytes depends on which severity it is. Look the severity up again via +`index.packet(id)` if it's needed later — it's never stored redundantly +on the decoded instance. + +Either way, **every byte of this frame is parsed inside the file that +owns that packet type** — `tcp.rs` never touches a timestamp, a `resv` +byte, a measurement's wire width, or a NUL-terminated string. `encode` +always writes the leading id back out too (from `packet.id`, after +confirming via `index` it resolves to the right type), so its output is +a complete, self-contained frame — feed it straight into +`decode(reader, None, index)` and get the same packet back; that's +exactly what each file's round-trip test does. + +### The dispatcher's one unavoidable read + +`tcp::decode_next` still has to read the raw 2-byte id itself once, to +decide *which* of `protection::decode`/`message::decode` to call — there +is no way around this for a non-seekable stream: once those bytes are +consumed, there's no handing them to the other function's `None` path +instead. That single mechanical "read a `u16`" is the one piece of +byte-handling that's expected to exist in `tcp.rs` — it calls the exact +same private `read_id` helper `protection.rs`/`message.rs` use for their +own `None` path (defined once in `packet.rs`, `pub(super)` so `tcp.rs` +can reach it too, rather than duplicated a third time). It is not +decoding — nothing is validated or interpreted by it beyond a single +`id.0 >= 8192` comparison (a protection's compound id always has its +high 3 bits set, see `AdjId`'s own docs) — and its result is +immediately handed to the matching function as `Some(id)`, which then +resolves it against `index` itself and does everything else. + +## Tests + +Every file's test module proves both `Some` and `None` work, plus a +round trip through its own `encode`/`decode` (`protection.rs`, +`message.rs`) or through `encode::`/`decode::` +(`data_order.rs`). See each file's own tests for the exact byte layouts. diff --git a/backend/crates/core/src/protocol/packet/data_order.rs b/backend/crates/core/src/protocol/packet/data_order.rs new file mode 100644 index 000000000..caca99161 --- /dev/null +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -0,0 +1,426 @@ +//! Encodes and decodes a positional Data/Order packet — see +//! [`crate::model::DataOrderPacket`] for why both share this one wire +//! shape under a phantom `Kind`. +//! +//! [`encode`]/[`decode`] are generic over that `Kind` via +//! [`crate::model::PacketTypeTag`], so they're written once and work for +//! either [`crate::model::Data`] or [`crate::model::Order`] — the backend +//! itself only ever calls `encode::` (via [`super::super::tcp`]) +//! and `decode::` (via [`super::super::udp`]), but that restriction +//! lives at the transport layer, not here. [`crate::model::PacketTypeTag`] +//! only answers whether a `PacketDef`'s type matches `Kind` at all; the +//! actual measurement list comes from +//! [`crate::model::PacketType::measurements`] either way. + +use crate::model::{AdjId, DataOrderPacket, PacketTypeTag}; +use crate::protocol::{DecodeError, EncodeError, Index, wire}; + +/// Encodes `packet` into the bytes to write to a board's connection, +/// resolving its field shapes against `index`. +pub fn encode( + packet: &DataOrderPacket, + index: &Index, +) -> Result, EncodeError> { + // Look up the packet definition for this packet's id + let packet_def = index + .packet(packet.id) + .ok_or(EncodeError::UnknownId(packet.id))?; + + // Check that the packet is an order or data, as expected by the generic PacketTypeTag + if !Kind::matches(&packet_def.packet_type) { + return Err(EncodeError::WrongPacketType { id: packet.id }); + } + + // Get the measurements for this packet + let measurements = packet_def.packet_type.measurements(); + + // Check that the number of values that we want to encode matches the number of measurements in the packet definition + if packet.values.len() != measurements.len() { + return Err(EncodeError::WrongFieldCount { + expected: measurements.len(), + got: packet.values.len(), + }); + } + + // Encode the packet id as little-endian u16 + let mut bytes = Vec::new(); + bytes.extend_from_slice(&packet.id.0.to_le_bytes()); + + // for each value in the packet encode it according to the corresponding measurements + for (field, measurement) in packet.values.iter().zip(measurements) { + // Encode the field value according to the measurement's kind + let field_bytes = wire::encode_value(field, &measurement.kind)?; + + // Append the encoded field bytes to the output vector + bytes.extend(field_bytes); + } + + Ok(bytes) +} + +/// Decodes a complete, already-delimited buffer into a +/// [`DataOrderPacket`], resolving its field shapes against `index`. +pub fn decode( + bytes: &[u8], + index: &Index, +) -> Result, DecodeError> { + // The first two bytes are the packet id, in little-endian u16 + if bytes.len() < 2 { + return Err(DecodeError::UnexpectedEof); + } + + // generate the packet id from the first two bytes + let id: [u8; 2] = bytes[0..2] + .try_into() + .map_err(|_| DecodeError::UnexpectedEof)?; + + // generate ADJ id from the little-endian bytes + let adj_id = AdjId(u16::from_le_bytes(id)); + + // Look up the packet definition for this id if not found return an UnknownId error + let packet_def = index.packet(adj_id).ok_or(DecodeError::UnknownId(adj_id))?; + + // Check that the packet is an order or data, as expected by the generic PacketTypeTag + if !Kind::matches(&packet_def.packet_type) { + return Err(DecodeError::WrongPacketType { id: adj_id }); + } + // Get the measurements for this packet + let measurements = packet_def.packet_type.measurements(); + + // Prepare a vector to hold the decoded values and a pointer to track the current position in the byte slice + let mut values = Vec::with_capacity(measurements.len()); + let mut pointer = 2; + + // For each measurement, decode the corresponding bytes from the input slice + for measurement in measurements { + let width = wire::byte_width(&measurement.kind); + let next = pointer + width; + + // Check if there are enough bytes to decode the field + if bytes.len() < next { + return Err(DecodeError::UnexpectedEof); + } + + // Slice the bytes for the current field and decode it according to the measurement's kind + let field = &bytes[pointer..next]; + let value = wire::decode_value(field, &measurement.kind)?; // Append the decoded value to the values vector + values.push(value); + + pointer = next; + } + + Ok(DataOrderPacket::new(adj_id, values)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + Board, BoardId, Data, DataPacket, MacAddress, Measurement, MeasurementKind, NumericKind, + Order, OrderPacket, PacketDef, PacketType, PodData, Value, + }; + use std::collections::HashMap; + use std::net::IpAddr; + + // Auxiliar function to create a sample measurement for testing + + fn sample_measurement(id: u16, kind: MeasurementKind) -> Measurement { + Measurement { + id: AdjId(id), + alias: format!("field_{id}"), + name: format!("Field {id}"), + display_units: String::new(), + kind, + protections: vec![], + } + } + + fn sample_packet_def(id: AdjId, packet_type: PacketType) -> PacketDef { + PacketDef { + id, + board: BoardId(1), + name: "TestPacket".into(), + packet_type, + } + } + + fn make_test_index(defs: Vec) -> Index { + let packets: HashMap = defs.into_iter().map(|p| (p.id, p)).collect(); + let board = Board { + id: BoardId(1), + name: "TestBoard".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets, + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + Index::new(&pod_data, None) + } + + // --- ENCODE TESTS --- + + #[test] + fn encode_success_little_endian_and_positional_fields() { + let order_id = AdjId(1001); + let packet_def = sample_packet_def( + order_id, + PacketType::Order(vec![ + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U16, + }, + ), + sample_measurement( + 2, + MeasurementKind::Enum { + options: vec!["Off".to_string(), "On".to_string()], + }, + ), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + let packet = OrderPacket::new(order_id, vec![Value::Number(300.0), Value::Number(1.0)]); + + let Ok(encoded) = encode(&packet, &index) else { + unreachable!("encoding a well-formed order should succeed"); + }; + + // 1001 in little-endian u16: [0xE9, 0x03] + // 300 in little-endian u16: [0x2C, 0x01] + // enum index 1: [0x01] + assert_eq!(encoded, vec![0xE9, 0x03, 0x2C, 0x01, 0x01]); + } + + #[test] + fn encode_fails_on_unknown_id() { + let index = make_test_index(vec![]); + let packet = OrderPacket::new(AdjId(9999), vec![]); + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an unknown id should fail"); + }; + assert_eq!(err, EncodeError::UnknownId(AdjId(9999))); + } + + #[test] + fn encode_fails_on_wrong_packet_type() { + let data_id = AdjId(2001); + let packet_def = sample_packet_def(data_id, PacketType::Data(vec![])); + + let index = make_test_index(vec![packet_def]); + let packet = OrderPacket::new(data_id, vec![]); + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding the wrong packet type should fail"); + }; + assert_eq!(err, EncodeError::WrongPacketType { id: data_id }); + } + + #[test] + fn encode_fails_on_wrong_field_count() { + let order_id = AdjId(1002); + let packet_def = sample_packet_def( + order_id, + PacketType::Order(vec![ + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ), + sample_measurement( + 2, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ), + ]), + ); + + let index = make_test_index(vec![packet_def]); + let packet = OrderPacket::new(order_id, vec![Value::Number(10.0)]); + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding with the wrong field count should fail"); + }; + assert_eq!( + err, + EncodeError::WrongFieldCount { + expected: 2, + got: 1, + } + ); + } + + // --- DECODE TESTS --- + + #[test] + fn decode_success_little_endian_and_values() { + let data_id = AdjId(3001); + let packet_def = sample_packet_def( + data_id, + PacketType::Data(vec![ + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U16, + }, + ), + sample_measurement( + 2, + MeasurementKind::Enum { + options: vec!["Off".to_string(), "On".to_string()], + }, + ), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + // 3001 in little-endian u16: [0xB9, 0x0B] + // 500 in little-endian u16: [0xF4, 0x01] + // enum index 0: [0x00] + let raw_bytes = [0xB9, 0x0B, 0xF4, 0x01, 0x00]; + + let Ok(decoded) = decode::(&raw_bytes, &index) else { + unreachable!("decoding a well-formed data packet should succeed"); + }; + + assert_eq!(decoded.id, data_id); + assert_eq!( + decoded.values, + vec![Value::Number(500.0), Value::Enum("Off".to_string())] + ); + } + + #[test] + fn decode_fails_when_buffer_is_shorter_than_header() { + let index = make_test_index(vec![]); + let Err(err) = decode::(&[0x01], &index) else { + unreachable!("decoding a buffer shorter than the header should fail"); + }; + assert_eq!(err, DecodeError::UnexpectedEof); + } + + #[test] + fn decode_fails_on_unexpected_eof_in_payload() { + let data_id = AdjId(3002); + let packet_def = sample_packet_def( + data_id, + PacketType::Data(vec![sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U32, + }, + )]), + ); + + let index = make_test_index(vec![packet_def]); + + let raw_bytes = [0xBA, 0x0B, 0x01, 0x02]; + let Err(err) = decode::(&raw_bytes, &index) else { + unreachable!("decoding a truncated payload should fail"); + }; + assert_eq!(err, DecodeError::UnexpectedEof); + } + + #[test] + fn decode_fails_on_unknown_id() { + let index = make_test_index(vec![]); + let raw_bytes = [0x88, 0x13]; + + let Err(err) = decode::(&raw_bytes, &index) else { + unreachable!("decoding an unknown id should fail"); + }; + assert_eq!(err, DecodeError::UnknownId(AdjId(5000))); + } + + #[test] + fn decode_fails_on_wrong_packet_type() { + let order_id = AdjId(4001); + let packet_def = sample_packet_def(order_id, PacketType::Order(vec![])); + + let index = make_test_index(vec![packet_def]); + let raw_bytes = [0xA1, 0x0F]; + + let Err(err) = decode::(&raw_bytes, &index) else { + unreachable!("decoding the wrong packet type should fail"); + }; + assert_eq!(err, DecodeError::WrongPacketType { id: order_id }); + } + + // --- ROUND-TRIP TESTS --- + // + // `encode`/`decode` are generic over `Kind`, so the same `PacketDef` + // works for both directions here — that genericity is the whole + // point of giving `DataOrderPacket` a phantom `Kind` in the first + // place, instead of writing one hardcoded pair of functions per type. + // + // Enum fields are left out of both: `encode_enum` takes an + // already-selected index (`Value::Number`) but `decode_enum` returns + // the option's text (`Value::Enum`) — see `wire.rs` — so an enum + // field's `Value` is never expected to round-trip unchanged, only its + // underlying byte. + + fn numeric_measurements() -> Vec { + vec![ + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U16, + }, + ), + sample_measurement( + 2, + MeasurementKind::Numeric { + wire_type: NumericKind::I8, + }, + ), + ] + } + + #[test] + fn an_order_packet_round_trips_through_encode_then_decode() { + let id = AdjId(6001); + let index = make_test_index(vec![sample_packet_def( + id, + PacketType::Order(numeric_measurements()), + )]); + let original = OrderPacket::new(id, vec![Value::Number(1234.0), Value::Number(-5.0)]); + + let Ok(bytes) = encode(&original, &index) else { + unreachable!("encoding this well-formed order should succeed"); + }; + let Ok(decoded) = decode::(&bytes, &index) else { + unreachable!("decoding what we just encoded should succeed"); + }; + + assert_eq!(decoded.id, original.id); + assert_eq!(decoded.values, original.values); + } + + #[test] + fn a_data_packet_round_trips_through_encode_then_decode() { + let id = AdjId(6002); + let index = make_test_index(vec![sample_packet_def( + id, + PacketType::Data(numeric_measurements()), + )]); + let original = DataPacket::new(id, vec![Value::Number(42.0), Value::Number(-1.0)]); + + let Ok(bytes) = encode(&original, &index) else { + unreachable!("encoding this well-formed data packet should succeed"); + }; + let Ok(decoded) = decode::(&bytes, &index) else { + unreachable!("decoding what we just encoded should succeed"); + }; + + assert_eq!(decoded.id, original.id); + assert_eq!(decoded.values, original.values); + } +} diff --git a/backend/crates/core/src/protocol/packet/message.rs b/backend/crates/core/src/protocol/packet/message.rs new file mode 100644 index 000000000..ec20e16e3 --- /dev/null +++ b/backend/crates/core/src/protocol/packet/message.rs @@ -0,0 +1,444 @@ +//! Decodes a `Message` packet: a board's free-text log line. Only ever +//! received, over TCP. Also provides `encode`, purely so tests can build +//! well-formed frames without hand-written byte arrays — nothing in the +//! real backend calls it, since a message never travels backend -> board. +//! +//! Wire structure (whole frame, including the leading id): +//! ```text +//! | byte(s) | 0-1 | 2 | 3 | 4 | 5 | 6.. | .. | +//! |---------|-----|------|-----|-----|--------|-----------|-----------| +//! | field | id | hora | min | seg | subseg | origin\0 | message\0 | +//! ``` +//! `id` (2 bytes) is normally read by a TCP dispatcher before it even +//! knows to call [`decode`] (it needs the id first, to decide this is a +//! message and not a protection) — when that's the case, pass it as +//! `Some`, and `reader` should start right at byte 2 above. Otherwise +//! pass `None`: `reader` should then start at byte 0, id included, and +//! `decode` reads it itself — all of this frame's byte parsing stays in +//! this file either way, never split out into whatever dispatches here. +//! Unlike [`super::protection`], there's no `resv` padding — the +//! timestamp is followed directly by `origin`, a NUL-terminated string +//! of at most `ORIGIN_MAX` bytes (terminator included), then `message`, +//! at most `MESSAGE_MAX` bytes the same way. Both are variable-length — +//! there's no fixed byte range for either. +//! +//! Unlike [`super::protection`] too, [`MessagePacket`] doesn't carry its +//! own severity — `id` is resolved against `index` to confirm it's +//! actually a declared [`crate::model::PacketType::Message`] (so a +//! `WrongPacketType`/`UnknownId` frame is rejected), but the severity +//! itself is never stored redundantly on the decoded instance; look it up +//! via `index.packet(id)` again if it's needed. + +use crate::model::{AdjId, MessagePacket, PacketType, ShortTimestamp}; +use crate::protocol::{DecodeError, EncodeError, Index}; +use std::io::Read; + +// `origin`/`message` max lengths, content + NUL terminator included +// (docu_backend/Comunicaciones_Reunion01.qmd, lines 207-237). +const ORIGIN_MAX: usize = 48; +const MESSAGE_MAX: usize = 320; + +/// Decodes a message frame, resolving its id against `index` to confirm +/// it's actually a declared message. +/// +/// `id`: `Some` if the caller already read it (see this module's doc +/// comment); `None` to have `decode` read it itself off `reader`'s first +/// 2 bytes. +pub fn decode( + mut reader: impl Read, + id: Option, + index: &Index, +) -> Result { + let id = match id { + Some(id) => id, + None => super::read_id(&mut reader)?, + }; + + // Look up the packet definition for this id, and ensure it's a + // message packet. If not, the frame is malformed and we can't decode it. + let packet_def = index.packet(id).ok_or(DecodeError::UnknownId(id))?; + + let PacketType::Message(_) = &packet_def.packet_type else { + return Err(DecodeError::WrongPacketType { id }); + }; + + let mut header = [0u8; 4]; + reader.read_exact(&mut header)?; + let timestamp = ShortTimestamp { + hour: header[0], + minute: header[1], + second: header[2], + subsecond: header[3], + }; + + let origin = read_null_terminated_string(&mut reader, ORIGIN_MAX)?; + let message = read_null_terminated_string(&mut reader, MESSAGE_MAX)?; + + Ok(MessagePacket { + id, + timestamp, + origin, + message, + }) +} + +/// Reads bytes up to the first `0x00` (exclusive) or `max` bytes total, +/// whichever comes first — [`DecodeError::UnterminatedString`] if no +/// terminator shows up within `max` bytes. +fn read_null_terminated_string(reader: &mut impl Read, max: usize) -> Result { + // Byte by byte via `read_exact`, not `.bytes()` — that iterator warns + // under clippy for being unbuffered, and `reader` here can be any + // `impl Read`, not necessarily a buffered one. + let mut bytes = Vec::new(); + for _ in 0..max { + let mut byte = [0u8; 1]; + reader.read_exact(&mut byte)?; + if byte[0] == 0 { + return String::from_utf8(bytes).map_err(|e| DecodeError::InvalidUtf8(e.into_bytes())); + } + bytes.push(byte[0]); + } + Err(DecodeError::UnterminatedString { max }) +} + +/// Encodes a [`MessagePacket`] back into the bytes [`decode`] expects — +/// a complete, self-contained frame, leading id included. +/// +/// Nothing in the real backend calls this — per the ADJ spec, message +/// packets only ever travel board -> backend. This exists purely so +/// tests can build well-formed frames without hand-written byte arrays, +/// and to prove `decode` recovers exactly what was encoded. +pub fn encode(packet: &MessagePacket, index: &Index) -> Result, EncodeError> { + let packet_def = index + .packet(packet.id) + .ok_or(EncodeError::UnknownId(packet.id))?; + + let PacketType::Message(_) = &packet_def.packet_type else { + return Err(EncodeError::WrongPacketType { id: packet.id }); + }; + + let mut bytes = packet.id.0.to_le_bytes().to_vec(); + bytes.extend([ + packet.timestamp.hour, + packet.timestamp.minute, + packet.timestamp.second, + packet.timestamp.subsecond, + ]); + + write_null_terminated_string(&mut bytes, &packet.origin, ORIGIN_MAX)?; + write_null_terminated_string(&mut bytes, &packet.message, MESSAGE_MAX)?; + + Ok(bytes) +} + +/// Appends `value` to `bytes` as a NUL-terminated string, erroring if it +/// can't be represented that way: an embedded `\0` would be misread as +/// an early terminator, and the content plus its terminator must still +/// fit within `max` bytes. +fn write_null_terminated_string( + bytes: &mut Vec, + value: &str, + max: usize, +) -> Result<(), EncodeError> { + if value.contains('\0') { + return Err(EncodeError::EmbeddedNulByte); + } + if value.len() >= max { + return Err(EncodeError::StringTooLong { max }); + } + + bytes.extend_from_slice(value.as_bytes()); + bytes.push(0); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Board, BoardId, MacAddress, MessageLevel, PacketDef, PodData}; + use std::collections::HashMap; + use std::io::Cursor; + use std::net::IpAddr; + + /// Builds an `Index` declaring one message severity: `id` as + /// `MessageLevel::Fault`, exactly as `protocol::Index::new` would + /// synthesize it from a loaded ADJ's `message_levels`. + fn index_with_message(id: AdjId, level: MessageLevel) -> Index { + let message_levels = HashMap::from([(id, level)]); + Index::new(&PodData::default(), Some(&message_levels)) + } + + #[test] + fn decodes_timestamp_and_both_strings() { + let index = index_with_message(AdjId(11), MessageLevel::Warning); + let mut bytes = vec![12, 30, 5, 0]; // hour, minute, second, subsecond + bytes.extend(b"main.c"); + bytes.push(0); + bytes.extend(b"something went wrong"); + bytes.push(0); + + let Ok(packet) = decode(Cursor::new(bytes), Some(AdjId(11)), &index) else { + unreachable!("decoding a well-formed message frame should succeed"); + }; + + assert_eq!(packet.id, AdjId(11)); + assert_eq!(packet.timestamp.hour, 12); + assert_eq!(packet.timestamp.minute, 30); + assert_eq!(packet.timestamp.second, 5); + assert_eq!(packet.origin, "main.c"); + assert_eq!(packet.message, "something went wrong"); + } + + #[test] + fn decode_treats_a_leading_terminator_as_an_empty_string() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let mut bytes = vec![0, 0, 0, 0]; + bytes.push(0); // origin: empty + bytes.push(0); // message: empty + + let Ok(packet) = decode(Cursor::new(bytes), Some(AdjId(12)), &index) else { + unreachable!("decoding a well-formed message frame should succeed"); + }; + + assert_eq!(packet.origin, ""); + assert_eq!(packet.message, ""); + } + + #[test] + fn decode_fails_when_origin_has_no_terminator_within_max() { + let index = index_with_message(AdjId(10), MessageLevel::Fault); + let mut bytes = vec![0, 0, 0, 0]; + bytes.extend(vec![b'a'; ORIGIN_MAX]); // no terminator anywhere + + let Err(err) = decode(Cursor::new(bytes), Some(AdjId(10)), &index) else { + unreachable!("decoding an unterminated origin should fail"); + }; + assert_eq!(err, DecodeError::UnterminatedString { max: ORIGIN_MAX }); + } + + #[test] + fn decode_fails_when_message_has_no_terminator_within_max() { + let index = index_with_message(AdjId(13), MessageLevel::Panic); + let mut bytes = vec![0, 0, 0, 0]; + bytes.push(0); // origin: empty, terminated + bytes.extend(vec![b'a'; MESSAGE_MAX]); // message: no terminator + + let Err(err) = decode(Cursor::new(bytes), Some(AdjId(13)), &index) else { + unreachable!("decoding an unterminated message should fail"); + }; + assert_eq!(err, DecodeError::UnterminatedString { max: MESSAGE_MAX }); + } + + #[test] + fn decode_fails_on_invalid_utf8() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let mut bytes = vec![0, 0, 0, 0]; + bytes.push(0x80); // lone continuation byte: not valid UTF-8 on its own + bytes.push(0); + + let Err(err) = decode(Cursor::new(bytes), Some(AdjId(12)), &index) else { + unreachable!("decoding invalid UTF-8 should fail"); + }; + assert_eq!(err, DecodeError::InvalidUtf8(vec![0x80])); + } + + #[test] + fn decode_fails_on_truncated_header() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let Err(err) = decode(Cursor::new(vec![0, 0]), Some(AdjId(12)), &index) else { + unreachable!("decoding a truncated header should fail"); + }; + assert!(matches!(err, DecodeError::Io(_))); + } + + #[test] + fn decode_reads_the_id_itself_when_not_given_one() { + let index = index_with_message(AdjId(11), MessageLevel::Warning); + let mut bytes = AdjId(11).0.to_le_bytes().to_vec(); + bytes.extend([12, 30, 5, 0]); // hour, minute, second, subsecond + bytes.push(0); // origin: empty + bytes.push(0); // message: empty + + let Ok(packet) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding a well-formed message frame should succeed"); + }; + + assert_eq!(packet.id, AdjId(11)); + } + + #[test] + fn decode_fails_on_an_unknown_id() { + let index = index_with_message(AdjId(11), MessageLevel::Warning); + let bytes = 9999u16.to_le_bytes().to_vec(); + + let Err(err) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding an unknown message id should fail"); + }; + assert_eq!(err, DecodeError::UnknownId(AdjId(9999))); + } + + #[test] + fn decode_fails_when_the_id_resolves_to_a_non_message_packet() { + let data_id = AdjId(2000); + let packet = PacketDef { + id: data_id, + board: BoardId(1), + name: "bcu_data".to_string(), + packet_type: PacketType::Data(vec![]), + }; + let board = Board { + id: BoardId(1), + name: "BCU".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet.id, packet)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let index = Index::new(&pod_data, None); + + let bytes = data_id.0.to_le_bytes().to_vec(); + let Err(err) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding an id that resolves to a Data packet should fail"); + }; + assert_eq!(err, DecodeError::WrongPacketType { id: data_id }); + } + + // --- ENCODE TESTS --- + + #[test] + fn encode_then_decode_round_trips_timestamp_and_strings() { + let index = index_with_message(AdjId(10), MessageLevel::Fault); + let original = MessagePacket { + id: AdjId(10), + timestamp: ShortTimestamp { + hour: 23, + minute: 59, + second: 58, + subsecond: 200, + }, + origin: "board/src/main.c".to_string(), + message: "overcurrent detected".to_string(), + }; + + let Ok(bytes) = encode(&original, &index) else { + unreachable!("encoding a well-formed message packet should succeed"); + }; + // `encode` writes the id too, so `decode` can read it back itself — + // proves the full frame round-trips, not just the body. + let Ok(decoded) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding what we just encoded should succeed"); + }; + + assert_eq!(decoded, original); + } + + #[test] + fn encode_fails_on_unknown_id() { + let index = index_with_message(AdjId(10), MessageLevel::Fault); + let packet = MessagePacket { + id: AdjId(9999), + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + origin: String::new(), + message: String::new(), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an unknown id should fail"); + }; + assert_eq!(err, EncodeError::UnknownId(AdjId(9999))); + } + + #[test] + fn encode_fails_on_wrong_packet_type() { + let data_id = AdjId(2000); + let packet_def = PacketDef { + id: data_id, + board: BoardId(1), + name: "bcu_data".to_string(), + packet_type: PacketType::Data(vec![]), + }; + let board = Board { + id: BoardId(1), + name: "BCU".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet_def.id, packet_def)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let index = Index::new(&pod_data, None); + + let packet = MessagePacket { + id: data_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + origin: String::new(), + message: String::new(), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding into a non-message packet's id should fail"); + }; + assert_eq!(err, EncodeError::WrongPacketType { id: data_id }); + } + + #[test] + fn encode_rejects_a_too_long_origin() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let packet = MessagePacket { + id: AdjId(12), + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + origin: "a".repeat(ORIGIN_MAX), + message: String::new(), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an origin with no room for its terminator should fail"); + }; + assert_eq!(err, EncodeError::StringTooLong { max: ORIGIN_MAX }); + + let fits = MessagePacket { + origin: "a".repeat(ORIGIN_MAX - 1), + ..packet + }; + assert!(encode(&fits, &index).is_ok()); + } + + #[test] + fn encode_rejects_an_embedded_nul_byte() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let packet = MessagePacket { + id: AdjId(12), + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + origin: "ab\0cd".to_string(), + message: String::new(), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding a string with an embedded NUL byte should fail"); + }; + assert_eq!(err, EncodeError::EmbeddedNulByte); + } +} diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs new file mode 100644 index 000000000..d3a4e1e4e --- /dev/null +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -0,0 +1,463 @@ +//! Decodes a `Protection` packet: a board reporting one of its declared +//! protections triggered. Only ever received, over TCP. Also provides +//! `encode`, purely so tests can build well-formed frames without +//! hand-written byte arrays — nothing in the real backend calls it, since +//! a protection never travels backend -> board. +//! +//! Wire structure (whole frame, including the leading id): +//! ```text +//! | byte(s) | 0-1 | 2 | 3 | 4 | 5 | 6-7 | 8.. | +//! |---------|-----|------|-----|-----|--------|------|--------------| +//! | field | id | hora | min | seg | subseg | resv | value | +//! ``` +//! `id` (2 bytes) is normally read by a TCP dispatcher before it even +//! knows to call [`decode`] (it needs the id first, to decide this is a +//! protection and not a message) — when that's the case, pass it as +//! `Some`, and `reader` should start right at byte 2 above. Otherwise +//! pass `None`: `reader` should then start at byte 0, id included, and +//! `decode` reads it itself — all of this frame's byte parsing stays in +//! this file either way, never split out into whatever dispatches here. +//! `resv` (2 bytes) only exists to align `value` to an 8-byte offset; +//! it's read and discarded, never stored. `value`'s width is whatever +//! [`crate::protocol::wire::byte_width`] says for the watched +//! measurement's kind — not fixed, unlike every other field here. + +use crate::model::{AdjId, PacketType, ProtectionPacket, ShortTimestamp}; +use crate::protocol::{DecodeError, EncodeError, Index, wire}; +use std::io::Read; + +/// Decodes a protection frame, resolving the watched measurement's wire +/// type against `index`. +/// +/// `compound_id`: `Some` if the caller already read it (see this +/// module's doc comment); `None` to have `decode` read it itself off +/// `reader`'s first 2 bytes. +pub fn decode( + mut reader: impl Read, + compound_id: Option, + index: &Index, +) -> Result { + let compound_id = match compound_id { + Some(id) => id, + None => super::read_id(&mut reader)?, + }; + + // Look up the packet definition for this compound id, and ensure it's a + // protection packet. If not, the frame is malformed and we can't decode it. + let packet_def = index + .packet(compound_id) + .ok_or(DecodeError::UnknownId(compound_id))?; + + let PacketType::Protection(measurement) = &packet_def.packet_type else { + return Err(DecodeError::WrongPacketType { id: compound_id }); + }; + + // `hora`/`min`/`seg`/`subseg` (1 byte each), then 2 bytes of `resv` + // padding that only exist to align `value` to an 8-byte offset — read + // and discarded, there's nowhere to put them in `ProtectionPacket`. + let mut header = [0u8; 6]; + reader.read_exact(&mut header)?; + let timestamp = ShortTimestamp { + hour: header[0], + minute: header[1], + second: header[2], + subsecond: header[3], + }; + + // Read the `value` bytes, whose length is determined by the measurement's wire type, and decode them into a `Value`. + let mut value_bytes = vec![0u8; wire::byte_width(&measurement.kind)]; + reader.read_exact(&mut value_bytes)?; + let value = wire::decode_value(&value_bytes, &measurement.kind)?; + + Ok(ProtectionPacket { + id: compound_id, + timestamp, + value, + }) +} + +/// Encodes a [`ProtectionPacket`] back into the bytes [`decode`] expects +/// — a complete, self-contained frame, leading id included. +/// +/// Nothing in the real backend calls this — per the ADJ spec, protection +/// packets only ever travel board -> backend. This exists purely so +/// tests can build well-formed frames without hand-written byte arrays, +/// and to prove `decode` recovers exactly what was encoded. +pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, EncodeError> { + let packet_def = index + .packet(packet.id) + .ok_or(EncodeError::UnknownId(packet.id))?; + + let PacketType::Protection(measurement) = &packet_def.packet_type else { + return Err(EncodeError::WrongPacketType { id: packet.id }); + }; + + let mut bytes = packet.id.0.to_le_bytes().to_vec(); + // `hora`/`min`/`seg`/`subseg` (1 byte each), then 2 bytes of `resv` + // — decode discards these, nothing to round-trip. + bytes.extend([ + packet.timestamp.hour, + packet.timestamp.minute, + packet.timestamp.second, + packet.timestamp.subsecond, + 0, + 0, + ]); + bytes.extend(wire::encode_value(&packet.value, &measurement.kind)?); + + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + AdjId, Board, BoardId, MacAddress, Measurement, MeasurementKind, NumericKind, PacketDef, + PodData, Value, + }; + use std::collections::HashMap; + use std::io::Cursor; + use std::net::IpAddr; + + fn sample_measurement(id: u16, kind: MeasurementKind) -> Measurement { + Measurement { + id: AdjId(id), + alias: "mock_alias".to_string(), + name: "Mock Measurement".to_string(), + display_units: String::new(), + kind, + protections: vec![], + } + } + + fn index_with_protection(measurement: Measurement, position: u16) -> (Index, AdjId) { + let compound_id = AdjId((position << 13) | measurement.id.0); + let packet = PacketDef { + id: compound_id, + board: BoardId(1), + name: "mock_protection".to_string(), + packet_type: PacketType::Protection(measurement), + }; + let board = Board { + id: BoardId(1), + name: "MOCK".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet.id, packet)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + (Index::new(&pod_data, None), compound_id) + } + + #[test] + fn decodes_a_numeric_protections_timestamp_and_value() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::F32, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let mut bytes = vec![12, 30, 5, 0, 0, 0]; // hora, min, seg, subseg, resv(2) + bytes.extend(53.2f32.to_le_bytes()); + let reader = Cursor::new(bytes); + + let Ok(packet) = decode(reader, Some(compound_id), &index) else { + unreachable!("decoding a well-formed protection frame should succeed"); + }; + + assert_eq!(packet.id.measurement_id(), AdjId(100)); + assert_eq!(packet.id.protection_position(), 1); + assert_eq!(packet.timestamp.hour, 12); + assert_eq!(packet.timestamp.minute, 30); + assert_eq!(packet.timestamp.second, 5); + assert_eq!(packet.value, Value::Number(f64::from(53.2f32))); + } + + #[test] + fn decode_reads_the_id_itself_when_not_given_one() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::F32, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + // Full frame, id included — nobody's pre-read it for us. + let mut bytes = compound_id.0.to_le_bytes().to_vec(); + bytes.extend([12, 30, 5, 0, 0, 0]); // hora, min, seg, subseg, resv(2) + bytes.extend(53.2f32.to_le_bytes()); + + let Ok(packet) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding a well-formed protection frame should succeed"); + }; + + assert_eq!(packet.id, compound_id); + assert_eq!(packet.timestamp.hour, 12); + assert_eq!(packet.value, Value::Number(f64::from(53.2f32))); + } + + #[test] + fn unknown_compound_id_is_an_error() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, _) = index_with_protection(measurement, 1); + let unknown_id = AdjId((2 << 13) | 100); + + assert!(matches!( + decode(Cursor::new(vec![]), Some(unknown_id), &index), + Err(DecodeError::UnknownId(id)) if id == unknown_id + )); + } + + #[test] + fn a_non_protection_packet_at_that_id_is_the_wrong_packet_type() { + let measurement = sample_measurement( + 200, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let compound_id = AdjId(200); // pos = 0: a normal, non-protection id + let packet = PacketDef { + id: compound_id, + board: BoardId(1), + name: "mock_data".to_string(), + packet_type: PacketType::Data(vec![measurement]), + }; + let board = Board { + id: BoardId(1), + name: "MOCK".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet.id, packet)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let index = Index::new(&pod_data, None); + + assert!(matches!( + decode(Cursor::new(vec![]), Some(compound_id), &index), + Err(DecodeError::WrongPacketType { id }) if id == compound_id + )); + } + + #[test] + fn truncated_frame_is_unexpected_eof() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U32, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + // Header alone, no value bytes at all. + let reader = Cursor::new(vec![0u8; 6]); + + assert!(matches!( + decode(reader, Some(compound_id), &index), + Err(DecodeError::Io(_)) + )); + } + + // --- ENCODE TESTS --- + + #[test] + fn encode_then_decode_round_trips_timestamp_and_value() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::F32, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let original = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 12, + minute: 30, + second: 5, + subsecond: 0, + }, + value: Value::Number(f64::from(53.2f32)), + }; + + let Ok(bytes) = encode(&original, &index) else { + unreachable!("encoding a well-formed protection packet should succeed"); + }; + // `encode` writes the id too, so `decode` can read it back itself — + // proves the full frame round-trips, not just the body. + let Ok(decoded) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding what we just encoded should succeed"); + }; + + assert_eq!(decoded, original); + } + + #[test] + fn encode_zeroes_the_reserved_bytes() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let packet = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 1, + minute: 2, + second: 3, + subsecond: 4, + }, + value: Value::Number(7.0), + }; + + let Ok(bytes) = encode(&packet, &index) else { + unreachable!("encoding a well-formed protection packet should succeed"); + }; + + // bytes[0..2] = id, [2..6] = timestamp, [6..8] = resv. + assert_eq!(bytes[6], 0); + assert_eq!(bytes[7], 0); + } + + #[test] + fn encode_fails_on_unknown_id() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, _) = index_with_protection(measurement, 1); + let unknown_id = AdjId((2 << 13) | 100); + + let packet = ProtectionPacket { + id: unknown_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + value: Value::Number(0.0), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an unknown id should fail"); + }; + assert_eq!(err, EncodeError::UnknownId(unknown_id)); + } + + #[test] + fn encode_fails_on_wrong_packet_type() { + let measurement = sample_measurement( + 200, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let compound_id = AdjId(200); // pos = 0: a normal, non-protection id + let packet_def = PacketDef { + id: compound_id, + board: BoardId(1), + name: "mock_data".to_string(), + packet_type: PacketType::Data(vec![measurement]), + }; + let board = Board { + id: BoardId(1), + name: "MOCK".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet_def.id, packet_def)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let index = Index::new(&pod_data, None); + + let packet = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + value: Value::Number(0.0), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding the wrong packet type should fail"); + }; + assert_eq!(err, EncodeError::WrongPacketType { id: compound_id }); + } + + #[test] + fn encode_bubbles_up_type_mismatch() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let packet = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + value: Value::Bool(true), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding a value of the wrong variant should fail"); + }; + assert_eq!(err, EncodeError::TypeMismatch); + } + + #[test] + fn encode_bubbles_up_out_of_range() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let packet = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + value: Value::Number(300.0), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an out-of-range value should fail"); + }; + assert!(matches!(err, EncodeError::OutOfRange { .. })); + } +} diff --git a/backend/crates/core/src/protocol/tcp.rs b/backend/crates/core/src/protocol/tcp.rs new file mode 100644 index 000000000..d4becf3fb --- /dev/null +++ b/backend/crates/core/src/protocol/tcp.rs @@ -0,0 +1,162 @@ +//! Transport-level TCP framing: a board's dedicated connection carries +//! triggered protections and free-text messages from the board, and +//! orders to it. Never a `Data` packet — see the module doc on +//! [`super`]. +//! +//! Both functions here are thin: the actual per-type decoding lives in +//! [`super::packet::protection`]/[`super::packet::message`]/ +//! [`super::packet::data_order`], independent of TCP. This module only adds +//! the transport-level guarantee — [`decode_next`] can only ever return +//! a [`TcpFrame`], never a `DataPacket`. + +use super::{DecodeError, EncodeError, Index, packet}; +use crate::model::{MessagePacket, OrderPacket, ProtectionPacket}; +use std::io::Read; + +/// A decoded TCP frame — everything a board can send to the backend over +/// its dedicated TCP connection. +#[derive(Debug, Clone, PartialEq)] +pub enum TcpFrame { + /// A protection triggered. + Protection(ProtectionPacket), + /// A free-text message. + Message(MessagePacket), +} + +/// Reads and decodes the next complete frame from `reader`, resolving +/// field shapes against `index`. +/// +/// Reads the frame's leading 16-bit id first — the one piece of byte +/// handling this function does itself, since it has no way to decide +/// which of [`super::packet::protection::decode`]/ +/// [`super::packet::message::decode`] to call otherwise (see +/// `packet/README.md`'s "dispatcher's one unavoidable read"). A +/// protection's compound id always has its high 3 bits set to a nonzero +/// position (see [`crate::model::AdjId`]'s own docs on its ranges), so +/// `id.0 >= 8192` (`1 << 13`) is a protection; anything below that hands +/// off to `message::decode` instead — `message::decode` resolves it +/// against `index` itself (`UnknownId`/`WrongPacketType` if it isn't +/// actually a declared message), so there's no separate lookup to do here +/// first. +/// Either call's result is wrapped in the matching [`TcpFrame`] variant. +pub fn decode_next(mut reader: impl Read, index: &Index) -> Result { + let id = packet::read_id(&mut reader)?; + + if id.0 >= 8192 { + let protection = packet::protection::decode(reader, Some(id), index)?; + Ok(TcpFrame::Protection(protection)) + } else { + let message = packet::message::decode(reader, Some(id), index)?; + Ok(TcpFrame::Message(message)) + } +} + +/// Encodes `order` into the bytes to write to the board's connection. +pub fn encode_order(order: &OrderPacket, index: &Index) -> Result, EncodeError> { + packet::data_order::encode(order, index) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + AdjId, Board, BoardId, MacAddress, Measurement, MeasurementKind, MessageLevel, NumericKind, + PacketDef, PacketType, PodData, ShortTimestamp, Value, + }; + use std::collections::HashMap; + use std::io::Cursor; + use std::net::IpAddr; + + /// A board with one protection (position 1 on measurement 100, wire + /// type `U8`) and one declared message severity (`id` 10 = `Fault`). + fn sample_index() -> Index { + let measurement = Measurement { + id: AdjId(100), + alias: "temp".to_string(), + name: "Temp".to_string(), + display_units: String::new(), + kind: MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + protections: vec![], + }; + let compound_id = AdjId((1u16 << 13) | measurement.id.0); + let packet_def = PacketDef { + id: compound_id, + board: BoardId(1), + name: "temp_protection".to_string(), + packet_type: PacketType::Protection(measurement), + }; + let board = Board { + id: BoardId(1), + name: "MOCK".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet_def.id, packet_def)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let message_levels = HashMap::from([(AdjId(10), MessageLevel::Fault)]); + Index::new(&pod_data, Some(&message_levels)) + } + + #[test] + fn decode_next_dispatches_a_protection_frame() { + let index = sample_index(); + let compound_id = AdjId((1u16 << 13) | 100); + + let mut bytes = compound_id.0.to_le_bytes().to_vec(); + bytes.extend([12, 30, 5, 0, 0, 0]); // hora, min, seg, subseg, resv(2) + bytes.push(42); // value, U8 + + let Ok(frame) = decode_next(Cursor::new(bytes), &index) else { + unreachable!("decoding a well-formed protection frame should succeed"); + }; + + let TcpFrame::Protection(protection) = frame else { + unreachable!("a non-zero protection_position should dispatch to Protection"); + }; + assert_eq!(protection.id, compound_id); + assert_eq!(protection.value, Value::Number(42.0)); + } + + #[test] + fn decode_next_dispatches_a_message_frame() { + let index = sample_index(); + + let mut bytes = AdjId(10).0.to_le_bytes().to_vec(); + bytes.extend([1, 2, 3, 4]); // hora, min, seg, subseg + bytes.push(0); // origin: empty + bytes.push(0); // message: empty + + let Ok(frame) = decode_next(Cursor::new(bytes), &index) else { + unreachable!("decoding a well-formed message frame should succeed"); + }; + + let TcpFrame::Message(message) = frame else { + unreachable!("a zero protection_position should dispatch to Message"); + }; + assert_eq!(message.id, AdjId(10)); + assert_eq!( + message.timestamp, + ShortTimestamp { + hour: 1, + minute: 2, + second: 3, + subsecond: 4, + } + ); + } + + #[test] + fn decode_next_propagates_an_unknown_message_id() { + let index = sample_index(); + let bytes = 9999u16.to_le_bytes().to_vec(); + + let Err(err) = decode_next(Cursor::new(bytes), &index) else { + unreachable!("decoding an id that's neither a protection nor a message should fail"); + }; + assert_eq!(err, DecodeError::UnknownId(AdjId(9999))); + } +} diff --git a/backend/crates/core/src/protocol/udp.rs b/backend/crates/core/src/protocol/udp.rs new file mode 100644 index 000000000..05c44c3c0 --- /dev/null +++ b/backend/crates/core/src/protocol/udp.rs @@ -0,0 +1,17 @@ +//! Transport-level UDP framing: the shared socket carries periodic +//! telemetry (`Data` packets) from any board. Receive-only — the backend +//! never sends anything over this socket. +//! +//! The actual decoding lives in [`super::packet::data_order`], independent of +//! UDP — this module only adds the transport-level guarantee: a UDP +//! datagram is a complete, already-delimited buffer (the OS preserves +//! datagram boundaries even on a socket shared by every board), unlike +//! [`super::tcp::decode_next`]'s stream. + +use super::{DecodeError, Index, packet}; +use crate::model::DataPacket; + +/// Decodes a single UDP datagram's bytes into a [`DataPacket`]. +pub fn decode_datagram(bytes: &[u8], index: &Index) -> Result { + packet::data_order::decode(bytes, index) +} diff --git a/backend/crates/core/src/protocol/wire.rs b/backend/crates/core/src/protocol/wire.rs new file mode 100644 index 000000000..9ebe6ab8b --- /dev/null +++ b/backend/crates/core/src/protocol/wire.rs @@ -0,0 +1,414 @@ +//! Byte <-> [`Value`] codec for a single measurement, given its declared +//! [`MeasurementKind`]. The one piece of `protocol` with no framing or id +//! lookup — just "this type, these bytes, that value" and back. `tcp`/ +//! `udp` build their packet-level decoding on top of this. +//! +//! Every wire width here is fixed by the measurement's own `kind` (a +//! `NumericKind` always has a known byte width; booleans and enums are +//! fixed-width too), so decoding never needs to report back how many +//! bytes it consumed — [`byte_width`] derives it straight from the +//! `MeasurementKind`, for a caller that needs to advance through a buffer +//! or know how much to read off a stream before decoding. +//! +//! [`decode_value`]/[`encode_value`] are thin dispatchers: they only pick +//! which per-kind function to call. All the actual work lives in its own +//! public function (`decode_numeric`, `decode_boolean`, `decode_enum`...), +//! each independently implementable and testable. + +use super::{DecodeError, EncodeError}; +use crate::model::{MeasurementKind, NumericKind, Value}; + +/// Decodes a single value from the front of `bytes`, according to `kind`. +pub fn decode_value(bytes: &[u8], kind: &MeasurementKind) -> Result { + match kind { + MeasurementKind::Numeric { wire_type } => decode_numeric(bytes, *wire_type), + MeasurementKind::Boolean => decode_boolean(bytes), + MeasurementKind::Enum { options } => decode_enum(bytes, options), + } +} + +/// Encodes a single value according to `kind`. +pub fn encode_value(value: &Value, kind: &MeasurementKind) -> Result, EncodeError> { + match kind { + MeasurementKind::Numeric { wire_type } => encode_numeric(value, *wire_type), + MeasurementKind::Boolean => encode_boolean(value), + MeasurementKind::Enum { options } => encode_enum(value, options), + } +} + +/// How many bytes `kind` takes on the wire. +pub fn byte_width(kind: &MeasurementKind) -> usize { + match kind { + MeasurementKind::Numeric { wire_type } => match wire_type { + NumericKind::U8 | NumericKind::I8 => 1, + NumericKind::U16 | NumericKind::I16 => 2, + NumericKind::U32 | NumericKind::I32 | NumericKind::F32 => 4, + NumericKind::U64 | NumericKind::I64 | NumericKind::F64 => 8, + }, + MeasurementKind::Boolean => 1, + MeasurementKind::Enum { .. } => 1, + } +} + +/// Decodes a numeric value of the given wire type, little-endian. +pub fn decode_numeric(bytes: &[u8], wire_type: NumericKind) -> Result { + fn take(bytes: &[u8]) -> Result<[u8; N], DecodeError> { + bytes + .get(..N) + .and_then(|slice| slice.try_into().ok()) + .ok_or(DecodeError::UnexpectedEof) + } + + let number: f64 = match wire_type { + NumericKind::U8 => f64::from(u8::from_le_bytes(take::<1>(bytes)?)), + NumericKind::U16 => f64::from(u16::from_le_bytes(take::<2>(bytes)?)), + NumericKind::U32 => f64::from(u32::from_le_bytes(take::<4>(bytes)?)), + NumericKind::U64 => u64::from_le_bytes(take::<8>(bytes)?) as f64, + NumericKind::I8 => f64::from(i8::from_le_bytes(take::<1>(bytes)?)), + NumericKind::I16 => f64::from(i16::from_le_bytes(take::<2>(bytes)?)), + NumericKind::I32 => f64::from(i32::from_le_bytes(take::<4>(bytes)?)), + NumericKind::I64 => i64::from_le_bytes(take::<8>(bytes)?) as f64, + NumericKind::F32 => f64::from(f32::from_le_bytes(take::<4>(bytes)?)), + NumericKind::F64 => f64::from_le_bytes(take::<8>(bytes)?), + }; + + Ok(Value::Number(number)) +} + +/// Encodes a numeric value as the given wire type, little-endian. +/// +/// Errors with [`EncodeError::OutOfRange`] if `value` doesn't fit the +/// wire type's representable range (this also rejects NaN, which compares +/// false to every bound). +pub fn encode_numeric(value: &Value, wire_type: NumericKind) -> Result, EncodeError> { + let Value::Number(number) = value else { + return Err(EncodeError::TypeMismatch); + }; + let number = *number; + + let (min, max) = numeric_range(wire_type); + if !(min..=max).contains(&number) { + return Err(EncodeError::OutOfRange { + value: number, + min, + max, + }); + } + + Ok(match wire_type { + NumericKind::U8 => vec![number as u8], // no need to call `to_le_bytes` for a single byte + NumericKind::U16 => (number as u16).to_le_bytes().to_vec(), + NumericKind::U32 => (number as u32).to_le_bytes().to_vec(), + NumericKind::U64 => (number as u64).to_le_bytes().to_vec(), + NumericKind::I8 => (number as i8).to_le_bytes().to_vec(), + NumericKind::I16 => (number as i16).to_le_bytes().to_vec(), + NumericKind::I32 => (number as i32).to_le_bytes().to_vec(), + NumericKind::I64 => (number as i64).to_le_bytes().to_vec(), + NumericKind::F32 => (number as f32).to_le_bytes().to_vec(), + NumericKind::F64 => number.to_le_bytes().to_vec(), + }) +} + +/// The `[min, max]` range a wire type can represent, as `f64` bounds. +fn numeric_range(wire_type: NumericKind) -> (f64, f64) { + match wire_type { + NumericKind::U8 => (f64::from(u8::MIN), f64::from(u8::MAX)), + NumericKind::U16 => (f64::from(u16::MIN), f64::from(u16::MAX)), + NumericKind::U32 => (f64::from(u32::MIN), f64::from(u32::MAX)), + NumericKind::U64 => (u64::MIN as f64, u64::MAX as f64), + NumericKind::I8 => (f64::from(i8::MIN), f64::from(i8::MAX)), + NumericKind::I16 => (f64::from(i16::MIN), f64::from(i16::MAX)), + NumericKind::I32 => (f64::from(i32::MIN), f64::from(i32::MAX)), + NumericKind::I64 => (i64::MIN as f64, i64::MAX as f64), + NumericKind::F32 => (f64::from(f32::MIN), f64::from(f32::MAX)), + NumericKind::F64 => (f64::MIN, f64::MAX), + } +} + +/// Decodes a boolean value: a single byte, zero for `false`, any other +/// value for `true`. +pub fn decode_boolean(bytes: &[u8]) -> Result { + let &byte = bytes.first().ok_or(DecodeError::UnexpectedEof)?; + Ok(Value::Bool(byte != 0)) +} + +/// Encodes a boolean value as a single byte: `0x00` for `false`, `0x01` +/// for `true`. +pub fn encode_boolean(value: &Value) -> Result, EncodeError> { + let Value::Bool(value) = value else { + return Err(EncodeError::TypeMismatch); + }; + Ok(vec![u8::from(*value)]) +} + +/// Decodes an enum value: a single `uint8` byte, the index into +/// `options`. Returns the option's text, not its index — see +/// [`encode_enum`] for why that's not symmetric. +pub fn decode_enum(bytes: &[u8], options: &[String]) -> Result { + let &index = bytes.first().ok_or(DecodeError::UnexpectedEof)?; + let Some(option) = options.get(usize::from(index)) else { + return Err(DecodeError::InvalidEnumValue { + index: u32::from(index), + options: options.len(), + }); + }; + Ok(Value::Enum(option.clone())) +} + +/// Encodes an enum value as a single `uint8` byte. +/// +/// `value` is the already-selected index (e.g. a frontend dropdown +/// already resolved to its position in `options`), **not** the option's +/// text — unlike [`decode_enum`], which produces the text. Errors with +/// [`EncodeError::OutOfRange`] if the index isn't a whole number or +/// doesn't fall within `options`. +pub fn encode_enum(value: &Value, options: &[String]) -> Result, EncodeError> { + let Value::Number(index) = value else { + return Err(EncodeError::TypeMismatch); + }; + let index = *index; + let max = options.len().saturating_sub(1) as f64; + + let in_range = !options.is_empty() && index.fract() == 0.0 && (0.0..=max).contains(&index); + if !in_range { + return Err(EncodeError::OutOfRange { + value: index, + min: 0.0, + max, + }); + } + + Ok(vec![index as u8]) // no need to call `to_le_bytes` for a single byte +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn byte_width_matches_each_numeric_kinds_wire_size() { + let cases = [ + (NumericKind::U8, 1), + (NumericKind::I8, 1), + (NumericKind::U16, 2), + (NumericKind::I16, 2), + (NumericKind::U32, 4), + (NumericKind::I32, 4), + (NumericKind::F32, 4), + (NumericKind::U64, 8), + (NumericKind::I64, 8), + (NumericKind::F64, 8), + ]; + + for (wire_type, expected) in cases { + assert_eq!( + byte_width(&MeasurementKind::Numeric { wire_type }), + expected + ); + } + } + + #[test] + fn byte_width_of_an_enum_is_always_one() { + assert_eq!( + byte_width(&MeasurementKind::Enum { + options: vec!["Idle".to_string(), "Fault".to_string()] + }), + 1 + ); + } + + #[test] + fn byte_width_of_a_boolean_is_one() { + assert_eq!(byte_width(&MeasurementKind::Boolean), 1); + } + + #[test] + fn decodes_a_zero_byte_as_false() { + let Ok(value) = decode_boolean(&[0x00]) else { + unreachable!("decoding a zero byte should succeed"); + }; + assert_eq!(value, Value::Bool(false)); + } + + #[test] + fn decodes_any_nonzero_byte_as_true() { + let Ok(value) = decode_boolean(&[0xFF]) else { + unreachable!("decoding a nonzero byte should succeed"); + }; + assert_eq!(value, Value::Bool(true)); + } + + #[test] + fn decode_boolean_rejects_empty_input() { + assert!(matches!( + decode_boolean(&[]), + Err(DecodeError::UnexpectedEof) + )); + } + + #[test] + fn boolean_round_trips_through_encode_then_decode() { + for value in [true, false] { + let Ok(bytes) = encode_boolean(&Value::Bool(value)) else { + unreachable!("encoding a well-formed boolean should succeed"); + }; + let Ok(decoded) = decode_boolean(&bytes) else { + unreachable!("decoding what we just encoded should succeed"); + }; + assert_eq!(decoded, Value::Bool(value)); + } + } + + #[test] + fn encode_boolean_rejects_the_wrong_value_variant() { + assert!(matches!( + encode_boolean(&Value::Number(1.0)), + Err(EncodeError::TypeMismatch) + )); + } + + #[test] + fn decodes_an_unsigned_integer_little_endian() { + let Ok(value) = decode_numeric(&[0x34, 0x12], NumericKind::U16) else { + unreachable!("decoding a well-formed U16 should succeed"); + }; + assert_eq!(value, Value::Number(0x1234 as f64)); + } + + #[test] + fn decodes_a_signed_integer_little_endian() { + let Ok(value) = decode_numeric(&[0xFF], NumericKind::I8) else { + unreachable!("decoding a well-formed I8 should succeed"); + }; + assert_eq!(value, Value::Number(-1.0)); + } + + #[test] + fn decodes_floats_little_endian() { + let bytes = 1.5f32.to_le_bytes(); + let Ok(value) = decode_numeric(&bytes, NumericKind::F32) else { + unreachable!("decoding a well-formed F32 should succeed"); + }; + assert_eq!(value, Value::Number(1.5)); + } + + #[test] + fn decode_numeric_rejects_short_input() { + assert!(matches!( + decode_numeric(&[0x00], NumericKind::U32), + Err(DecodeError::UnexpectedEof) + )); + } + + #[test] + fn numeric_round_trips_through_encode_then_decode() { + let value = Value::Number(1234.0); + let Ok(bytes) = encode_numeric(&value, NumericKind::U16) else { + unreachable!("encoding a well-formed value should succeed"); + }; + let Ok(decoded) = decode_numeric(&bytes, NumericKind::U16) else { + unreachable!("decoding what we just encoded should succeed"); + }; + assert_eq!(decoded, value); + } + + #[test] + fn encode_numeric_rejects_the_wrong_value_variant() { + assert!(matches!( + encode_numeric(&Value::Bool(true), NumericKind::U8), + Err(EncodeError::TypeMismatch) + )); + } + + #[test] + fn encode_numeric_rejects_out_of_range_values() { + assert!(matches!( + encode_numeric(&Value::Number(300.0), NumericKind::U8), + Err(EncodeError::OutOfRange { .. }) + )); + assert!(matches!( + encode_numeric(&Value::Number(-1.0), NumericKind::U8), + Err(EncodeError::OutOfRange { .. }) + )); + } + + #[test] + fn encode_numeric_accepts_the_exact_boundary_values() { + assert!(encode_numeric(&Value::Number(0.0), NumericKind::U8).is_ok()); + assert!(encode_numeric(&Value::Number(255.0), NumericKind::U8).is_ok()); + } + + #[test] + fn encode_numeric_rejects_nan() { + assert!(matches!( + encode_numeric(&Value::Number(f64::NAN), NumericKind::F32), + Err(EncodeError::OutOfRange { .. }) + )); + } + + #[test] + fn decodes_an_enum_value_by_index_into_its_text() { + let options = vec!["Idle".to_string(), "Fault".to_string()]; + let Ok(value) = decode_enum(&[1], &options) else { + unreachable!("decoding a valid enum index should succeed"); + }; + assert_eq!(value, Value::Enum("Fault".to_string())); + } + + #[test] + fn decode_enum_rejects_an_out_of_range_index() { + let options = vec!["Idle".to_string()]; + assert!(matches!( + decode_enum(&[5], &options), + Err(DecodeError::InvalidEnumValue { + index: 5, + options: 1 + }) + )); + } + + #[test] + fn decode_enum_rejects_empty_input() { + assert!(matches!( + decode_enum(&[], &[]), + Err(DecodeError::UnexpectedEof) + )); + } + + #[test] + fn encodes_an_enum_value_from_its_already_selected_index() { + let options = vec!["Idle".to_string(), "Fault".to_string()]; + let Ok(bytes) = encode_enum(&Value::Number(1.0), &options) else { + unreachable!("encoding a valid index should succeed"); + }; + assert_eq!(bytes, vec![1]); + } + + #[test] + fn encode_enum_rejects_an_out_of_range_index() { + let options = vec!["Idle".to_string()]; + assert!(matches!( + encode_enum(&Value::Number(1.0), &options), + Err(EncodeError::OutOfRange { .. }) + )); + } + + #[test] + fn encode_enum_rejects_a_fractional_index() { + let options = vec!["Idle".to_string(), "Fault".to_string()]; + assert!(matches!( + encode_enum(&Value::Number(0.5), &options), + Err(EncodeError::OutOfRange { .. }) + )); + } + + #[test] + fn encode_enum_rejects_the_wrong_value_variant() { + let options = vec!["Idle".to_string()]; + assert!(matches!( + encode_enum(&Value::Enum("Idle".to_string()), &options), + Err(EncodeError::TypeMismatch) + )); + } +} diff --git a/backend/crates/station/Cargo.toml b/backend/crates/station/Cargo.toml new file mode 100644 index 000000000..20c403657 --- /dev/null +++ b/backend/crates/station/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "station" +version = "0.1.0" +description = "Hyperloop UPV control station backend, built on software-core" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +software-core = { workspace = true, features = ["net", "git"] } + +serde.workspace = true +thiserror.workspace = true +tokio.workspace = true +toml.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/backend/crates/station/README.md b/backend/crates/station/README.md new file mode 100644 index 000000000..00e8661df --- /dev/null +++ b/backend/crates/station/README.md @@ -0,0 +1,17 @@ +# station + +El backend de la Control Station de Hyperloop UPV: escucha a las placas del +pod, decodifica sus paquetes, los reenvía a los frontends y guarda un +registro en disco. Construido sobre [`software-core`](../core/README.md). + +Ver [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) para el diseño completo. + +## Arrancar + +```sh +cargo run -p station -- --config ./dev-config.toml +``` + +*(Pendiente: crear `dev-config.toml` y `config.toml`, portados de +`backend_old/cmd/`.)* + diff --git a/backend/crates/station/src/main.rs b/backend/crates/station/src/main.rs new file mode 100644 index 000000000..27767e13f --- /dev/null +++ b/backend/crates/station/src/main.rs @@ -0,0 +1,25 @@ +//! Ejecutable mínimo para demostrar la integración con `software-core`. + +use tracing_subscriber::EnvFilter; + +fn main() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + tracing_subscriber::fmt().with_env_filter(filter).init(); + + tracing::info!("iniciando demo de station"); + let name = std::env::args().nth(1).unwrap_or_else(|| "World".into()); + let _greeting = software_core::hello_world::greet(&name); + + let adj_path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../core/src/adj/test/adj"); + let adj = match software_core::adj::load_from_dir(&adj_path) { + Ok(adj) => adj, + Err(err) => { + tracing::error!(%err, "no se pudo cargar el ADJ de prueba"); + std::process::exit(1); + } + }; + tracing::info!(boards = adj.pod_data.boards.len(), "adj de prueba cargado"); + + tracing::info!("demo finalizada"); +} diff --git a/backend/package.json b/backend/package.json index ae867cc7e..cd65355be 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,12 +2,14 @@ "name": "backend", "version": "1.0.0", "private": true, - "author": "Hyperloop UPV Team", + "author": "Hyperloop UPV", "license": "MIT", "scripts": { - "dev": "go run ./cmd --config ./cmd/dev-config.toml", - "dev:main": "go run ./cmd --config ./cmd/config.toml", - "build": "go build -o bin/backend ./cmd", - "build:ci": "go build" + "dev": "cargo run -p station -- --config ./crates/station/dev-config.toml", + "dev:main": "cargo run -p station --release -- --config ./crates/station/config.toml", + "build": "cargo build -p station --release", + "build:ci": "cargo build --workspace --release", + "lint": "cargo clippy --workspace --all-targets -- -D warnings", + "fmt": "cargo fmt --all" } } diff --git a/backend/readme-demo.md b/backend/readme-demo.md new file mode 100644 index 000000000..050e911ac --- /dev/null +++ b/backend/readme-demo.md @@ -0,0 +1,41 @@ +# Demo: core → station + tracing + +Desde `software/backend`, con Rust 1.98.0 y Cargo instalados (la primera +ejecución descarga las dependencias): + +```sh +# Ejecutar: muestra el saludo y el ciclo de vida a nivel INFO. +cargo run -p station +cargo run -p station -- "Hyperloop UPV" + +# Mostrar también DEBUG del módulo, manteniendo INFO en station. +RUST_LOG=info,software_core::hello_world=debug cargo run -p station -- "Lucía" + +# Filtrar todos los eventos de esta demo (no emite errores). +RUST_LOG=error cargo run -p station + +# Tests unitarios y ejemplo de documentación ejecutable (doctest). +cargo test --workspace + +# Generar documentación HTML de la API. +cargo doc --workspace --no-deps + +# Comprobaciones de formato y lints. +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +``` + +Abre `target/doc/software_core/hello_world/index.html` para ver la API. + +- `crates/core/src/hello_world.rs`: `greet(&str) -> String`, con rustdoc, +- `crates/core/src/lib.rs`: exporta el módulo público. +- `crates/station/src/main.rs`: inicializa el subscriber una sola vez y llama a `greet`. +- `#[tracing::instrument]` crea el span `hello_world`; los eventos incluyen + el destinatario y campos estructurados como `greeting` o `used_default`. + `INFO` muestra el saludo; `DEBUG` permite inspeccionar la elección del destinatario. +- Core solo emite eventos: sin subscriber, devuelve igualmente el saludo. + Los tests comprueban ese resultado sin configurar logging global. + +Esta demo termina tras saludar; todavía no arranca servidores ni carga TOML. +Usa los comandos Cargo de arriba: los scripts `pnpm dev` están preparados para +la futura configuración del backend, que esta demo no interpreta. diff --git a/backend/rust-toolchain.toml b/backend/rust-toolchain.toml new file mode 100644 index 000000000..aa20bc990 --- /dev/null +++ b/backend/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.98.0" +components = ["rustfmt", "clippy"] diff --git a/backend/rustfmt.toml b/backend/rustfmt.toml new file mode 100644 index 000000000..4937083a4 --- /dev/null +++ b/backend/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2024" +max_width = 100 +use_field_init_shorthand = true diff --git a/backend_old/.gitignore b/backend_old/.gitignore new file mode 100644 index 000000000..384729b1f --- /dev/null +++ b/backend_old/.gitignore @@ -0,0 +1,22 @@ +log +trace.json + +/bin + +# Test coverage +*testfile + +# Build artifacts +/bin/ +backend +trace.json + +# Test data +*.out +c.out + +# Logs +cmd/logger/ +cmd/cmd +cmd/adj +/logger/ \ No newline at end of file diff --git a/backend/captures/IP_in_IP.cap b/backend_old/captures/IP_in_IP.cap similarity index 100% rename from backend/captures/IP_in_IP.cap rename to backend_old/captures/IP_in_IP.cap diff --git a/backend/captures/README.md b/backend_old/captures/README.md similarity index 100% rename from backend/captures/README.md rename to backend_old/captures/README.md diff --git a/backend/captures/path_MTU_discovery.cap b/backend_old/captures/path_MTU_discovery.cap similarity index 100% rename from backend/captures/path_MTU_discovery.cap rename to backend_old/captures/path_MTU_discovery.cap diff --git a/backend/captures/telnet.cap b/backend_old/captures/telnet.cap similarity index 100% rename from backend/captures/telnet.cap rename to backend_old/captures/telnet.cap diff --git a/backend/cmd/config.toml b/backend_old/cmd/config.toml similarity index 100% rename from backend/cmd/config.toml rename to backend_old/cmd/config.toml diff --git a/backend/cmd/dev-config.toml b/backend_old/cmd/dev-config.toml similarity index 100% rename from backend/cmd/dev-config.toml rename to backend_old/cmd/dev-config.toml diff --git a/backend/cmd/main.go b/backend_old/cmd/main.go similarity index 100% rename from backend/cmd/main.go rename to backend_old/cmd/main.go diff --git a/backend/cmd/orchestrator.go b/backend_old/cmd/orchestrator.go similarity index 100% rename from backend/cmd/orchestrator.go rename to backend_old/cmd/orchestrator.go diff --git a/backend/cmd/setup_transport.go b/backend_old/cmd/setup_transport.go similarity index 100% rename from backend/cmd/setup_transport.go rename to backend_old/cmd/setup_transport.go diff --git a/backend/cmd/setup_vehicle.go b/backend_old/cmd/setup_vehicle.go similarity index 100% rename from backend/cmd/setup_vehicle.go rename to backend_old/cmd/setup_vehicle.go diff --git a/backend/go.mod b/backend_old/go.mod similarity index 100% rename from backend/go.mod rename to backend_old/go.mod diff --git a/backend/go.sum b/backend_old/go.sum similarity index 100% rename from backend/go.sum rename to backend_old/go.sum diff --git a/backend/internal/common/chan.go b/backend_old/internal/common/chan.go similarity index 100% rename from backend/internal/common/chan.go rename to backend_old/internal/common/chan.go diff --git a/backend/internal/common/errors.go b/backend_old/internal/common/errors.go similarity index 100% rename from backend/internal/common/errors.go rename to backend_old/internal/common/errors.go diff --git a/backend/internal/common/io.go b/backend_old/internal/common/io.go similarity index 100% rename from backend/internal/common/io.go rename to backend_old/internal/common/io.go diff --git a/backend/internal/common/map.go b/backend_old/internal/common/map.go similarity index 100% rename from backend/internal/common/map.go rename to backend_old/internal/common/map.go diff --git a/backend/internal/common/moving_average.go b/backend_old/internal/common/moving_average.go similarity index 100% rename from backend/internal/common/moving_average.go rename to backend_old/internal/common/moving_average.go diff --git a/backend/internal/common/net.go b/backend_old/internal/common/net.go similarity index 100% rename from backend/internal/common/net.go rename to backend_old/internal/common/net.go diff --git a/backend/internal/common/numbers.go b/backend_old/internal/common/numbers.go similarity index 100% rename from backend/internal/common/numbers.go rename to backend_old/internal/common/numbers.go diff --git a/backend/internal/common/ring_buf.go b/backend_old/internal/common/ring_buf.go similarity index 100% rename from backend/internal/common/ring_buf.go rename to backend_old/internal/common/ring_buf.go diff --git a/backend/internal/common/set.go b/backend_old/internal/common/set.go similarity index 100% rename from backend/internal/common/set.go rename to backend_old/internal/common/set.go diff --git a/backend/internal/common/slice.go b/backend_old/internal/common/slice.go similarity index 100% rename from backend/internal/common/slice.go rename to backend_old/internal/common/slice.go diff --git a/backend/internal/common/types.go b/backend_old/internal/common/types.go similarity index 100% rename from backend/internal/common/types.go rename to backend_old/internal/common/types.go diff --git a/backend/internal/config/config.go b/backend_old/internal/config/config.go similarity index 100% rename from backend/internal/config/config.go rename to backend_old/internal/config/config.go diff --git a/backend/internal/config/config_types.go b/backend_old/internal/config/config_types.go similarity index 100% rename from backend/internal/config/config_types.go rename to backend_old/internal/config/config_types.go diff --git a/backend/internal/flags/flags.go b/backend_old/internal/flags/flags.go similarity index 100% rename from backend/internal/flags/flags.go rename to backend_old/internal/flags/flags.go diff --git a/backend/internal/pod_data/measurement.go b/backend_old/internal/pod_data/measurement.go similarity index 100% rename from backend/internal/pod_data/measurement.go rename to backend_old/internal/pod_data/measurement.go diff --git a/backend/internal/pod_data/models.go b/backend_old/internal/pod_data/models.go similarity index 100% rename from backend/internal/pod_data/models.go rename to backend_old/internal/pod_data/models.go diff --git a/backend/internal/pod_data/pod_data.go b/backend_old/internal/pod_data/pod_data.go similarity index 100% rename from backend/internal/pod_data/pod_data.go rename to backend_old/internal/pod_data/pod_data.go diff --git a/backend/internal/pod_data/sortable_packets.go b/backend_old/internal/pod_data/sortable_packets.go similarity index 100% rename from backend/internal/pod_data/sortable_packets.go rename to backend_old/internal/pod_data/sortable_packets.go diff --git a/backend/internal/server/config.go b/backend_old/internal/server/config.go similarity index 100% rename from backend/internal/server/config.go rename to backend_old/internal/server/config.go diff --git a/backend/internal/update_factory/models/update.go b/backend_old/internal/update_factory/models/update.go similarity index 100% rename from backend/internal/update_factory/models/update.go rename to backend_old/internal/update_factory/models/update.go diff --git a/backend/internal/update_factory/update_factory.go b/backend_old/internal/update_factory/update_factory.go similarity index 100% rename from backend/internal/update_factory/update_factory.go rename to backend_old/internal/update_factory/update_factory.go diff --git a/backend/internal/utils/units.go b/backend_old/internal/utils/units.go similarity index 100% rename from backend/internal/utils/units.go rename to backend_old/internal/utils/units.go diff --git a/backend/internal/vehicle/config.go b/backend_old/internal/vehicle/config.go similarity index 100% rename from backend/internal/vehicle/config.go rename to backend_old/internal/vehicle/config.go diff --git a/backend/internal/vehicle/models/order_data.go b/backend_old/internal/vehicle/models/order_data.go similarity index 100% rename from backend/internal/vehicle/models/order_data.go rename to backend_old/internal/vehicle/models/order_data.go diff --git a/backend/internal/vehicle/packet_parser/config.go b/backend_old/internal/vehicle/packet_parser/config.go similarity index 100% rename from backend/internal/vehicle/packet_parser/config.go rename to backend_old/internal/vehicle/packet_parser/config.go diff --git a/backend/package-lock.json b/backend_old/package-lock.json similarity index 100% rename from backend/package-lock.json rename to backend_old/package-lock.json diff --git a/backend_old/package.json b/backend_old/package.json new file mode 100644 index 000000000..ae867cc7e --- /dev/null +++ b/backend_old/package.json @@ -0,0 +1,13 @@ +{ + "name": "backend", + "version": "1.0.0", + "private": true, + "author": "Hyperloop UPV Team", + "license": "MIT", + "scripts": { + "dev": "go run ./cmd --config ./cmd/dev-config.toml", + "dev:main": "go run ./cmd --config ./cmd/config.toml", + "build": "go build -o bin/backend ./cmd", + "build:ci": "go build" + } +} diff --git a/backend/pkg/abstraction/board.go b/backend_old/pkg/abstraction/board.go similarity index 100% rename from backend/pkg/abstraction/board.go rename to backend_old/pkg/abstraction/board.go diff --git a/backend/pkg/abstraction/broker.go b/backend_old/pkg/abstraction/broker.go similarity index 100% rename from backend/pkg/abstraction/broker.go rename to backend_old/pkg/abstraction/broker.go diff --git a/backend/pkg/abstraction/logger.go b/backend_old/pkg/abstraction/logger.go similarity index 100% rename from backend/pkg/abstraction/logger.go rename to backend_old/pkg/abstraction/logger.go diff --git a/backend/pkg/abstraction/packet.go b/backend_old/pkg/abstraction/packet.go similarity index 100% rename from backend/pkg/abstraction/packet.go rename to backend_old/pkg/abstraction/packet.go diff --git a/backend/pkg/abstraction/transport.go b/backend_old/pkg/abstraction/transport.go similarity index 100% rename from backend/pkg/abstraction/transport.go rename to backend_old/pkg/abstraction/transport.go diff --git a/backend/pkg/adj/adj.go b/backend_old/pkg/adj/adj.go similarity index 100% rename from backend/pkg/adj/adj.go rename to backend_old/pkg/adj/adj.go diff --git a/backend/pkg/adj/boards.go b/backend_old/pkg/adj/boards.go similarity index 100% rename from backend/pkg/adj/boards.go rename to backend_old/pkg/adj/boards.go diff --git a/backend/pkg/adj/git.go b/backend_old/pkg/adj/git.go similarity index 100% rename from backend/pkg/adj/git.go rename to backend_old/pkg/adj/git.go diff --git a/backend/pkg/adj/models.go b/backend_old/pkg/adj/models.go similarity index 100% rename from backend/pkg/adj/models.go rename to backend_old/pkg/adj/models.go diff --git a/backend/pkg/adj/validator.go b/backend_old/pkg/adj/validator.go similarity index 100% rename from backend/pkg/adj/validator.go rename to backend_old/pkg/adj/validator.go diff --git a/backend/pkg/boards/errors.go b/backend_old/pkg/boards/errors.go similarity index 100% rename from backend/pkg/boards/errors.go rename to backend_old/pkg/boards/errors.go diff --git a/backend/pkg/boards/events.go b/backend_old/pkg/boards/events.go similarity index 100% rename from backend/pkg/boards/events.go rename to backend_old/pkg/boards/events.go diff --git a/backend/pkg/broker/broker.go b/backend_old/pkg/broker/broker.go similarity index 100% rename from backend/pkg/broker/broker.go rename to backend_old/pkg/broker/broker.go diff --git a/backend/pkg/broker/errors.go b/backend_old/pkg/broker/errors.go similarity index 100% rename from backend/pkg/broker/errors.go rename to backend_old/pkg/broker/errors.go diff --git a/backend/pkg/broker/topics/connection/connection_test.go b/backend_old/pkg/broker/topics/connection/connection_test.go similarity index 100% rename from backend/pkg/broker/topics/connection/connection_test.go rename to backend_old/pkg/broker/topics/connection/connection_test.go diff --git a/backend/pkg/broker/topics/connection/push.go b/backend_old/pkg/broker/topics/connection/push.go similarity index 100% rename from backend/pkg/broker/topics/connection/push.go rename to backend_old/pkg/broker/topics/connection/push.go diff --git a/backend/pkg/broker/topics/connection/update.go b/backend_old/pkg/broker/topics/connection/update.go similarity index 100% rename from backend/pkg/broker/topics/connection/update.go rename to backend_old/pkg/broker/topics/connection/update.go diff --git a/backend/pkg/broker/topics/data/data_test.go b/backend_old/pkg/broker/topics/data/data_test.go similarity index 100% rename from backend/pkg/broker/topics/data/data_test.go rename to backend_old/pkg/broker/topics/data/data_test.go diff --git a/backend/pkg/broker/topics/data/push.go b/backend_old/pkg/broker/topics/data/push.go similarity index 100% rename from backend/pkg/broker/topics/data/push.go rename to backend_old/pkg/broker/topics/data/push.go diff --git a/backend/pkg/broker/topics/data/update.go b/backend_old/pkg/broker/topics/data/update.go similarity index 100% rename from backend/pkg/broker/topics/data/update.go rename to backend_old/pkg/broker/topics/data/update.go diff --git a/backend/pkg/broker/topics/errors.go b/backend_old/pkg/broker/topics/errors.go similarity index 100% rename from backend/pkg/broker/topics/errors.go rename to backend_old/pkg/broker/topics/errors.go diff --git a/backend/pkg/broker/topics/logger/enable.go b/backend_old/pkg/broker/topics/logger/enable.go similarity index 100% rename from backend/pkg/broker/topics/logger/enable.go rename to backend_old/pkg/broker/topics/logger/enable.go diff --git a/backend/pkg/broker/topics/logger/logger_test.go b/backend_old/pkg/broker/topics/logger/logger_test.go similarity index 100% rename from backend/pkg/broker/topics/logger/logger_test.go rename to backend_old/pkg/broker/topics/logger/logger_test.go diff --git a/backend/pkg/broker/topics/message/message_test.go b/backend_old/pkg/broker/topics/message/message_test.go similarity index 100% rename from backend/pkg/broker/topics/message/message_test.go rename to backend_old/pkg/broker/topics/message/message_test.go diff --git a/backend/pkg/broker/topics/message/update.go b/backend_old/pkg/broker/topics/message/update.go similarity index 100% rename from backend/pkg/broker/topics/message/update.go rename to backend_old/pkg/broker/topics/message/update.go diff --git a/backend/pkg/broker/topics/order/order_test.go b/backend_old/pkg/broker/topics/order/order_test.go similarity index 100% rename from backend/pkg/broker/topics/order/order_test.go rename to backend_old/pkg/broker/topics/order/order_test.go diff --git a/backend/pkg/broker/topics/order/send.go b/backend_old/pkg/broker/topics/order/send.go similarity index 100% rename from backend/pkg/broker/topics/order/send.go rename to backend_old/pkg/broker/topics/order/send.go diff --git a/backend/pkg/broker/topics/order/state.go b/backend_old/pkg/broker/topics/order/state.go similarity index 100% rename from backend/pkg/broker/topics/order/state.go rename to backend_old/pkg/broker/topics/order/state.go diff --git a/backend/pkg/broker/topics/tests_functions/tests_functions.go b/backend_old/pkg/broker/topics/tests_functions/tests_functions.go similarity index 100% rename from backend/pkg/broker/topics/tests_functions/tests_functions.go rename to backend_old/pkg/broker/topics/tests_functions/tests_functions.go diff --git a/backend/pkg/broker/topics/topic.go b/backend_old/pkg/broker/topics/topic.go similarity index 100% rename from backend/pkg/broker/topics/topic.go rename to backend_old/pkg/broker/topics/topic.go diff --git a/backend/pkg/http/handlers.go b/backend_old/pkg/http/handlers.go similarity index 100% rename from backend/pkg/http/handlers.go rename to backend_old/pkg/http/handlers.go diff --git a/backend/pkg/http/mux.go b/backend_old/pkg/http/mux.go similarity index 100% rename from backend/pkg/http/mux.go rename to backend_old/pkg/http/mux.go diff --git a/backend/pkg/http/server.go b/backend_old/pkg/http/server.go similarity index 100% rename from backend/pkg/http/server.go rename to backend_old/pkg/http/server.go diff --git a/backend/pkg/logger/.gitignore b/backend_old/pkg/logger/.gitignore similarity index 100% rename from backend/pkg/logger/.gitignore rename to backend_old/pkg/logger/.gitignore diff --git a/backend/pkg/logger/base/logger.go b/backend_old/pkg/logger/base/logger.go similarity index 100% rename from backend/pkg/logger/base/logger.go rename to backend_old/pkg/logger/base/logger.go diff --git a/backend/pkg/logger/base/logger_test.go b/backend_old/pkg/logger/base/logger_test.go similarity index 100% rename from backend/pkg/logger/base/logger_test.go rename to backend_old/pkg/logger/base/logger_test.go diff --git a/backend/pkg/logger/data/.gitignore b/backend_old/pkg/logger/data/.gitignore similarity index 100% rename from backend/pkg/logger/data/.gitignore rename to backend_old/pkg/logger/data/.gitignore diff --git a/backend/pkg/logger/data/logger.go b/backend_old/pkg/logger/data/logger.go similarity index 100% rename from backend/pkg/logger/data/logger.go rename to backend_old/pkg/logger/data/logger.go diff --git a/backend/pkg/logger/data/logger_test.go b/backend_old/pkg/logger/data/logger_test.go similarity index 100% rename from backend/pkg/logger/data/logger_test.go rename to backend_old/pkg/logger/data/logger_test.go diff --git a/backend/pkg/logger/errors.go b/backend_old/pkg/logger/errors.go similarity index 100% rename from backend/pkg/logger/errors.go rename to backend_old/pkg/logger/errors.go diff --git a/backend/pkg/logger/file/csv.go b/backend_old/pkg/logger/file/csv.go similarity index 100% rename from backend/pkg/logger/file/csv.go rename to backend_old/pkg/logger/file/csv.go diff --git a/backend/pkg/logger/file/csv_test.go b/backend_old/pkg/logger/file/csv_test.go similarity index 100% rename from backend/pkg/logger/file/csv_test.go rename to backend_old/pkg/logger/file/csv_test.go diff --git a/backend/pkg/logger/logger.go b/backend_old/pkg/logger/logger.go similarity index 100% rename from backend/pkg/logger/logger.go rename to backend_old/pkg/logger/logger.go diff --git a/backend/pkg/logger/logger_test.go b/backend_old/pkg/logger/logger_test.go similarity index 100% rename from backend/pkg/logger/logger_test.go rename to backend_old/pkg/logger/logger_test.go diff --git a/backend/pkg/logger/order/logger.go b/backend_old/pkg/logger/order/logger.go similarity index 100% rename from backend/pkg/logger/order/logger.go rename to backend_old/pkg/logger/order/logger.go diff --git a/backend/pkg/logger/order/logger_test.go b/backend_old/pkg/logger/order/logger_test.go similarity index 100% rename from backend/pkg/logger/order/logger_test.go rename to backend_old/pkg/logger/order/logger_test.go diff --git a/backend/pkg/logger/protection/logger.go b/backend_old/pkg/logger/protection/logger.go similarity index 100% rename from backend/pkg/logger/protection/logger.go rename to backend_old/pkg/logger/protection/logger.go diff --git a/backend/pkg/logger/state/logger.go b/backend_old/pkg/logger/state/logger.go similarity index 100% rename from backend/pkg/logger/state/logger.go rename to backend_old/pkg/logger/state/logger.go diff --git a/backend/pkg/logger/time.go b/backend_old/pkg/logger/time.go similarity index 100% rename from backend/pkg/logger/time.go rename to backend_old/pkg/logger/time.go diff --git a/backend/pkg/logger/time_test.go b/backend_old/pkg/logger/time_test.go similarity index 100% rename from backend/pkg/logger/time_test.go rename to backend_old/pkg/logger/time_test.go diff --git a/backend/pkg/logger/trace/trace.go b/backend_old/pkg/logger/trace/trace.go similarity index 100% rename from backend/pkg/logger/trace/trace.go rename to backend_old/pkg/logger/trace/trace.go diff --git a/backend/pkg/logger/validator/validator.go b/backend_old/pkg/logger/validator/validator.go similarity index 100% rename from backend/pkg/logger/validator/validator.go rename to backend_old/pkg/logger/validator/validator.go diff --git a/backend/pkg/transport/constructor.go b/backend_old/pkg/transport/constructor.go similarity index 100% rename from backend/pkg/transport/constructor.go rename to backend_old/pkg/transport/constructor.go diff --git a/backend/pkg/transport/errors.go b/backend_old/pkg/transport/errors.go similarity index 100% rename from backend/pkg/transport/errors.go rename to backend_old/pkg/transport/errors.go diff --git a/backend/pkg/transport/keepalive.go b/backend_old/pkg/transport/keepalive.go similarity index 100% rename from backend/pkg/transport/keepalive.go rename to backend_old/pkg/transport/keepalive.go diff --git a/backend/pkg/transport/messages.go b/backend_old/pkg/transport/messages.go similarity index 100% rename from backend/pkg/transport/messages.go rename to backend_old/pkg/transport/messages.go diff --git a/backend/pkg/transport/network/payload.go b/backend_old/pkg/transport/network/payload.go similarity index 100% rename from backend/pkg/transport/network/payload.go rename to backend_old/pkg/transport/network/payload.go diff --git a/backend/pkg/transport/network/sniffer/errors.go b/backend_old/pkg/transport/network/sniffer/errors.go similarity index 100% rename from backend/pkg/transport/network/sniffer/errors.go rename to backend_old/pkg/transport/network/sniffer/errors.go diff --git a/backend/pkg/transport/network/tcp/client.go b/backend_old/pkg/transport/network/tcp/client.go similarity index 100% rename from backend/pkg/transport/network/tcp/client.go rename to backend_old/pkg/transport/network/tcp/client.go diff --git a/backend/pkg/transport/network/tcp/config.go b/backend_old/pkg/transport/network/tcp/config.go similarity index 100% rename from backend/pkg/transport/network/tcp/config.go rename to backend_old/pkg/transport/network/tcp/config.go diff --git a/backend/pkg/transport/network/tcp/conn.go b/backend_old/pkg/transport/network/tcp/conn.go similarity index 100% rename from backend/pkg/transport/network/tcp/conn.go rename to backend_old/pkg/transport/network/tcp/conn.go diff --git a/backend/pkg/transport/network/tcp/server.go b/backend_old/pkg/transport/network/tcp/server.go similarity index 100% rename from backend/pkg/transport/network/tcp/server.go rename to backend_old/pkg/transport/network/tcp/server.go diff --git a/backend/pkg/transport/network/tcp/tcp_test.go b/backend_old/pkg/transport/network/tcp/tcp_test.go similarity index 100% rename from backend/pkg/transport/network/tcp/tcp_test.go rename to backend_old/pkg/transport/network/tcp/tcp_test.go diff --git a/backend/pkg/transport/network/udp/server.go b/backend_old/pkg/transport/network/udp/server.go similarity index 100% rename from backend/pkg/transport/network/udp/server.go rename to backend_old/pkg/transport/network/udp/server.go diff --git a/backend/pkg/transport/notifications.go b/backend_old/pkg/transport/notifications.go similarity index 100% rename from backend/pkg/transport/notifications.go rename to backend_old/pkg/transport/notifications.go diff --git a/backend/pkg/transport/packet/data/codec.go b/backend_old/pkg/transport/packet/data/codec.go similarity index 100% rename from backend/pkg/transport/packet/data/codec.go rename to backend_old/pkg/transport/packet/data/codec.go diff --git a/backend/pkg/transport/packet/data/converter.go b/backend_old/pkg/transport/packet/data/converter.go similarity index 100% rename from backend/pkg/transport/packet/data/converter.go rename to backend_old/pkg/transport/packet/data/converter.go diff --git a/backend/pkg/transport/packet/data/decoder.go b/backend_old/pkg/transport/packet/data/decoder.go similarity index 100% rename from backend/pkg/transport/packet/data/decoder.go rename to backend_old/pkg/transport/packet/data/decoder.go diff --git a/backend/pkg/transport/packet/data/descriptors.go b/backend_old/pkg/transport/packet/data/descriptors.go similarity index 100% rename from backend/pkg/transport/packet/data/descriptors.go rename to backend_old/pkg/transport/packet/data/descriptors.go diff --git a/backend/pkg/transport/packet/data/encoder.go b/backend_old/pkg/transport/packet/data/encoder.go similarity index 100% rename from backend/pkg/transport/packet/data/encoder.go rename to backend_old/pkg/transport/packet/data/encoder.go diff --git a/backend/pkg/transport/packet/data/errors.go b/backend_old/pkg/transport/packet/data/errors.go similarity index 100% rename from backend/pkg/transport/packet/data/errors.go rename to backend_old/pkg/transport/packet/data/errors.go diff --git a/backend/pkg/transport/packet/data/packet.go b/backend_old/pkg/transport/packet/data/packet.go similarity index 100% rename from backend/pkg/transport/packet/data/packet.go rename to backend_old/pkg/transport/packet/data/packet.go diff --git a/backend/pkg/transport/packet/data/value.go b/backend_old/pkg/transport/packet/data/value.go similarity index 100% rename from backend/pkg/transport/packet/data/value.go rename to backend_old/pkg/transport/packet/data/value.go diff --git a/backend/pkg/transport/packet/order/codec.go b/backend_old/pkg/transport/packet/order/codec.go similarity index 100% rename from backend/pkg/transport/packet/order/codec.go rename to backend_old/pkg/transport/packet/order/codec.go diff --git a/backend/pkg/transport/packet/order/decoder.go b/backend_old/pkg/transport/packet/order/decoder.go similarity index 100% rename from backend/pkg/transport/packet/order/decoder.go rename to backend_old/pkg/transport/packet/order/decoder.go diff --git a/backend/pkg/transport/packet/order/errors.go b/backend_old/pkg/transport/packet/order/errors.go similarity index 100% rename from backend/pkg/transport/packet/order/errors.go rename to backend_old/pkg/transport/packet/order/errors.go diff --git a/backend/pkg/transport/packet/order/packet.go b/backend_old/pkg/transport/packet/order/packet.go similarity index 100% rename from backend/pkg/transport/packet/order/packet.go rename to backend_old/pkg/transport/packet/order/packet.go diff --git a/backend/pkg/transport/packet/protection/decoder.go b/backend_old/pkg/transport/packet/protection/decoder.go similarity index 100% rename from backend/pkg/transport/packet/protection/decoder.go rename to backend_old/pkg/transport/packet/protection/decoder.go diff --git a/backend/pkg/transport/packet/protection/errors.go b/backend_old/pkg/transport/packet/protection/errors.go similarity index 100% rename from backend/pkg/transport/packet/protection/errors.go rename to backend_old/pkg/transport/packet/protection/errors.go diff --git a/backend/pkg/transport/packet/protection/kind.go b/backend_old/pkg/transport/packet/protection/kind.go similarity index 100% rename from backend/pkg/transport/packet/protection/kind.go rename to backend_old/pkg/transport/packet/protection/kind.go diff --git a/backend/pkg/transport/packet/protection/packet.go b/backend_old/pkg/transport/packet/protection/packet.go similarity index 100% rename from backend/pkg/transport/packet/protection/packet.go rename to backend_old/pkg/transport/packet/protection/packet.go diff --git a/backend/pkg/transport/packet/state/decoder.go b/backend_old/pkg/transport/packet/state/decoder.go similarity index 100% rename from backend/pkg/transport/packet/state/decoder.go rename to backend_old/pkg/transport/packet/state/decoder.go diff --git a/backend/pkg/transport/packet/state/packet.go b/backend_old/pkg/transport/packet/state/packet.go similarity index 100% rename from backend/pkg/transport/packet/state/packet.go rename to backend_old/pkg/transport/packet/state/packet.go diff --git a/backend/pkg/transport/presentation/decoder.go b/backend_old/pkg/transport/presentation/decoder.go similarity index 100% rename from backend/pkg/transport/presentation/decoder.go rename to backend_old/pkg/transport/presentation/decoder.go diff --git a/backend/pkg/transport/presentation/decoder_test.go b/backend_old/pkg/transport/presentation/decoder_test.go similarity index 100% rename from backend/pkg/transport/presentation/decoder_test.go rename to backend_old/pkg/transport/presentation/decoder_test.go diff --git a/backend/pkg/transport/presentation/encoder.go b/backend_old/pkg/transport/presentation/encoder.go similarity index 100% rename from backend/pkg/transport/presentation/encoder.go rename to backend_old/pkg/transport/presentation/encoder.go diff --git a/backend/pkg/transport/presentation/encoder_test.go b/backend_old/pkg/transport/presentation/encoder_test.go similarity index 100% rename from backend/pkg/transport/presentation/encoder_test.go rename to backend_old/pkg/transport/presentation/encoder_test.go diff --git a/backend/pkg/transport/presentation/errors.go b/backend_old/pkg/transport/presentation/errors.go similarity index 100% rename from backend/pkg/transport/presentation/errors.go rename to backend_old/pkg/transport/presentation/errors.go diff --git a/backend/pkg/transport/session/buffer.go b/backend_old/pkg/transport/session/buffer.go similarity index 100% rename from backend/pkg/transport/session/buffer.go rename to backend_old/pkg/transport/session/buffer.go diff --git a/backend/pkg/transport/transport.go b/backend_old/pkg/transport/transport.go similarity index 100% rename from backend/pkg/transport/transport.go rename to backend_old/pkg/transport/transport.go diff --git a/backend/pkg/vehicle/constructor.go b/backend_old/pkg/vehicle/constructor.go similarity index 100% rename from backend/pkg/vehicle/constructor.go rename to backend_old/pkg/vehicle/constructor.go diff --git a/backend/pkg/vehicle/errors.go b/backend_old/pkg/vehicle/errors.go similarity index 100% rename from backend/pkg/vehicle/errors.go rename to backend_old/pkg/vehicle/errors.go diff --git a/backend/pkg/vehicle/notification.go b/backend_old/pkg/vehicle/notification.go similarity index 100% rename from backend/pkg/vehicle/notification.go rename to backend_old/pkg/vehicle/notification.go diff --git a/backend/pkg/vehicle/vehicle.go b/backend_old/pkg/vehicle/vehicle.go similarity index 100% rename from backend/pkg/vehicle/vehicle.go rename to backend_old/pkg/vehicle/vehicle.go diff --git a/backend/pkg/websocket/client.go b/backend_old/pkg/websocket/client.go similarity index 100% rename from backend/pkg/websocket/client.go rename to backend_old/pkg/websocket/client.go diff --git a/backend/pkg/websocket/errors.go b/backend_old/pkg/websocket/errors.go similarity index 100% rename from backend/pkg/websocket/errors.go rename to backend_old/pkg/websocket/errors.go diff --git a/backend/pkg/websocket/pool.go b/backend_old/pkg/websocket/pool.go similarity index 100% rename from backend/pkg/websocket/pool.go rename to backend_old/pkg/websocket/pool.go diff --git a/backend/pkg/websocket/upgrader.go b/backend_old/pkg/websocket/upgrader.go similarity index 100% rename from backend/pkg/websocket/upgrader.go rename to backend_old/pkg/websocket/upgrader.go diff --git a/backend/scripts/analyze_trace.py b/backend_old/scripts/analyze_trace.py similarity index 100% rename from backend/scripts/analyze_trace.py rename to backend_old/scripts/analyze_trace.py