From 89dcbf57bc2bfd796e08dd0690a3791534d2b4de Mon Sep 17 00:00:00 2001 From: Luke Marks Date: Wed, 11 Mar 2026 18:25:26 -0600 Subject: [PATCH 01/11] Initial commit, stubs and partly done GraphQL Mutations and Queries --- .../payload-services/fram-service/Cargo.toml | 20 +++++++ .../payload-services/fram-service/config.toml | 3 + .../payload-services/fram-service/readme.txt | 12 ++++ .../payload-services/fram-service/src/main.rs | 57 +++++++++++++++++++ .../fram-service/src/schema.rs | 46 +++++++++++++++ 5 files changed, 138 insertions(+) create mode 100644 services/payload-services/fram-service/Cargo.toml create mode 100644 services/payload-services/fram-service/config.toml create mode 100644 services/payload-services/fram-service/readme.txt create mode 100644 services/payload-services/fram-service/src/main.rs create mode 100644 services/payload-services/fram-service/src/schema.rs diff --git a/services/payload-services/fram-service/Cargo.toml b/services/payload-services/fram-service/Cargo.toml new file mode 100644 index 0000000..319e3ae --- /dev/null +++ b/services/payload-services/fram-service/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "fram-service" +edition = "2024" +version.workspace = true +description.workspace = true +documentation.workspace = true +repository.workspace = true +license.workspace = true + +[dependencies] +async-graphql = "7.0.17" +async-graphql-axum = "7.0.17" +kubos-service = { path = "../../../kubos/services/kubos-service" } +rust-i2c = { path = "../../../kubos/hal/rust-hal/rust-i2c"} +tokio = { version = "1.45.1", features = ["full"] } +axum = "0.8.4" +chrono = "0.4.41" +log = "^0.4.0" +embedded-hal = "1.0" +linux-embedded-hal = "0.4" diff --git a/services/payload-services/fram-service/config.toml b/services/payload-services/fram-service/config.toml new file mode 100644 index 0000000..d2abe70 --- /dev/null +++ b/services/payload-services/fram-service/config.toml @@ -0,0 +1,3 @@ +[fram-service.addr] +ip = "127.0.0.1" +port = diff --git a/services/payload-services/fram-service/readme.txt b/services/payload-services/fram-service/readme.txt new file mode 100644 index 0000000..745d65a --- /dev/null +++ b/services/payload-services/fram-service/readme.txt @@ -0,0 +1,12 @@ +Fram Documentation + + + + + + + + + + + diff --git a/services/payload-services/fram-service/src/main.rs b/services/payload-services/fram-service/src/main.rs new file mode 100644 index 0000000..8d6802f --- /dev/null +++ b/services/payload-services/fram-service/src/main.rs @@ -0,0 +1,57 @@ +use rust_i2c::{Command, Connection}; +use linux_embedded_hal::I2cdev; +use embedded_hal::i2c::I2c; + +const I2C_BUS: &str = "/dev/i2c-0"; +const FRAM_ADDR: u8 = 0x50; +const SOLAR_PANEL_MEMORY: [i32; 4] = [0x01, 0x02, 0x03,0x04]; +const ATTENNA_MEMORY: [i32; 4] = [0x05,0x06,0x07,0x08]; + +enum Checks { + SolarPanels, + Attenna, +} + +fn write_fram(bus: &mut I2cdev){ + match bus.write(FRAM_ADDR, &[0x10, 0x01]){ + Ok(_) => println!("ASGJHSGKAJHKSJAHGKJSHGKJA"), + Err(e) => println!("FUCK THIS SHIT"), + }; +} + + +fn read_fram(){ + + println!("GA") + + +} + +fn write_status(arr: &[i32;4]){ + + + +} + +fn read_status(){ + + +} + +fn getaddresses(Part_to_Address: Checks){ + match Part_to_Address { + Checks::SolarPanels => write_status(&SOLAR_PANEL_MEMORY), + + Checks::Attenna => write_status(&ATTENNA_MEMORY), + + } + +} + +fn main(){ + let mut i2c = match I2cdev::new(I2C_BUS){ + Ok(_) => println!("BANG"), + Err(e) => println!("{:?}",e), + }; + +} diff --git a/services/payload-services/fram-service/src/schema.rs b/services/payload-services/fram-service/src/schema.rs new file mode 100644 index 0000000..e579a60 --- /dev/null +++ b/services/payload-services/fram-service/src/schema.rs @@ -0,0 +1,46 @@ +use async_graphql::{Object, SimpleObject, Schema}; + + +struct Query; +struct Mutation; + +#[derive(SimpleObject, Clone)] +struct Data{ + Component: enum + Status: ____ + Confidence: u32 +} + +enum Checks{ + SolarPanels, + antenna, +} + +#[Object] +impl Query{ + + async fn GetStatus(&self, Part: Checks) -> Result{ + let Part_Status = ____ + + Ok(Data { + Component: Checks, + Status: Part_Status + Confidence: ____ + } + + { +} + + +#[Object] +impl Mutation{ + + + async fn SetStatus(&self, Part: Checks){ + + + } + + + + From 20ab08338d19710314c5625d81c290821e093735 Mon Sep 17 00:00:00 2001 From: Luke Marks Date: Sun, 22 Mar 2026 14:39:33 -0600 Subject: [PATCH 02/11] wrote functions that do not work --- .../payload-services/fram-service/src/main.rs | 86 ++++++++++++++----- .../fram-service/src/schema.rs | 21 +++-- 2 files changed, 82 insertions(+), 25 deletions(-) diff --git a/services/payload-services/fram-service/src/main.rs b/services/payload-services/fram-service/src/main.rs index 8d6802f..154a4e7 100644 --- a/services/payload-services/fram-service/src/main.rs +++ b/services/payload-services/fram-service/src/main.rs @@ -2,51 +2,97 @@ use rust_i2c::{Command, Connection}; use linux_embedded_hal::I2cdev; use embedded_hal::i2c::I2c; -const I2C_BUS: &str = "/dev/i2c-0"; +const I2C_BUS: &str = "/dev/i2c-2"; const FRAM_ADDR: u8 = 0x50; -const SOLAR_PANEL_MEMORY: [i32; 4] = [0x01, 0x02, 0x03,0x04]; -const ATTENNA_MEMORY: [i32; 4] = [0x05,0x06,0x07,0x08]; +const SOLAR_PANEL_MEMORY: [u8; 4] = [0x01, 0x02, 0x03,0x04]; +const ATTENNA_MEMORY: [u8; 4] = [0x05,0x06,0x07,0x08]; +const ERROR_MEMORY: [u8; 4] = [0x00,0x00,0x00,0x00]; enum Checks { SolarPanels, Attenna, } -fn write_fram(bus: &mut I2cdev){ - match bus.write(FRAM_ADDR, &[0x10, 0x01]){ - Ok(_) => println!("ASGJHSGKAJHKSJAHGKJSHGKJA"), - Err(e) => println!("FUCK THIS SHIT"), +fn write_fram(bus: &mut I2cdev, address: u8, data: u8){ + match bus.write(FRAM_ADDR, &[address, data]){ + Ok(_) => println!(""), + Err(e) => println!(""), }; } -fn read_fram(){ - - println!("GA") - +fn read_fram(bus: &mut I2cdev, mem_loc: u8) -> u8{ + match bus.read(FRAM_ADDR, &[mem_loc]){ + Ok(_) => return bus.read(FRAM_ADDR, &[mem_loc]), + Err(e) => return 0x00, + }; } -fn write_status(arr: &[i32;4]){ - +fn write_status(arr: [u8;4],status:bool){ + let mut data: u8; + if status{ + data = 0x01 + } + else{ + data = 0x00 + } + + for location in arr{ + write_fram(i2c, location, data) + } } -fn read_status(){ - +fn getaddresses(part_to_address: Checks) -> Result<[u8;4],String>{ + let address: [u8;4]; + match part_to_address { + Checks::SolarPanels => address = SOLAR_PANEL_MEMORY, + + Checks::Attenna => address = ATTENNA_MEMORY, + + _ => return Err("Cannot get address".to_string()), + }; + Ok( + address + ) + } -fn getaddresses(Part_to_Address: Checks){ - match Part_to_Address { - Checks::SolarPanels => write_status(&SOLAR_PANEL_MEMORY), +fn check_sum(arr: [u8; 4]) -> u8{ + let mut equal: u8 = 0; + let mut non_equal: u8 = 0; + let mut first: u8 = read_fram(arr[0]); + let mut data: u8; + + for address in arr{ + + data = read_fram(address); - Checks::Attenna => write_status(&ATTENNA_MEMORY), + + if first == data{ + equal += 1; + } + else { + non_equal += 1; + } } -} + if equal >= non_equal{ + + equal/non_equal + + } + else{ + + non_equal/equal + + } + +} fn main(){ let mut i2c = match I2cdev::new(I2C_BUS){ diff --git a/services/payload-services/fram-service/src/schema.rs b/services/payload-services/fram-service/src/schema.rs index e579a60..d20000f 100644 --- a/services/payload-services/fram-service/src/schema.rs +++ b/services/payload-services/fram-service/src/schema.rs @@ -7,10 +7,16 @@ struct Mutation; #[derive(SimpleObject, Clone)] struct Data{ Component: enum - Status: ____ + Status: bool Confidence: u32 } +#[derive(SimpleObject, clone)] +struct Error{ + Component: enum + failure: str +} + enum Checks{ SolarPanels, antenna, @@ -20,15 +26,20 @@ enum Checks{ impl Query{ async fn GetStatus(&self, Part: Checks) -> Result{ - let Part_Status = ____ + let Part_Address = getaddresses(Checks) Ok(Data { Component: Checks, Status: Part_Status - Confidence: ____ - } + Confidence: + }) + + Err(Error { + Component: Checks + failure: "Couldnt get address of Component" + }) - { + } } From 4a4ae8db9638f748afb087842d4d7696520c7451 Mon Sep 17 00:00:00 2001 From: Luke Marks Date: Sun, 22 Mar 2026 14:46:52 -0600 Subject: [PATCH 03/11] additional files pushed from PCPowerEdge --- Cargo.lock | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 4 ++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 90a9a33..c8552af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1263,6 +1263,20 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dosimeter" +version = "0.1.0" +dependencies = [ + "async-graphql", + "async-graphql-axum", + "axum", + "chrono", + "kubos-service", + "log 0.4.29", + "rust-i2c", + "tokio 1.49.0", +] + [[package]] name = "double" version = "0.2.4" @@ -1631,6 +1645,22 @@ dependencies = [ "percent-encoding 2.3.2", ] +[[package]] +name = "fram-service" +version = "0.1.0" +dependencies = [ + "async-graphql", + "async-graphql-axum", + "axum", + "chrono", + "embedded-hal", + "kubos-service", + "linux-embedded-hal", + "log 0.4.29", + "rust-i2c", + "tokio 1.49.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3942,10 +3972,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f842b1982eb6c2fe34036a4fbfb06dd185a3f5c8edfaacdf7d1ea10b07de6252" dependencies = [ "lock_api 0.3.4", - "parking_lot_core", + "parking_lot_core 0.6.3", "rustc_version 0.2.3", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api 0.4.14", + "parking_lot_core 0.9.12", +] + [[package]] name = "parking_lot_core" version = "0.6.3" @@ -3961,6 +4001,19 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "redox_syscall 0.5.18", + "smallvec 1.15.1", + "windows-link", +] + [[package]] name = "percent-encoding" version = "1.0.1" @@ -4600,6 +4653,15 @@ version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_syscall" version = "0.7.0" @@ -5269,6 +5331,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[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 = "simd-adler32" version = "0.3.8" @@ -5934,7 +6006,9 @@ dependencies = [ "bytes 1.11.0", "libc", "mio 1.1.1", + "parking_lot 0.12.5", "pin-project-lite", + "signal-hook-registry", "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", @@ -6037,7 +6111,7 @@ dependencies = [ "log 0.4.29", "mio 0.6.23", "num_cpus", - "parking_lot", + "parking_lot 0.9.0", "slab", "tokio-executor", "tokio-io", diff --git a/Cargo.toml b/Cargo.toml index b6c9996..6ed7e93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,9 @@ members = [ "kubos/services/isis-ants-service", "kubos/test/integration/linux/isis-ants", "services/payload-services/star-risc", - "services/hardware-services/mram-service", + "services/payload-services/mram-service", + "services/payload-services/dosimeter", + "services/payload-services/fram-service", ] exclude = ["kubos/apis/nosengine-rust", "kubos/test/integration/nosengine-rust"] From 79b9d024c0f664454c30933144c2224f7ef63967 Mon Sep 17 00:00:00 2001 From: Oren Rotaru Date: Sun, 3 May 2026 16:29:18 -0600 Subject: [PATCH 04/11] Move f-ram service to the hardware services folder --- .../fram-service/Cargo.toml | 0 .../fram-service/config.toml | 0 .../fram-service/readme.txt | 0 .../fram-service/src/main.rs | 0 .../fram-service/src/schema.rs | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename services/{payload-services => hardware-services}/fram-service/Cargo.toml (100%) rename services/{payload-services => hardware-services}/fram-service/config.toml (100%) rename services/{payload-services => hardware-services}/fram-service/readme.txt (100%) rename services/{payload-services => hardware-services}/fram-service/src/main.rs (100%) rename services/{payload-services => hardware-services}/fram-service/src/schema.rs (100%) diff --git a/services/payload-services/fram-service/Cargo.toml b/services/hardware-services/fram-service/Cargo.toml similarity index 100% rename from services/payload-services/fram-service/Cargo.toml rename to services/hardware-services/fram-service/Cargo.toml diff --git a/services/payload-services/fram-service/config.toml b/services/hardware-services/fram-service/config.toml similarity index 100% rename from services/payload-services/fram-service/config.toml rename to services/hardware-services/fram-service/config.toml diff --git a/services/payload-services/fram-service/readme.txt b/services/hardware-services/fram-service/readme.txt similarity index 100% rename from services/payload-services/fram-service/readme.txt rename to services/hardware-services/fram-service/readme.txt diff --git a/services/payload-services/fram-service/src/main.rs b/services/hardware-services/fram-service/src/main.rs similarity index 100% rename from services/payload-services/fram-service/src/main.rs rename to services/hardware-services/fram-service/src/main.rs diff --git a/services/payload-services/fram-service/src/schema.rs b/services/hardware-services/fram-service/src/schema.rs similarity index 100% rename from services/payload-services/fram-service/src/schema.rs rename to services/hardware-services/fram-service/src/schema.rs From d3e1cb1447ed1fb6ca78d7e4c16ec6384d0c95ed Mon Sep 17 00:00:00 2001 From: Oren Rotaru <64712576+OrenRotaru@users.noreply.github.com> Date: Wed, 6 May 2026 13:06:20 -0600 Subject: [PATCH 05/11] Write F-RAM service and tests --- Cargo.lock | 9 +- Cargo.toml | 5 +- .../hardware-services/fram-service/Cargo.toml | 29 +- .../hardware-services/fram-service/README.md | 70 +++ .../fram-service/config.toml | 19 +- .../fram-service/obc-tests/README.md | 147 +++++ .../fram-service/obc-tests/build.sh | 24 + .../obc-tests/config/fram-hw.toml | 14 + .../fram-service/obc-tests/package.sh | 35 ++ .../obc-tests/requests/00_ping.json | 1 + .../obc-tests/requests/01_health.json | 1 + .../obc-tests/requests/02_mission_state.json | 1 + .../requests/20_reconcile_dry_run.json | 1 + .../fram-service/obc-tests/run.sh | 103 ++++ .../fram-service/obc-tests/src/main.rs | 539 ++++++++++++++++++ .../hardware-services/fram-service/readme.txt | 12 - .../fram-service/src/backend.rs | 246 ++++++++ .../hardware-services/fram-service/src/env.rs | 97 ++++ .../fram-service/src/layout.rs | 272 +++++++++ .../hardware-services/fram-service/src/lib.rs | 7 + .../fram-service/src/main.rs | 114 +--- .../fram-service/src/model.rs | 270 +++++++++ .../fram-service/src/reconcile.rs | 177 ++++++ .../fram-service/src/schema.rs | 169 ++++-- .../fram-service/src/subsystem.rs | 366 ++++++++++++ .../fram-service/tests/graphql.rs | 156 +++++ .../fram-service/tests/reconcile.rs | 89 +++ .../fram-service/tests/storage.rs | 90 +++ 28 files changed, 2907 insertions(+), 156 deletions(-) create mode 100644 services/hardware-services/fram-service/README.md create mode 100644 services/hardware-services/fram-service/obc-tests/README.md create mode 100755 services/hardware-services/fram-service/obc-tests/build.sh create mode 100644 services/hardware-services/fram-service/obc-tests/config/fram-hw.toml create mode 100755 services/hardware-services/fram-service/obc-tests/package.sh create mode 100644 services/hardware-services/fram-service/obc-tests/requests/00_ping.json create mode 100644 services/hardware-services/fram-service/obc-tests/requests/01_health.json create mode 100644 services/hardware-services/fram-service/obc-tests/requests/02_mission_state.json create mode 100644 services/hardware-services/fram-service/obc-tests/requests/20_reconcile_dry_run.json create mode 100755 services/hardware-services/fram-service/obc-tests/run.sh create mode 100644 services/hardware-services/fram-service/obc-tests/src/main.rs delete mode 100644 services/hardware-services/fram-service/readme.txt create mode 100644 services/hardware-services/fram-service/src/backend.rs create mode 100644 services/hardware-services/fram-service/src/env.rs create mode 100644 services/hardware-services/fram-service/src/layout.rs create mode 100644 services/hardware-services/fram-service/src/lib.rs create mode 100644 services/hardware-services/fram-service/src/model.rs create mode 100644 services/hardware-services/fram-service/src/reconcile.rs create mode 100644 services/hardware-services/fram-service/src/subsystem.rs create mode 100644 services/hardware-services/fram-service/tests/graphql.rs create mode 100644 services/hardware-services/fram-service/tests/reconcile.rs create mode 100644 services/hardware-services/fram-service/tests/storage.rs diff --git a/Cargo.lock b/Cargo.lock index c8552af..f35672a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1651,14 +1651,15 @@ version = "0.1.0" dependencies = [ "async-graphql", "async-graphql-axum", - "axum", - "chrono", + "crc32fast", "embedded-hal", + "futures 0.3.31", "kubos-service", "linux-embedded-hal", "log 0.4.29", - "rust-i2c", - "tokio 1.49.0", + "serde_json", + "tempfile", + "thiserror 2.0.18", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6ed7e93..2b79464 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,9 +61,9 @@ members = [ "kubos/services/isis-ants-service", "kubos/test/integration/linux/isis-ants", "services/payload-services/star-risc", - "services/payload-services/mram-service", "services/payload-services/dosimeter", - "services/payload-services/fram-service", + "services/hardware-services/mram-service", + "services/hardware-services/fram-service", ] exclude = ["kubos/apis/nosengine-rust", "kubos/test/integration/nosengine-rust"] @@ -118,6 +118,7 @@ default-members = [ "kubos/utils", "services/simulator-services/clyde-3g-eps-simulator", "services/hardware-services/mram-service", + "services/hardware-services/fram-service", ] [workspace.package] diff --git a/services/hardware-services/fram-service/Cargo.toml b/services/hardware-services/fram-service/Cargo.toml index 319e3ae..d3311cf 100644 --- a/services/hardware-services/fram-service/Cargo.toml +++ b/services/hardware-services/fram-service/Cargo.toml @@ -7,14 +7,29 @@ documentation.workspace = true repository.workspace = true license.workspace = true +[features] +default = [] +i2c = ["dep:embedded-hal", "dep:linux-embedded-hal"] +obc-tests = [] + +[[bin]] +name = "fram-obc-tests" +path = "obc-tests/src/main.rs" +required-features = ["i2c", "obc-tests"] + [dependencies] async-graphql = "7.0.17" async-graphql-axum = "7.0.17" -kubos-service = { path = "../../../kubos/services/kubos-service" } -rust-i2c = { path = "../../../kubos/hal/rust-hal/rust-i2c"} -tokio = { version = "1.45.1", features = ["full"] } -axum = "0.8.4" -chrono = "0.4.41" +crc32fast = "1.5" log = "^0.4.0" -embedded-hal = "1.0" -linux-embedded-hal = "0.4" +thiserror = "2.0" + +kubos-service = { path = "../../../kubos/services/kubos-service" } + +embedded-hal = { version = "1.0", optional = true } +linux-embedded-hal = { version = "0.4", optional = true } + +[dev-dependencies] +futures = "0.3" +serde_json = "1.0" +tempfile = "3" diff --git a/services/hardware-services/fram-service/README.md b/services/hardware-services/fram-service/README.md new file mode 100644 index 0000000..1f162fb --- /dev/null +++ b/services/hardware-services/fram-service/README.md @@ -0,0 +1,70 @@ +# FRAM Service + +`fram-service` owns access to the 64-Kbit I2C F-RAM used for mission-critical +persistent state. It stores typed flags in a fixed-slot redundant layout and +mirrors selected values to U-Boot environment variables through `fw_printenv` +and `fw_setenv`. + +The service uses a file backend by default so the GraphQL API, record CRCs, and +reconciliation behavior can be tested without hardware: + +```toml +[fram-service] +backend = "file" +image_path = "/tmp/fram-service.img" +image_capacity_bytes = 8192 +``` + +Flight hardware should use the I2C FRAM backend: + +```toml +[fram-service] +backend = "i2c" +i2c_bus = "/dev/i2c-2" +i2c_addr = "0x50" +capacity_bytes = 8192 +address_width_bytes = 2 +max_transfer_bytes = 32 +``` + +`i2c_addr` is the Linux 7-bit device address, not the 8-bit address byte shown +in the datasheet bus diagrams. For FM24CL64B, use `0x50` when A2/A1/A0 are all +low. Valid values are `0x50..0x57`; do not configure `0xA0` or `0xA1`. +If `i2cdetect` shows more than one responder in that range, confirm which one is +the FRAM before enabling writes. + +On the current board schematic, the FRAM address pins are tied low, so the FRAM +is `0x50`. The RV-3028 RTC responds at `0x52`. + +The FRAM is byte-addressable and has NoDelay writes. The backend does not add +EEPROM write-cycle polling or delays, but it still reports I2C errors so callers +can handle an OPD-off power domain. I2C bus speed is configured by the Linux +I2C controller/device tree; the FRAM part supports up to 1 MHz. + +Mission applications should query this service rather than opening the I2C +device directly. The intended boot flow is: + +1. Set system time from the RTC. +2. Call `reconcileMissionState(dryRun: false)`. +3. Read `missionState`. +4. Run deployment logic from the reconciled state. +5. Set completion flags through `setMissionFlag`. + +## OBC hardware tests + +Hardware-only tests live under `obc-tests/` so normal host tests never require a +real FRAM chip. To build, package, transfer, and run them: + +```sh +services/hardware-services/fram-service/obc-tests/package.sh +transfer -d /home/kubos/fram-tests target/obc-tests/fram-service +``` + +Then on the OBC: + +```sh +./run.sh +``` + +See `obc-tests/README.md` for the full cross-build commands, transfer flow, and +hardware test options. diff --git a/services/hardware-services/fram-service/config.toml b/services/hardware-services/fram-service/config.toml index d2abe70..21667e7 100644 --- a/services/hardware-services/fram-service/config.toml +++ b/services/hardware-services/fram-service/config.toml @@ -1,3 +1,20 @@ +[fram-service] +backend = "file" +image_path = "/tmp/fram-service.img" +image_capacity_bytes = 8192 + +fw_printenv = "/usr/sbin/fw_printenv" +fw_setenv = "/usr/sbin/fw_setenv" + +# Enable hardware mode by building with: cargo build -p fram-service --features i2c +# backend = "i2c" +# i2c_bus = "/dev/i2c-2" +# i2c_addr = "0x50" +# capacity_bytes = 8192 +# address_width_bytes = 2 +# max_transfer_bytes = 32 +# I2C bus speed is configured by Linux/device-tree. The FRAM supports up to 1 MHz. + [fram-service.addr] ip = "127.0.0.1" -port = +port = 8091 diff --git a/services/hardware-services/fram-service/obc-tests/README.md b/services/hardware-services/fram-service/obc-tests/README.md new file mode 100644 index 0000000..c2757a2 --- /dev/null +++ b/services/hardware-services/fram-service/obc-tests/README.md @@ -0,0 +1,147 @@ +# FRAM OBC Tests + +This folder contains tests that are meant to run on the OBC with the real +64-Kbit I2C FRAM connected. These are intentionally separate from the normal +Rust `tests/` directory so `cargo test -p fram-service` remains host-safe. + +## Layout + +- `config/fram-hw.toml` - hardware config for the service. +- `requests/*.json` - simple GraphQL request fixtures for manual inspection. +- `src/main.rs` - compiled OBC test binary, `fram-obc-tests`. +- `build.sh` - shorthand cross-build command for the service and test binary. +- `package.sh` - builds and stages a transfer-ready test bundle. +- `run.sh` - runs the service, GraphQL smoke requests, and compiled tests on the OBC. + +The compiled test binary does two things by default: + +1. Writes a 32-byte test pattern to FRAM scratch offset `4096`, reads it back, + and restores the original bytes. +2. Calls the FRAM GraphQL service for `ping`, `health`, and `missionState`. + +Offset `4096` is outside the service's mission-state record area. The mission +record layout currently reserves bytes `0..2048`. + +## Build + +From the repository root: + +```sh +services/hardware-services/fram-service/obc-tests/build.sh +``` + +Equivalent explicit commands: + +```sh +cross build \ + --target armv7-unknown-linux-gnueabihf \ + -p fram-service \ + --release \ + --features i2c + +cross build \ + --target armv7-unknown-linux-gnueabihf \ + -p fram-service \ + --release \ + --features i2c,obc-tests \ + --bin fram-obc-tests +``` + +## Package and Transfer + +Create a transfer-ready bundle: + +```sh +services/hardware-services/fram-service/obc-tests/package.sh +``` + +The bundle is written to: + +```text +target/obc-tests/fram-service +``` + +Transfer it to the OBC using the repo transfer helper: + +```sh +transfer -d /home/kubos/fram-tests target/obc-tests/fram-service +``` + +The transfer helper unpacks directories under the destination, so the OBC path +will be: + +```text +/home/kubos/fram-tests/fram-service +``` + +## Run on the OBC + +On the OBC: + +```sh +cd /home/kubos/fram-tests/fram-service +./run.sh +``` + +`run.sh` starts `bin/fram-service` with `config/fram-hw.toml`, waits for the +GraphQL endpoint, runs the request fixtures, then runs `bin/fram-obc-tests`. + +If the service is already running: + +```sh +START_SERVICE=0 URL=http://127.0.0.1:8091/graphql ./run.sh +``` + +To use a different I2C bus or address: + +```sh +I2C_BUS=/dev/i2c-2 I2C_ADDR=0x50 ./run.sh +``` + +`I2C_ADDR` is the Linux 7-bit address. For FM24CL64B that is `0x50..0x57` +depending on A2/A1/A0. Do not pass the datasheet's 8-bit address byte +`0xA0`/`0xA1`. + +To read-probe the FM24CL64B address range without starting the service or +writing scratch bytes: + +```sh +FRAM_TEST_SCAN_ONLY=1 ./run.sh +``` + +If the scan reports more than one candidate, identify the FRAM from the +schematic or by using a known scratch-write-safe address before changing +`config/fram-hw.toml`. + +On the current board schematic, FM24CL64B A0/A1/A2 are tied low, so the FRAM is +`0x50`. The RV-3028 RTC responds at `0x52`; do not run the scratch-write test +against `0x52`. + +## Mission Flag Write Test + +By default, the compiled test avoids writing mission-state keys. It only writes +to the scratch offset. + +To also test the GraphQL mission write path, enable the guarded write/restore +test: + +```sh +FRAM_TEST_MISSION_WRITE=1 ./run.sh +``` + +That test toggles `detumbling_complete` with `mirrorToEnv: false`, verifies the +readback, and restores the original value. Do not enable it during a real flight +configuration unless you intend to touch that mission flag. + +## Useful Overrides + +```sh +URL=http://127.0.0.1:8091/graphql +SERVICE_BIN=/path/to/fram-service +TEST_BIN=/path/to/fram-obc-tests +CONFIG=/path/to/fram-hw.toml +SCRATCH_OFFSET=4096 +FRAM_TEST_MISSION_WRITE=1 +FRAM_TEST_SCAN_ONLY=1 +START_SERVICE=0 +``` diff --git a/services/hardware-services/fram-service/obc-tests/build.sh b/services/hardware-services/fram-service/obc-tests/build.sh new file mode 100755 index 0000000..5e56207 --- /dev/null +++ b/services/hardware-services/fram-service/obc-tests/build.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$DIR/../../../.." && pwd) +TARGET="${TARGET:-armv7-unknown-linux-gnueabihf}" +CROSS="${CROSS:-cross}" + +cd "$ROOT" + +echo "Building fram-service for $TARGET" +"$CROSS" build \ + --target "$TARGET" \ + -p fram-service \ + --release \ + --features i2c + +echo "Building fram-obc-tests for $TARGET" +"$CROSS" build \ + --target "$TARGET" \ + -p fram-service \ + --release \ + --features i2c,obc-tests \ + --bin fram-obc-tests diff --git a/services/hardware-services/fram-service/obc-tests/config/fram-hw.toml b/services/hardware-services/fram-service/obc-tests/config/fram-hw.toml new file mode 100644 index 0000000..b94558e --- /dev/null +++ b/services/hardware-services/fram-service/obc-tests/config/fram-hw.toml @@ -0,0 +1,14 @@ +[fram-service] +backend = "i2c" +i2c_bus = "/dev/i2c-2" +i2c_addr = "0x50" +capacity_bytes = 8192 +address_width_bytes = 2 +max_transfer_bytes = 32 + +fw_printenv = "/usr/sbin/fw_printenv" +fw_setenv = "/usr/sbin/fw_setenv" + +[fram-service.addr] +ip = "127.0.0.1" +port = 8091 diff --git a/services/hardware-services/fram-service/obc-tests/package.sh b/services/hardware-services/fram-service/obc-tests/package.sh new file mode 100755 index 0000000..3f2b304 --- /dev/null +++ b/services/hardware-services/fram-service/obc-tests/package.sh @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu + +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$DIR/../../../.." && pwd) +TARGET="${TARGET:-armv7-unknown-linux-gnueabihf}" +OUT="${OUT:-$ROOT/target/obc-tests/fram-service}" + +case "$OUT" in + "$ROOT"/target/obc-tests/*) ;; + *) + echo "Refusing to package outside $ROOT/target/obc-tests: $OUT" >&2 + exit 2 + ;; +esac + +if [ "${SKIP_BUILD:-0}" != "1" ]; then + "$DIR/build.sh" +fi + +rm -rf "$OUT" +mkdir -p "$OUT/bin" "$OUT/config" "$OUT/requests" + +cp "$ROOT/target/$TARGET/release/fram-service" "$OUT/bin/" +cp "$ROOT/target/$TARGET/release/fram-obc-tests" "$OUT/bin/" +cp "$DIR/config/fram-hw.toml" "$OUT/config/" +cp "$DIR"/requests/*.json "$OUT/requests/" +cp "$DIR/run.sh" "$OUT/" +cp "$DIR/README.md" "$OUT/" + +chmod +x "$OUT/run.sh" "$OUT/bin/fram-service" "$OUT/bin/fram-obc-tests" + +echo "Packaged OBC FRAM tests at $OUT" +echo "Transfer with:" +echo " transfer -d /home/kubos/fram-tests $OUT" diff --git a/services/hardware-services/fram-service/obc-tests/requests/00_ping.json b/services/hardware-services/fram-service/obc-tests/requests/00_ping.json new file mode 100644 index 0000000..4e33804 --- /dev/null +++ b/services/hardware-services/fram-service/obc-tests/requests/00_ping.json @@ -0,0 +1 @@ +{"query":"{ ping }"} diff --git a/services/hardware-services/fram-service/obc-tests/requests/01_health.json b/services/hardware-services/fram-service/obc-tests/requests/01_health.json new file mode 100644 index 0000000..0e5a642 --- /dev/null +++ b/services/hardware-services/fram-service/obc-tests/requests/01_health.json @@ -0,0 +1 @@ +{"query":"{ health { backend capacityBytes framReachable lastError } }"} diff --git a/services/hardware-services/fram-service/obc-tests/requests/02_mission_state.json b/services/hardware-services/fram-service/obc-tests/requests/02_mission_state.json new file mode 100644 index 0000000..07461a8 --- /dev/null +++ b/services/hardware-services/fram-service/obc-tests/requests/02_mission_state.json @@ -0,0 +1 @@ +{"query":"{ missionState { removeBeforeFlight deployed deployStart solarPanelDeployed uhfAntennaDeployed vhfAntennaDeployed initialSafeStateComplete detumblingComplete } }"} diff --git a/services/hardware-services/fram-service/obc-tests/requests/20_reconcile_dry_run.json b/services/hardware-services/fram-service/obc-tests/requests/20_reconcile_dry_run.json new file mode 100644 index 0000000..8cefdae --- /dev/null +++ b/services/hardware-services/fram-service/obc-tests/requests/20_reconcile_dry_run.json @@ -0,0 +1 @@ +{"query":"mutation { reconcileMissionState(dryRun: true) { success errors actions { key framValue envValue mergedValue action errors } state { removeBeforeFlight deployed deployStart solarPanelDeployed uhfAntennaDeployed vhfAntennaDeployed initialSafeStateComplete detumblingComplete } } }"} diff --git a/services/hardware-services/fram-service/obc-tests/run.sh b/services/hardware-services/fram-service/obc-tests/run.sh new file mode 100755 index 0000000..91042c8 --- /dev/null +++ b/services/hardware-services/fram-service/obc-tests/run.sh @@ -0,0 +1,103 @@ +#!/bin/sh +set -eu + +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +URL="${URL:-http://127.0.0.1:8091/graphql}" +SERVICE_BIN="${SERVICE_BIN:-$DIR/bin/fram-service}" +TEST_BIN="${TEST_BIN:-$DIR/bin/fram-obc-tests}" +CONFIG="${CONFIG:-$DIR/config/fram-hw.toml}" +LOG="${LOG:-$DIR/fram-service.log}" +START_SERVICE="${START_SERVICE:-1}" +I2C_BUS="${I2C_BUS:-/dev/i2c-2}" +I2C_ADDR="${I2C_ADDR:-0x50}" +SCRATCH_OFFSET="${SCRATCH_OFFSET:-4096}" +SCAN_ONLY="${FRAM_TEST_SCAN_ONLY:-0}" +MISSION_WRITE="${FRAM_TEST_MISSION_WRITE:-0}" + +SERVICE_PID="" + +cleanup() { + if [ -n "$SERVICE_PID" ] && kill -0 "$SERVICE_PID" 2>/dev/null; then + kill "$SERVICE_PID" 2>/dev/null || true + wait "$SERVICE_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +if [ "$SCAN_ONLY" = "1" ]; then + if [ ! -x "$TEST_BIN" ]; then + echo "test binary not executable: $TEST_BIN" >&2 + exit 2 + fi + + "$TEST_BIN" \ + --scan-only \ + --i2c-bus "$I2C_BUS" \ + --i2c-addr "$I2C_ADDR" \ + --scratch-offset "$SCRATCH_OFFSET" + exit 0 +fi + +post_request() { + name="$1" + file="$2" + printf '\n=== %s ===\n' "$name" + curl -fsS "$URL" \ + -H 'content-type: application/json' \ + --data @"$file" + printf '\n' +} + +wait_for_service() { + i=0 + while [ "$i" -lt 30 ]; do + if curl -fsS "$URL" \ + -H 'content-type: application/json' \ + --data @"$DIR/requests/00_ping.json" >/dev/null 2>&1; then + return 0 + fi + i=$((i + 1)) + sleep 1 + done + + echo "FRAM service did not become ready at $URL" >&2 + if [ -f "$LOG" ]; then + echo "--- service log ---" >&2 + tail -n 80 "$LOG" >&2 || true + fi + return 1 +} + +if [ "$START_SERVICE" = "1" ]; then + if [ ! -x "$SERVICE_BIN" ]; then + echo "service binary not executable: $SERVICE_BIN" >&2 + exit 2 + fi + + echo "Starting FRAM service" + "$SERVICE_BIN" -c "$CONFIG" >"$LOG" 2>&1 & + SERVICE_PID="$!" + wait_for_service +else + echo "Using already-running FRAM service at $URL" +fi + +post_request "Ping" "$DIR/requests/00_ping.json" +post_request "Health" "$DIR/requests/01_health.json" +post_request "Mission state read" "$DIR/requests/02_mission_state.json" +post_request "Reconcile dry run" "$DIR/requests/20_reconcile_dry_run.json" + +if [ ! -x "$TEST_BIN" ]; then + echo "test binary not executable: $TEST_BIN" >&2 + exit 2 +fi + +TEST_ARGS="--url $URL --i2c-bus $I2C_BUS --i2c-addr $I2C_ADDR --scratch-offset $SCRATCH_OFFSET" +if [ "$MISSION_WRITE" = "1" ]; then + TEST_ARGS="$TEST_ARGS --mission-write" +else + echo "Mission flag write/restore is disabled. Set FRAM_TEST_MISSION_WRITE=1 to enable it." +fi + +# shellcheck disable=SC2086 +"$TEST_BIN" $TEST_ARGS diff --git a/services/hardware-services/fram-service/obc-tests/src/main.rs b/services/hardware-services/fram-service/obc-tests/src/main.rs new file mode 100644 index 0000000..797915f --- /dev/null +++ b/services/hardware-services/fram-service/obc-tests/src/main.rs @@ -0,0 +1,539 @@ +use std::env; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::process; +use std::time::Duration; + +use fram_service::backend::{ByteStorage, I2cFramBackend}; + +const DEFAULT_URL: &str = "http://127.0.0.1:8091/graphql"; +const DEFAULT_BUS: &str = "/dev/i2c-2"; +const DEFAULT_ADDR: u8 = 0x50; +const DEFAULT_CAPACITY: u32 = 8192; +const DEFAULT_ADDRESS_WIDTH: u8 = 2; +const DEFAULT_MAX_TRANSFER: usize = 32; +const DEFAULT_SCRATCH_OFFSET: u32 = 4096; +const SCRATCH_LEN: usize = 32; + +#[derive(Debug)] +struct Options { + url: String, + i2c_bus: String, + i2c_addr: u8, + capacity: u32, + address_width: u8, + max_transfer: usize, + scratch_offset: u32, + scan_only: bool, + skip_graphql: bool, + skip_direct: bool, + mission_write: bool, +} + +fn main() { + let options = match Options::parse() { + Ok(options) => options, + Err(err) => { + eprintln!("{err}"); + process::exit(2); + } + }; + + let mut failures = Vec::new(); + + if options.scan_only { + run( + "scan FM24CL64B address range", + || scan_fram_candidates(&options), + &mut failures, + ); + finish(failures); + return; + } + + if !options.skip_direct { + run( + "direct FRAM scratch roundtrip", + || direct_fram_test(&options), + &mut failures, + ); + } + + if !options.skip_graphql { + run("GraphQL ping", || graphql_ping(&options.url), &mut failures); + run( + "GraphQL health", + || graphql_health(&options.url), + &mut failures, + ); + run( + "GraphQL mission state read", + || graphql_mission_state(&options.url), + &mut failures, + ); + + if options.mission_write { + run( + "GraphQL mission flag write/restore", + || graphql_mission_write_restore(&options.url), + &mut failures, + ); + } + } + + finish(failures); +} + +fn finish(failures: Vec) { + if failures.is_empty() { + println!("PASS fram-obc-tests"); + } else { + eprintln!("FAIL fram-obc-tests"); + for failure in failures { + eprintln!("- {failure}"); + } + process::exit(1); + } +} + +fn run(name: &str, test: F, failures: &mut Vec) +where + F: FnOnce() -> Result<(), String>, +{ + print!("{name} ... "); + if let Err(err) = std::io::stdout().flush() { + failures.push(format!("{name}: failed to flush stdout: {err}")); + return; + } + + match test() { + Ok(()) => println!("ok"), + Err(err) => { + println!("FAILED"); + failures.push(format!("{name}: {err}")); + } + } +} + +impl Options { + fn parse() -> Result { + let mut options = Self { + url: env::var("FRAM_TEST_URL").unwrap_or_else(|_| DEFAULT_URL.to_string()), + i2c_bus: env::var("FRAM_TEST_I2C_BUS").unwrap_or_else(|_| DEFAULT_BUS.to_string()), + i2c_addr: env::var("FRAM_TEST_I2C_ADDR") + .ok() + .map(|value| parse_u8(&value)) + .transpose()? + .unwrap_or(DEFAULT_ADDR), + capacity: env::var("FRAM_TEST_CAPACITY") + .ok() + .map(|value| parse_u32(&value)) + .transpose()? + .unwrap_or(DEFAULT_CAPACITY), + address_width: env::var("FRAM_TEST_ADDRESS_WIDTH") + .ok() + .map(|value| parse_u8(&value)) + .transpose()? + .unwrap_or(DEFAULT_ADDRESS_WIDTH), + max_transfer: env::var("FRAM_TEST_MAX_TRANSFER") + .ok() + .map(|value| { + value + .parse::() + .map_err(|err| format!("invalid FRAM_TEST_MAX_TRANSFER: {err}")) + }) + .transpose()? + .unwrap_or(DEFAULT_MAX_TRANSFER), + scratch_offset: env::var("FRAM_TEST_SCRATCH_OFFSET") + .ok() + .map(|value| parse_u32(&value)) + .transpose()? + .unwrap_or(DEFAULT_SCRATCH_OFFSET), + scan_only: env::var("FRAM_TEST_SCAN_ONLY").ok().as_deref() == Some("1"), + skip_graphql: env::var("FRAM_TEST_SKIP_GRAPHQL").ok().as_deref() == Some("1"), + skip_direct: env::var("FRAM_TEST_SKIP_DIRECT").ok().as_deref() == Some("1"), + mission_write: env::var("FRAM_TEST_MISSION_WRITE").ok().as_deref() == Some("1"), + }; + + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--url" => options.url = require_arg(&mut args, "--url")?, + "--i2c-bus" => options.i2c_bus = require_arg(&mut args, "--i2c-bus")?, + "--i2c-addr" => { + options.i2c_addr = parse_u8(&require_arg(&mut args, "--i2c-addr")?)? + } + "--capacity" => { + options.capacity = parse_u32(&require_arg(&mut args, "--capacity")?)? + } + "--address-width" => { + options.address_width = parse_u8(&require_arg(&mut args, "--address-width")?)? + } + "--max-transfer" => { + options.max_transfer = require_arg(&mut args, "--max-transfer")? + .parse::() + .map_err(|err| format!("invalid --max-transfer: {err}"))? + } + "--scratch-offset" => { + options.scratch_offset = + parse_u32(&require_arg(&mut args, "--scratch-offset")?)? + } + "--scan-only" => options.scan_only = true, + "--skip-graphql" => options.skip_graphql = true, + "--skip-direct" => options.skip_direct = true, + "--mission-write" => options.mission_write = true, + "--help" | "-h" => { + print_usage(); + process::exit(0); + } + other => return Err(format!("unknown argument '{other}'")), + } + } + + if options.scratch_offset < 2048 { + return Err( + "scratch offset must be >= 2048 to stay out of mission record slots".to_string(), + ); + } + + let scratch_len = u32::try_from(SCRATCH_LEN).map_err(|err| err.to_string())?; + if options + .scratch_offset + .checked_add(scratch_len) + .filter(|end| *end <= options.capacity) + .is_none() + { + return Err(format!( + "scratch offset {} plus {} bytes exceeds capacity {}", + options.scratch_offset, SCRATCH_LEN, options.capacity + )); + } + + Ok(options) + } +} + +fn print_usage() { + println!( + "fram-obc-tests [options]\n\ + \n\ + Options:\n\ + --url URL GraphQL URL (default {DEFAULT_URL})\n\ + --i2c-bus PATH I2C bus path (default {DEFAULT_BUS})\n\ + --i2c-addr ADDR FRAM I2C address, decimal or 0xNN (default 0x50)\n\ + --capacity BYTES FRAM capacity (default 8192)\n\ + --address-width BYTES FRAM address width (default 2)\n\ + --max-transfer BYTES I2C chunk size (default 32)\n\ + --scratch-offset OFFSET Direct test scratch offset (default 4096)\n\ + --scan-only Read-probe 0x50..0x57 and exit without writes\n\ + --skip-graphql Do not call the GraphQL service\n\ + --skip-direct Do not run direct I2C scratch test\n\ + --mission-write Toggle and restore detumbling_complete through GraphQL\n" + ); +} + +fn require_arg(args: &mut impl Iterator, name: &str) -> Result { + args.next() + .ok_or_else(|| format!("{name} requires a value")) +} + +fn parse_u8(value: &str) -> Result { + parse_u32(value).and_then(|value| { + u8::try_from(value).map_err(|_| format!("value '{value}' does not fit in u8")) + }) +} + +fn parse_u32(value: &str) -> Result { + let trimmed = value.trim(); + if let Some(hex) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + u32::from_str_radix(hex, 16).map_err(|err| format!("invalid hex value '{value}': {err}")) + } else { + trimmed + .parse::() + .map_err(|err| format!("invalid integer value '{value}': {err}")) + } +} + +fn direct_fram_test(options: &Options) -> Result<(), String> { + let mut backend = I2cFramBackend::new( + &options.i2c_bus, + options.i2c_addr, + options.capacity, + options.address_width, + options.max_transfer, + ) + .map_err(|err| err.to_string())?; + + let mut original = [0u8; SCRATCH_LEN]; + backend + .read(options.scratch_offset, &mut original) + .map_err(|err| format!("scratch read failed: {err}"))?; + + let mut pattern = [0u8; SCRATCH_LEN]; + let label = b"FRAM-OBC-TEST-v1"; + pattern[..label.len()].copy_from_slice(label); + pattern[label.len()..label.len() + 4].copy_from_slice(&process::id().to_le_bytes()); + pattern[SCRATCH_LEN - 4..].copy_from_slice(&0xF00D_CAFE_u32.to_le_bytes()); + + backend + .write(options.scratch_offset, &pattern) + .map_err(|err| format!("scratch write failed: {err}"))?; + + let mut readback = [0u8; SCRATCH_LEN]; + backend + .read(options.scratch_offset, &mut readback) + .map_err(|err| format!("scratch readback failed: {err}"))?; + + let mut restore_error = None; + if let Err(err) = backend.write(options.scratch_offset, &original) { + restore_error = Some(format!("failed to restore original scratch bytes: {err}")); + } + + if readback != pattern { + return Err("scratch readback did not match written pattern".to_string()); + } + + if let Some(err) = restore_error { + return Err(err); + } + + let mut restored = [0u8; SCRATCH_LEN]; + backend + .read(options.scratch_offset, &mut restored) + .map_err(|err| format!("scratch restore readback failed: {err}"))?; + if restored != original { + return Err("scratch restore verification failed".to_string()); + } + + Ok(()) +} + +fn scan_fram_candidates(options: &Options) -> Result<(), String> { + use embedded_hal::i2c::I2c; + + let mut found = Vec::new(); + let mut i2c = + linux_embedded_hal::I2cdev::new(&options.i2c_bus).map_err(|err| err.to_string())?; + + for addr in 0x50..=0x57 { + let mut byte = [0u8; 1]; + match i2c.read(addr, &mut byte) { + Ok(()) => { + println!("0x{addr:02X}: ACK, current-read byte=0x{:02X}", byte[0]); + found.push(addr); + } + Err(err) => println!("0x{addr:02X}: no read response ({err})"), + } + } + + if found.is_empty() { + Err(format!( + "no FM24CL64B-style responders found on {}", + options.i2c_bus + )) + } else { + println!("Candidates: {}", format_addr_list(&found)); + Ok(()) + } +} + +fn format_addr_list(addrs: &[u8]) -> String { + addrs + .iter() + .map(|addr| format!("0x{addr:02X}")) + .collect::>() + .join(", ") +} + +fn graphql_ping(url: &str) -> Result<(), String> { + let body = graphql(url, "{ ping }")?; + expect_contains(&body, "\"ping\":\"pong\"") +} + +fn graphql_health(url: &str) -> Result<(), String> { + let body = graphql( + url, + "{ health { backend capacityBytes framReachable lastError } }", + )?; + expect_contains(&body, "\"backend\":\"i2c\"")?; + expect_contains(&body, "\"capacityBytes\":8192")?; + expect_contains(&body, "\"framReachable\":true") +} + +fn graphql_mission_state(url: &str) -> Result<(), String> { + let body = graphql( + url, + "{ missionState { removeBeforeFlight deployed deployStart detumblingComplete } }", + )?; + expect_contains(&body, "\"missionState\"") +} + +fn graphql_mission_write_restore(url: &str) -> Result<(), String> { + let original = query_detumbling(url)?; + let test_value = !original; + + set_detumbling(url, test_value)?; + let observed = query_detumbling(url)?; + if observed != test_value { + let restore_result = set_detumbling(url, original); + return match restore_result { + Ok(()) => Err(format!( + "detumbling_complete was {observed}, expected {test_value}; original restored" + )), + Err(err) => Err(format!( + "detumbling_complete was {observed}, expected {test_value}; restore also failed: {err}" + )), + }; + } + + set_detumbling(url, original)?; + + let restored = query_detumbling(url)?; + if restored != original { + return Err(format!( + "detumbling_complete restore readback was {restored}, expected {original}" + )); + } + + Ok(()) +} + +fn query_detumbling(url: &str) -> Result { + let body = graphql(url, "{ missionState { detumblingComplete } }")?; + json_bool(&body, "detumblingComplete") + .ok_or_else(|| format!("could not find detumblingComplete bool in response: {body}")) +} + +fn set_detumbling(url: &str, value: bool) -> Result<(), String> { + let query = format!( + "mutation {{ setMissionFlag(key: DETUMBLING_COMPLETE, value: {value}, mirrorToEnv: false) {{ success errors state {{ detumblingComplete }} }} }}" + ); + let body = graphql(url, &query)?; + expect_contains(&body, "\"success\":true")?; + let observed = json_bool(&body, "detumblingComplete") + .ok_or_else(|| format!("could not find detumblingComplete bool in response: {body}"))?; + if observed == value { + Ok(()) + } else { + Err(format!( + "detumblingComplete write returned {observed}, expected {value}" + )) + } +} + +fn graphql(url: &str, query: &str) -> Result { + let endpoint = Endpoint::parse(url)?; + let json = format!("{{\"query\":\"{}\"}}", json_escape(query)); + let mut stream = TcpStream::connect((endpoint.host.as_str(), endpoint.port)) + .map_err(|err| format!("connect {}:{} failed: {err}", endpoint.host, endpoint.port))?; + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .map_err(|err| format!("set read timeout failed: {err}"))?; + stream + .set_write_timeout(Some(Duration::from_secs(10))) + .map_err(|err| format!("set write timeout failed: {err}"))?; + + let request = format!( + "POST {} HTTP/1.1\r\n\ + Host: {}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {}", + endpoint.path, + endpoint.host, + json.len(), + json + ); + stream + .write_all(request.as_bytes()) + .map_err(|err| format!("HTTP write failed: {err}"))?; + + let mut response = String::new(); + stream + .read_to_string(&mut response) + .map_err(|err| format!("HTTP read failed: {err}"))?; + + let (headers, body) = response + .split_once("\r\n\r\n") + .ok_or_else(|| format!("invalid HTTP response: {response}"))?; + if !headers.starts_with("HTTP/1.1 200") && !headers.starts_with("HTTP/1.0 200") { + return Err(format!("non-200 response: {headers}\n{body}")); + } + if body.contains("\"errors\"") { + return Err(format!("GraphQL returned errors: {body}")); + } + + Ok(body.to_string()) +} + +fn expect_contains(body: &str, needle: &str) -> Result<(), String> { + if body.contains(needle) { + Ok(()) + } else { + Err(format!("response did not contain {needle}: {body}")) + } +} + +fn json_bool(body: &str, field: &str) -> Option { + let true_pattern = format!("\"{field}\":true"); + let false_pattern = format!("\"{field}\":false"); + if body.contains(&true_pattern) { + Some(true) + } else if body.contains(&false_pattern) { + Some(false) + } else { + None + } +} + +fn json_escape(value: &str) -> String { + let mut out = String::new(); + for c in value.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + other => out.push(other), + } + } + out +} + +#[derive(Debug)] +struct Endpoint { + host: String, + port: u16, + path: String, +} + +impl Endpoint { + fn parse(url: &str) -> Result { + let rest = url + .strip_prefix("http://") + .ok_or_else(|| "only http:// URLs are supported".to_string())?; + let (host_port, path) = rest.split_once('/').unwrap_or((rest, "graphql")); + let (host, port) = match host_port.rsplit_once(':') { + Some((host, port)) => ( + host.to_string(), + port.parse::() + .map_err(|err| format!("invalid URL port '{port}': {err}"))?, + ), + None => (host_port.to_string(), 80), + }; + if host.is_empty() { + return Err("URL host is empty".to_string()); + } + Ok(Self { + host, + port, + path: format!("/{path}"), + }) + } +} diff --git a/services/hardware-services/fram-service/readme.txt b/services/hardware-services/fram-service/readme.txt deleted file mode 100644 index 745d65a..0000000 --- a/services/hardware-services/fram-service/readme.txt +++ /dev/null @@ -1,12 +0,0 @@ -Fram Documentation - - - - - - - - - - - diff --git a/services/hardware-services/fram-service/src/backend.rs b/services/hardware-services/fram-service/src/backend.rs new file mode 100644 index 0000000..9d2ca57 --- /dev/null +++ b/services/hardware-services/fram-service/src/backend.rs @@ -0,0 +1,246 @@ +use std::fs::OpenOptions; +use std::io::{Read, Seek, SeekFrom, Write}; + +use thiserror::Error; + +pub const DEFAULT_CAPACITY_BYTES: u32 = 8192; +pub const DEFAULT_MAX_TRANSFER_BYTES: usize = 32; +pub const FM24CL64B_MIN_I2C_ADDR: u8 = 0x50; +pub const FM24CL64B_MAX_I2C_ADDR: u8 = 0x57; + +#[derive(Debug, Error)] +pub enum BackendError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("invalid backend config: {0}")] + InvalidConfig(String), + #[cfg(feature = "i2c")] + #[error("I2C driver error: {0}")] + Driver(String), + #[error("operation out of bounds (offset={offset}, len={len}, capacity={capacity})")] + OutOfBounds { + offset: u32, + len: usize, + capacity: u32, + }, +} + +pub trait ByteStorage: Send { + fn capacity(&self) -> u32; + fn read(&mut self, offset: u32, out: &mut [u8]) -> Result<(), BackendError>; + fn write(&mut self, offset: u32, data: &[u8]) -> Result<(), BackendError>; +} + +pub fn validate_fm24cl64b_i2c_addr(device_addr: u8) -> Result<(), BackendError> { + if (FM24CL64B_MIN_I2C_ADDR..=FM24CL64B_MAX_I2C_ADDR).contains(&device_addr) { + Ok(()) + } else { + Err(BackendError::InvalidConfig(format!( + "i2c_addr must be the Linux 7-bit FM24CL64B address 0x50..0x57; got 0x{device_addr:02X}. Do not use the 8-bit datasheet address byte 0xA0/0xA1." + ))) + } +} + +pub fn memory_address_bytes(offset: u32, address_width_bytes: u8) -> Result, BackendError> { + match address_width_bytes { + 1 => Ok(vec![(offset & 0xFF) as u8]), + 2 => Ok(vec![((offset >> 8) & 0xFF) as u8, (offset & 0xFF) as u8]), + _ => Err(BackendError::InvalidConfig( + "address_width_bytes must be 1 or 2".to_string(), + )), + } +} + +pub struct FileImageBackend { + file: std::fs::File, + capacity: u32, +} + +impl FileImageBackend { + pub fn new(path: &str, capacity: u32) -> Result { + if capacity == 0 { + return Err(BackendError::InvalidConfig( + "image_capacity_bytes must be > 0".to_string(), + )); + } + + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + + let current_len = file.metadata()?.len(); + if current_len < capacity as u64 { + file.set_len(capacity as u64)?; + file.flush()?; + } + + Ok(Self { file, capacity }) + } + + fn ensure_in_bounds(&self, offset: u32, len: usize) -> Result<(), BackendError> { + ensure_in_bounds(offset, len, self.capacity) + } +} + +impl ByteStorage for FileImageBackend { + fn capacity(&self) -> u32 { + self.capacity + } + + fn read(&mut self, offset: u32, out: &mut [u8]) -> Result<(), BackendError> { + self.ensure_in_bounds(offset, out.len())?; + self.file.seek(SeekFrom::Start(offset as u64))?; + self.file.read_exact(out)?; + Ok(()) + } + + fn write(&mut self, offset: u32, data: &[u8]) -> Result<(), BackendError> { + self.ensure_in_bounds(offset, data.len())?; + self.file.seek(SeekFrom::Start(offset as u64))?; + self.file.write_all(data)?; + self.file.flush()?; + Ok(()) + } +} + +#[cfg(feature = "i2c")] +pub struct I2cFramBackend { + i2c: linux_embedded_hal::I2cdev, + device_addr: u8, + capacity: u32, + address_width_bytes: u8, + max_transfer_bytes: usize, +} + +#[cfg(feature = "i2c")] +impl I2cFramBackend { + pub fn new( + bus: &str, + device_addr: u8, + capacity: u32, + address_width_bytes: u8, + max_transfer_bytes: usize, + ) -> Result { + if capacity == 0 { + return Err(BackendError::InvalidConfig( + "capacity_bytes must be > 0".to_string(), + )); + } + + if !matches!(address_width_bytes, 1 | 2) { + return Err(BackendError::InvalidConfig( + "address_width_bytes must be 1 or 2".to_string(), + )); + } + + if max_transfer_bytes == 0 { + return Err(BackendError::InvalidConfig( + "max_transfer_bytes must be > 0".to_string(), + )); + } + validate_fm24cl64b_i2c_addr(device_addr)?; + + let addressable_bytes = 1_u32 + .checked_shl(u32::from(address_width_bytes) * 8) + .ok_or_else(|| BackendError::InvalidConfig("address width too large".to_string()))?; + if capacity > addressable_bytes { + return Err(BackendError::InvalidConfig(format!( + "capacity {capacity} exceeds {addressable_bytes} bytes addressable by {address_width_bytes} address bytes" + ))); + } + + let i2c = linux_embedded_hal::I2cdev::new(bus) + .map_err(|err| BackendError::Driver(err.to_string()))?; + + Ok(Self { + i2c, + device_addr, + capacity, + address_width_bytes, + max_transfer_bytes, + }) + } + + fn ensure_in_bounds(&self, offset: u32, len: usize) -> Result<(), BackendError> { + ensure_in_bounds(offset, len, self.capacity) + } + + fn address_bytes(&self, offset: u32) -> Vec { + memory_address_bytes(offset, self.address_width_bytes) + .expect("address width was validated during I2C backend initialization") + } +} + +#[cfg(feature = "i2c")] +impl ByteStorage for I2cFramBackend { + fn capacity(&self) -> u32 { + self.capacity + } + + fn read(&mut self, offset: u32, out: &mut [u8]) -> Result<(), BackendError> { + use embedded_hal::i2c::I2c; + + self.ensure_in_bounds(offset, out.len())?; + + let mut cursor = offset; + for chunk in out.chunks_mut(self.max_transfer_bytes) { + let addr = self.address_bytes(cursor); + // FM24CL64B selective read: write the two-byte memory address, + // repeated START, then read sequential data bytes. + self.i2c + .write_read(self.device_addr, &addr, chunk) + .map_err(|err| BackendError::Driver(err.to_string()))?; + cursor += chunk.len() as u32; + } + + Ok(()) + } + + fn write(&mut self, offset: u32, data: &[u8]) -> Result<(), BackendError> { + use embedded_hal::i2c::I2c; + + self.ensure_in_bounds(offset, data.len())?; + + let mut cursor = offset; + for chunk in data.chunks(self.max_transfer_bytes) { + let mut frame = self.address_bytes(cursor); + frame.extend_from_slice(chunk); + // FM24CL64B write: two-byte memory address followed by one or more + // data bytes. F-RAM has no EEPROM-style write delay. + self.i2c + .write(self.device_addr, &frame) + .map_err(|err| BackendError::Driver(err.to_string()))?; + cursor += chunk.len() as u32; + } + + Ok(()) + } +} + +fn ensure_in_bounds(offset: u32, len: usize, capacity: u32) -> Result<(), BackendError> { + let len_u32 = u32::try_from(len).map_err(|_| BackendError::OutOfBounds { + offset, + len, + capacity, + })?; + let end = offset + .checked_add(len_u32) + .ok_or(BackendError::OutOfBounds { + offset, + len, + capacity, + })?; + + if end > capacity { + return Err(BackendError::OutOfBounds { + offset, + len, + capacity, + }); + } + + Ok(()) +} diff --git a/services/hardware-services/fram-service/src/env.rs b/services/hardware-services/fram-service/src/env.rs new file mode 100644 index 0000000..2cfc2bb --- /dev/null +++ b/services/hardware-services/fram-service/src/env.rs @@ -0,0 +1,97 @@ +use std::collections::HashMap; +use std::process::Command; + +pub trait EnvStore: Send { + fn read(&mut self, name: &str) -> Result, String>; + fn write(&mut self, name: &str, value: Option<&str>) -> Result<(), String>; +} + +pub struct CommandEnvStore { + printenv_path: String, + setenv_path: String, +} + +impl CommandEnvStore { + pub fn new(printenv_path: String, setenv_path: String) -> Self { + Self { + printenv_path, + setenv_path, + } + } +} + +impl EnvStore for CommandEnvStore { + fn read(&mut self, name: &str) -> Result, String> { + let output = Command::new(&self.printenv_path) + .args(["-n", name]) + .output() + .map_err(|err| format!("failed to execute {}: {err}", self.printenv_path))?; + + if !output.status.success() { + return Ok(None); + } + + Ok(Some( + String::from_utf8_lossy(&output.stdout).trim().to_string(), + )) + } + + fn write(&mut self, name: &str, value: Option<&str>) -> Result<(), String> { + let mut command = Command::new(&self.setenv_path); + command.arg(name); + if let Some(value) = value { + command.arg(value); + } + + let output = command + .output() + .map_err(|err| format!("failed to execute {}: {err}", self.setenv_path))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { + format!("{} failed for {name}", self.setenv_path) + } else { + stderr + }) + } + } +} + +#[derive(Default)] +pub struct MemoryEnvStore { + values: HashMap, +} + +impl MemoryEnvStore { + pub fn new(values: impl IntoIterator) -> Self { + Self { + values: values.into_iter().collect(), + } + } + + pub fn get(&self, name: &str) -> Option<&str> { + self.values.get(name).map(String::as_str) + } +} + +impl EnvStore for MemoryEnvStore { + fn read(&mut self, name: &str) -> Result, String> { + Ok(self.values.get(name).cloned()) + } + + fn write(&mut self, name: &str, value: Option<&str>) -> Result<(), String> { + match value { + Some(value) => { + self.values.insert(name.to_string(), value.to_string()); + } + None => { + self.values.remove(name); + } + } + + Ok(()) + } +} diff --git a/services/hardware-services/fram-service/src/layout.rs b/services/hardware-services/fram-service/src/layout.rs new file mode 100644 index 0000000..5c0f9c5 --- /dev/null +++ b/services/hardware-services/fram-service/src/layout.rs @@ -0,0 +1,272 @@ +use crc32fast::Hasher; +use thiserror::Error; + +use crate::backend::{BackendError, ByteStorage}; +use crate::model::{MissionKey, MissionValue, ValueType}; + +pub const RECORD_SIZE: usize = 32; +pub const SLOTS_PER_KEY: u32 = 2; +pub const RESERVED_KEYS: u32 = 32; + +const MAGIC: u16 = 0x4652; // "FR" +const VERSION: u8 = 1; +const PAYLOAD_LEN: usize = 16; +const CRC_OFFSET: usize = 28; + +#[derive(Debug, Error)] +pub enum LayoutError { + #[error("backend error: {0}")] + Backend(#[from] BackendError), + #[error("FRAM capacity {capacity} is too small for reserved layout {required}")] + CapacityTooSmall { capacity: u32, required: u32 }, + #[error("invalid record value for {0}")] + InvalidValue(String), +} + +pub struct FramStorage { + storage: Box, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct FramRecord { + key: MissionKey, + value: MissionValue, + sequence: u32, +} + +impl FramStorage { + pub fn mount(storage: Box) -> Result { + let required = RESERVED_KEYS * SLOTS_PER_KEY * RECORD_SIZE as u32; + let capacity = storage.capacity(); + if capacity < required { + return Err(LayoutError::CapacityTooSmall { capacity, required }); + } + + Ok(Self { storage }) + } + + pub fn capacity(&self) -> u32 { + self.storage.capacity() + } + + pub fn read_key(&mut self, key: MissionKey) -> Result, LayoutError> { + Ok(self.current_record(key)?.map(|record| record.value)) + } + + pub fn write_key(&mut self, key: MissionKey, value: &MissionValue) -> Result<(), LayoutError> { + validate_key_value(key, value)?; + + let a = self.read_slot(key, 0)?; + let b = self.read_slot(key, 1)?; + let current = current_record(a.as_ref(), b.as_ref()); + let sequence = current + .map(|record| record.sequence.wrapping_add(1)) + .unwrap_or(1); + + let target_slot = match (&a, &b) { + (None, _) => 0, + (_, None) => 1, + (Some(a), Some(b)) if a.sequence <= b.sequence => 0, + (Some(_), Some(_)) => 1, + }; + + let record = FramRecord { + key, + value: value.clone(), + sequence, + }; + let encoded = encode_record(&record)?; + self.storage + .write(slot_offset(key, target_slot), &encoded) + .map_err(LayoutError::Backend)?; + + let readback = self.read_slot(key, target_slot)?; + if readback.as_ref() != Some(&record) { + return Err(LayoutError::InvalidValue(format!( + "{} readback verification failed", + key.env_name() + ))); + } + + Ok(()) + } + + fn current_record(&mut self, key: MissionKey) -> Result, LayoutError> { + let a = self.read_slot(key, 0)?; + let b = self.read_slot(key, 1)?; + Ok(current_record(a.as_ref(), b.as_ref()).cloned()) + } + + fn read_slot(&mut self, key: MissionKey, slot: u32) -> Result, LayoutError> { + let mut raw = [0u8; RECORD_SIZE]; + self.storage + .read(slot_offset(key, slot), &mut raw) + .map_err(LayoutError::Backend)?; + decode_record(key, &raw) + } +} + +fn current_record<'a>( + a: Option<&'a FramRecord>, + b: Option<&'a FramRecord>, +) -> Option<&'a FramRecord> { + match (a, b) { + (Some(a), Some(b)) if a.sequence >= b.sequence => Some(a), + (Some(_), Some(b)) => Some(b), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + } +} + +fn slot_offset(key: MissionKey, slot: u32) -> u32 { + let key_index = u32::from(key.id() - 1); + ((key_index * SLOTS_PER_KEY) + slot) * RECORD_SIZE as u32 +} + +fn encode_record(record: &FramRecord) -> Result<[u8; RECORD_SIZE], LayoutError> { + validate_key_value(record.key, &record.value)?; + + let mut raw = [0u8; RECORD_SIZE]; + raw[0..2].copy_from_slice(&MAGIC.to_le_bytes()); + raw[2] = VERSION; + raw[3] = record.key.id(); + raw[4] = record.value.value_type().id(); + raw[6..10].copy_from_slice(&record.sequence.to_le_bytes()); + + match &record.value { + MissionValue::Bool(value) => { + raw[5] = 1; + raw[10] = u8::from(*value); + } + MissionValue::Timestamp(Some(value)) => { + raw[5] = 8; + raw[10..18].copy_from_slice(&value.to_le_bytes()); + } + MissionValue::Timestamp(None) => { + raw[5] = 0; + } + } + + let crc = crc32(&raw[..CRC_OFFSET]); + raw[CRC_OFFSET..CRC_OFFSET + 4].copy_from_slice(&crc.to_le_bytes()); + + Ok(raw) +} + +fn decode_record( + key: MissionKey, + raw: &[u8; RECORD_SIZE], +) -> Result, LayoutError> { + let magic = u16::from_le_bytes([raw[0], raw[1]]); + if magic != MAGIC { + return Ok(None); + } + + if raw[2] != VERSION || raw[3] != key.id() { + return Ok(None); + } + + let expected_crc = u32::from_le_bytes([ + raw[CRC_OFFSET], + raw[CRC_OFFSET + 1], + raw[CRC_OFFSET + 2], + raw[CRC_OFFSET + 3], + ]); + if expected_crc != crc32(&raw[..CRC_OFFSET]) { + return Ok(None); + } + + let value_type = match ValueType::from_id(raw[4]) { + Some(value_type) => value_type, + None => return Ok(None), + }; + let payload_len = raw[5] as usize; + if payload_len > PAYLOAD_LEN { + return Ok(None); + } + + let value = match value_type { + ValueType::Bool if payload_len == 1 => MissionValue::Bool(raw[10] != 0), + ValueType::Timestamp if payload_len == 0 => MissionValue::Timestamp(None), + ValueType::Timestamp if payload_len == 8 => { + let mut buf = [0u8; 8]; + buf.copy_from_slice(&raw[10..18]); + MissionValue::Timestamp(Some(u64::from_le_bytes(buf))) + } + _ => return Ok(None), + }; + + if validate_key_value(key, &value).is_err() { + return Ok(None); + } + + let sequence = u32::from_le_bytes([raw[6], raw[7], raw[8], raw[9]]); + Ok(Some(FramRecord { + key, + value, + sequence, + })) +} + +fn validate_key_value(key: MissionKey, value: &MissionValue) -> Result<(), LayoutError> { + match (key, value) { + (MissionKey::DeployStart, MissionValue::Timestamp(_)) => Ok(()), + (MissionKey::DeployStart, _) => Err(LayoutError::InvalidValue(key.env_name().to_string())), + (_, MissionValue::Bool(_)) => Ok(()), + (_, _) => Err(LayoutError::InvalidValue(key.env_name().to_string())), + } +} + +fn crc32(data: &[u8]) -> u32 { + let mut hasher = Hasher::new(); + hasher.update(data); + hasher.finalize() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::FileImageBackend; + use tempfile::TempDir; + + fn storage() -> (TempDir, FramStorage) { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("fram.img"); + let backend = FileImageBackend::new(path.to_str().unwrap(), 8192).unwrap(); + ( + tmp, + FramStorage::mount(Box::new(backend)).expect("mount failed"), + ) + } + + #[test] + fn missing_key_reads_none() { + let (_tmp, mut storage) = storage(); + assert_eq!(storage.read_key(MissionKey::Deployed).unwrap(), None); + } + + #[test] + fn write_and_read_bool() { + let (_tmp, mut storage) = storage(); + storage + .write_key(MissionKey::Deployed, &MissionValue::Bool(true)) + .unwrap(); + assert_eq!( + storage.read_key(MissionKey::Deployed).unwrap(), + Some(MissionValue::Bool(true)) + ); + } + + #[test] + fn write_and_read_unset_timestamp() { + let (_tmp, mut storage) = storage(); + storage + .write_key(MissionKey::DeployStart, &MissionValue::Timestamp(None)) + .unwrap(); + assert_eq!( + storage.read_key(MissionKey::DeployStart).unwrap(), + Some(MissionValue::Timestamp(None)) + ); + } +} diff --git a/services/hardware-services/fram-service/src/lib.rs b/services/hardware-services/fram-service/src/lib.rs new file mode 100644 index 0000000..f81d0b7 --- /dev/null +++ b/services/hardware-services/fram-service/src/lib.rs @@ -0,0 +1,7 @@ +pub mod backend; +pub mod env; +pub mod layout; +pub mod model; +pub mod reconcile; +pub mod schema; +pub mod subsystem; diff --git a/services/hardware-services/fram-service/src/main.rs b/services/hardware-services/fram-service/src/main.rs index 154a4e7..bdb24e9 100644 --- a/services/hardware-services/fram-service/src/main.rs +++ b/services/hardware-services/fram-service/src/main.rs @@ -1,103 +1,31 @@ -use rust_i2c::{Command, Connection}; -use linux_embedded_hal::I2cdev; -use embedded_hal::i2c::I2c; +use kubos_service::{Config, Logger, Service}; -const I2C_BUS: &str = "/dev/i2c-2"; -const FRAM_ADDR: u8 = 0x50; -const SOLAR_PANEL_MEMORY: [u8; 4] = [0x01, 0x02, 0x03,0x04]; -const ATTENNA_MEMORY: [u8; 4] = [0x05,0x06,0x07,0x08]; -const ERROR_MEMORY: [u8; 4] = [0x00,0x00,0x00,0x00]; +use fram_service::schema::{MutationRoot, QueryRoot}; +use fram_service::subsystem::Subsystem; -enum Checks { - SolarPanels, - Attenna, -} - -fn write_fram(bus: &mut I2cdev, address: u8, data: u8){ - match bus.write(FRAM_ADDR, &[address, data]){ - Ok(_) => println!(""), - Err(e) => println!(""), - }; -} - - -fn read_fram(bus: &mut I2cdev, mem_loc: u8) -> u8{ - match bus.read(FRAM_ADDR, &[mem_loc]){ - Ok(_) => return bus.read(FRAM_ADDR, &[mem_loc]), - Err(e) => return 0x00, - }; - -} - -fn write_status(arr: [u8;4],status:bool){ - let mut data: u8; - - if status{ - data = 0x01 - } - else{ - data = 0x00 +fn main() { + if let Err(err) = Logger::init("fram-service") { + eprintln!("failed to initialize logger: {err:?}"); + std::process::exit(1); } - - for location in arr{ - write_fram(i2c, location, data) - } - -} -fn getaddresses(part_to_address: Checks) -> Result<[u8;4],String>{ - let address: [u8;4]; - - match part_to_address { - Checks::SolarPanels => address = SOLAR_PANEL_MEMORY, - - Checks::Attenna => address = ATTENNA_MEMORY, - - _ => return Err("Cannot get address".to_string()), + let config = match Config::new("fram-service") { + Ok(config) => config, + Err(err) => { + log::error!("Failed to load service config: {:?}", err); + eprintln!("Failed to load service config: {err}"); + std::process::exit(2); + } }; - Ok( - address - ) - -} - -fn check_sum(arr: [u8; 4]) -> u8{ - let mut equal: u8 = 0; - let mut non_equal: u8 = 0; - let mut first: u8 = read_fram(arr[0]); - let mut data: u8; - - for address in arr{ - - data = read_fram(address); - - if first == data{ - equal += 1; + let subsystem = match Subsystem::from_config(&config) { + Ok(subsystem) => subsystem, + Err(err) => { + log::error!("Failed to initialize FRAM subsystem: {}", err); + eprintln!("Failed to initialize FRAM subsystem: {err}"); + std::process::exit(3); } - else { - non_equal += 1; - } - - } - - if equal >= non_equal{ - - equal/non_equal - - } - else{ - - non_equal/equal - - } - -} - -fn main(){ - let mut i2c = match I2cdev::new(I2C_BUS){ - Ok(_) => println!("BANG"), - Err(e) => println!("{:?}",e), }; + Service::new(config, subsystem, QueryRoot, MutationRoot).start(); } diff --git a/services/hardware-services/fram-service/src/model.rs b/services/hardware-services/fram-service/src/model.rs new file mode 100644 index 0000000..30d8b7b --- /dev/null +++ b/services/hardware-services/fram-service/src/model.rs @@ -0,0 +1,270 @@ +use std::fmt; +use std::str::FromStr; + +use async_graphql::{Enum, SimpleObject}; + +pub const MISSION_KEYS: [MissionKey; 8] = [ + MissionKey::RemoveBeforeFlight, + MissionKey::Deployed, + MissionKey::DeployStart, + MissionKey::SolarPanelDeployed, + MissionKey::UhfAntennaDeployed, + MissionKey::VhfAntennaDeployed, + MissionKey::InitialSafeStateComplete, + MissionKey::DetumblingComplete, +]; + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum MissionKey { + RemoveBeforeFlight, + Deployed, + DeployStart, + SolarPanelDeployed, + UhfAntennaDeployed, + VhfAntennaDeployed, + InitialSafeStateComplete, + DetumblingComplete, +} + +impl MissionKey { + pub fn id(self) -> u8 { + match self { + Self::RemoveBeforeFlight => 1, + Self::Deployed => 2, + Self::DeployStart => 3, + Self::SolarPanelDeployed => 4, + Self::UhfAntennaDeployed => 5, + Self::VhfAntennaDeployed => 6, + Self::InitialSafeStateComplete => 7, + Self::DetumblingComplete => 8, + } + } + + pub fn from_id(id: u8) -> Option { + match id { + 1 => Some(Self::RemoveBeforeFlight), + 2 => Some(Self::Deployed), + 3 => Some(Self::DeployStart), + 4 => Some(Self::SolarPanelDeployed), + 5 => Some(Self::UhfAntennaDeployed), + 6 => Some(Self::VhfAntennaDeployed), + 7 => Some(Self::InitialSafeStateComplete), + 8 => Some(Self::DetumblingComplete), + _ => None, + } + } + + pub fn env_name(self) -> &'static str { + match self { + Self::RemoveBeforeFlight => "remove_before_flight", + Self::Deployed => "deployed", + Self::DeployStart => "deploy_start", + Self::SolarPanelDeployed => "solar_panel_deployed", + Self::UhfAntennaDeployed => "uhf_antenna_deployed", + Self::VhfAntennaDeployed => "vhf_antenna_deployed", + Self::InitialSafeStateComplete => "initial_safe_state_complete", + Self::DetumblingComplete => "detumbling_complete", + } + } + + pub fn is_completion_flag(self) -> bool { + matches!( + self, + Self::Deployed + | Self::SolarPanelDeployed + | Self::UhfAntennaDeployed + | Self::VhfAntennaDeployed + | Self::InitialSafeStateComplete + | Self::DetumblingComplete + ) + } + + pub fn default_value(self) -> MissionValue { + match self { + Self::DeployStart => MissionValue::Timestamp(None), + _ => MissionValue::Bool(false), + } + } +} + +impl fmt::Display for MissionKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.env_name()) + } +} + +#[derive(Enum, Copy, Clone, Debug, Eq, PartialEq)] +#[graphql(rename_items = "SCREAMING_SNAKE_CASE")] +pub enum MissionFlagKey { + RemoveBeforeFlight, + Deployed, + SolarPanelDeployed, + UhfAntennaDeployed, + VhfAntennaDeployed, + InitialSafeStateComplete, + DetumblingComplete, +} + +impl From for MissionKey { + fn from(key: MissionFlagKey) -> Self { + match key { + MissionFlagKey::RemoveBeforeFlight => Self::RemoveBeforeFlight, + MissionFlagKey::Deployed => Self::Deployed, + MissionFlagKey::SolarPanelDeployed => Self::SolarPanelDeployed, + MissionFlagKey::UhfAntennaDeployed => Self::UhfAntennaDeployed, + MissionFlagKey::VhfAntennaDeployed => Self::VhfAntennaDeployed, + MissionFlagKey::InitialSafeStateComplete => Self::InitialSafeStateComplete, + MissionFlagKey::DetumblingComplete => Self::DetumblingComplete, + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ValueType { + Bool, + Timestamp, +} + +impl ValueType { + pub fn id(self) -> u8 { + match self { + Self::Bool => 1, + Self::Timestamp => 2, + } + } + + pub fn from_id(id: u8) -> Option { + match id { + 1 => Some(Self::Bool), + 2 => Some(Self::Timestamp), + _ => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MissionValue { + Bool(bool), + Timestamp(Option), +} + +impl MissionValue { + pub fn value_type(&self) -> ValueType { + match self { + Self::Bool(_) => ValueType::Bool, + Self::Timestamp(_) => ValueType::Timestamp, + } + } + + pub fn as_bool(&self) -> Option { + match self { + Self::Bool(value) => Some(*value), + Self::Timestamp(_) => None, + } + } + + pub fn as_timestamp(&self) -> Option> { + match self { + Self::Timestamp(value) => Some(*value), + Self::Bool(_) => None, + } + } + + pub fn to_env_value(&self) -> Option { + match self { + Self::Bool(true) => Some("true".to_string()), + Self::Bool(false) => Some("false".to_string()), + Self::Timestamp(Some(value)) => Some(value.to_string()), + Self::Timestamp(None) => None, + } + } + + pub fn display_value(&self) -> Option { + self.to_env_value() + } +} + +pub fn parse_env_value(key: MissionKey, value: &str) -> Result { + match key { + MissionKey::DeployStart => { + let value = value.trim(); + if value.is_empty() { + Ok(MissionValue::Timestamp(None)) + } else { + u64::from_str(value) + .map(|value| MissionValue::Timestamp(Some(value))) + .map_err(|_| format!("invalid deploy_start timestamp '{value}'")) + } + } + _ => parse_bool(value).map(MissionValue::Bool), + } +} + +pub fn parse_bool(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "t" | "true" | "1" | "y" | "yes" => Ok(true), + "f" | "false" | "0" | "n" | "no" => Ok(false), + other => Err(format!("invalid bool value '{other}'")), + } +} + +#[derive(SimpleObject, Clone, Debug, Default, Eq, PartialEq)] +pub struct MissionState { + pub remove_before_flight: bool, + pub deployed: bool, + pub deploy_start: Option, + pub solar_panel_deployed: bool, + pub uhf_antenna_deployed: bool, + pub vhf_antenna_deployed: bool, + pub initial_safe_state_complete: bool, + pub detumbling_complete: bool, + pub sync: Vec, +} + +impl MissionState { + pub fn set(&mut self, key: MissionKey, value: &MissionValue) { + match (key, value) { + (MissionKey::RemoveBeforeFlight, MissionValue::Bool(value)) => { + self.remove_before_flight = *value + } + (MissionKey::Deployed, MissionValue::Bool(value)) => self.deployed = *value, + (MissionKey::DeployStart, MissionValue::Timestamp(value)) => { + self.deploy_start = value.map(|value| value as i64) + } + (MissionKey::SolarPanelDeployed, MissionValue::Bool(value)) => { + self.solar_panel_deployed = *value + } + (MissionKey::UhfAntennaDeployed, MissionValue::Bool(value)) => { + self.uhf_antenna_deployed = *value + } + (MissionKey::VhfAntennaDeployed, MissionValue::Bool(value)) => { + self.vhf_antenna_deployed = *value + } + (MissionKey::InitialSafeStateComplete, MissionValue::Bool(value)) => { + self.initial_safe_state_complete = *value + } + (MissionKey::DetumblingComplete, MissionValue::Bool(value)) => { + self.detumbling_complete = *value + } + _ => {} + } + } +} + +#[derive(SimpleObject, Clone, Debug, Default, Eq, PartialEq)] +pub struct SyncAction { + pub key: String, + pub fram_value: Option, + pub env_value: Option, + pub merged_value: Option, + pub action: String, + pub errors: String, +} + +#[derive(SimpleObject, Clone, Debug, Default, Eq, PartialEq)] +pub struct ReconcileResponse { + pub success: bool, + pub errors: String, + pub actions: Vec, + pub state: MissionState, +} diff --git a/services/hardware-services/fram-service/src/reconcile.rs b/services/hardware-services/fram-service/src/reconcile.rs new file mode 100644 index 0000000..4dea3f9 --- /dev/null +++ b/services/hardware-services/fram-service/src/reconcile.rs @@ -0,0 +1,177 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::model::{ + MISSION_KEYS, MissionKey, MissionState, MissionValue, SyncAction, parse_env_value, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SourceValue { + Missing, + Invalid(String), + Unavailable(String), + Value(MissionValue), +} + +impl SourceValue { + pub fn display_value(&self) -> Option { + match self { + Self::Value(value) => value.display_value(), + Self::Missing | Self::Invalid(_) | Self::Unavailable(_) => None, + } + } + + pub fn error(&self) -> Option { + match self { + Self::Invalid(err) | Self::Unavailable(err) => Some(err.clone()), + Self::Missing | Self::Value(_) => None, + } + } +} + +pub fn source_from_env(key: MissionKey, value: Option) -> SourceValue { + match value { + Some(value) => match parse_env_value(key, &value) { + Ok(value) => SourceValue::Value(value), + Err(err) => SourceValue::Invalid(err), + }, + None => SourceValue::Missing, + } +} + +pub fn merge_value( + key: MissionKey, + fram: &SourceValue, + env: &SourceValue, + now_timestamp: u64, +) -> MissionValue { + match key { + MissionKey::RemoveBeforeFlight => { + let value = [fram, env] + .iter() + .any(|source| matches!(source, SourceValue::Value(MissionValue::Bool(true)))); + MissionValue::Bool(value) + } + MissionKey::DeployStart => merge_deploy_start(fram, env, now_timestamp), + key if key.is_completion_flag() => { + let values: Vec = [fram, env] + .iter() + .filter_map(|source| match source { + SourceValue::Value(MissionValue::Bool(value)) => Some(*value), + _ => None, + }) + .collect(); + + if values.iter().any(|value| !*value) { + MissionValue::Bool(false) + } else if values.iter().any(|value| *value) { + MissionValue::Bool(true) + } else { + MissionValue::Bool(false) + } + } + _ => key.default_value(), + } +} + +pub fn now_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +pub fn build_state(values: &[(MissionKey, MissionValue)], sync: Vec) -> MissionState { + let mut state = MissionState { + sync, + ..MissionState::default() + }; + + for (key, value) in values { + state.set(*key, value); + } + + state +} + +pub fn all_default_values() -> Vec<(MissionKey, MissionValue)> { + MISSION_KEYS + .iter() + .map(|key| (*key, key.default_value())) + .collect() +} + +fn merge_deploy_start(fram: &SourceValue, env: &SourceValue, now_timestamp: u64) -> MissionValue { + let mut timestamps = Vec::new(); + let mut saw_invalid = false; + + for source in [fram, env] { + match source { + SourceValue::Value(MissionValue::Timestamp(Some(value))) => timestamps.push(*value), + SourceValue::Value(MissionValue::Timestamp(None)) | SourceValue::Missing => {} + SourceValue::Unavailable(_) => {} + SourceValue::Invalid(_) => saw_invalid = true, + SourceValue::Value(MissionValue::Bool(_)) => saw_invalid = true, + } + } + + if let Some(max) = timestamps.into_iter().max() { + MissionValue::Timestamp(Some(max)) + } else if saw_invalid { + MissionValue::Timestamp(Some(now_timestamp)) + } else { + MissionValue::Timestamp(None) + } +} + +pub fn source_matches(source: &SourceValue, merged: &MissionValue) -> bool { + matches!(source, SourceValue::Value(value) if value == merged) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remove_before_flight_or_merges() { + let merged = merge_value( + MissionKey::RemoveBeforeFlight, + &SourceValue::Value(MissionValue::Bool(true)), + &SourceValue::Value(MissionValue::Bool(false)), + 10, + ); + assert_eq!(merged, MissionValue::Bool(true)); + } + + #[test] + fn completion_flag_false_beats_true() { + let merged = merge_value( + MissionKey::Deployed, + &SourceValue::Value(MissionValue::Bool(true)), + &SourceValue::Value(MissionValue::Bool(false)), + 10, + ); + assert_eq!(merged, MissionValue::Bool(false)); + } + + #[test] + fn deploy_start_latest_timestamp_wins() { + let merged = merge_value( + MissionKey::DeployStart, + &SourceValue::Value(MissionValue::Timestamp(Some(100))), + &SourceValue::Value(MissionValue::Timestamp(Some(200))), + 10, + ); + assert_eq!(merged, MissionValue::Timestamp(Some(200))); + } + + #[test] + fn invalid_deploy_start_restarts_hold_timer() { + let merged = merge_value( + MissionKey::DeployStart, + &SourceValue::Invalid("bad".to_string()), + &SourceValue::Missing, + 123, + ); + assert_eq!(merged, MissionValue::Timestamp(Some(123))); + } +} diff --git a/services/hardware-services/fram-service/src/schema.rs b/services/hardware-services/fram-service/src/schema.rs index d20000f..3fe7c93 100644 --- a/services/hardware-services/fram-service/src/schema.rs +++ b/services/hardware-services/fram-service/src/schema.rs @@ -1,57 +1,152 @@ -use async_graphql::{Object, SimpleObject, Schema}; +use async_graphql::{Context, Object, Result, SimpleObject}; +use crate::model::{MissionFlagKey, MissionState, ReconcileResponse}; +use crate::subsystem::Subsystem; -struct Query; -struct Mutation; +pub struct QueryRoot; +pub struct MutationRoot; -#[derive(SimpleObject, Clone)] -struct Data{ - Component: enum - Status: bool - Confidence: u32 +#[derive(SimpleObject)] +pub struct HealthInfo { + pub backend: String, + pub capacity_bytes: i64, + pub fram_reachable: bool, + pub last_error: Option, } -#[derive(SimpleObject, clone)] -struct Error{ - Component: enum - failure: str +#[derive(SimpleObject)] +pub struct MutationResponse { + pub success: bool, + pub errors: String, } -enum Checks{ - SolarPanels, - antenna, +#[derive(SimpleObject)] +pub struct StateMutationResponse { + pub success: bool, + pub errors: String, + pub state: Option, } #[Object] -impl Query{ - - async fn GetStatus(&self, Part: Checks) -> Result{ - let Part_Address = getaddresses(Checks) - - Ok(Data { - Component: Checks, - Status: Part_Status - Confidence: - }) +impl QueryRoot { + async fn ping(&self) -> &str { + "pong" + } - Err(Error { - Component: Checks - failure: "Couldnt get address of Component" + async fn health(&self, ctx: &Context<'_>) -> Result { + let context = ctx.data::>()?; + let health = context.subsystem().health(); + Ok(HealthInfo { + backend: health.backend, + capacity_bytes: health.capacity_bytes as i64, + fram_reachable: health.fram_reachable, + last_error: health.last_error, }) + } - } + async fn mission_state( + &self, + ctx: &Context<'_>, + reconcile: Option, + ) -> Result { + let context = ctx.data::>()?; + context + .subsystem() + .mission_state(reconcile.unwrap_or(false)) + .map_err(async_graphql::Error::new) + } } - #[Object] -impl Mutation{ - - - async fn SetStatus(&self, Part: Checks){ - - +impl MutationRoot { + async fn reconcile_mission_state( + &self, + ctx: &Context<'_>, + dry_run: Option, + ) -> Result { + let context = ctx.data::>()?; + context + .subsystem() + .reconcile(dry_run.unwrap_or(false)) + .map_err(async_graphql::Error::new) } + async fn set_mission_flag( + &self, + ctx: &Context<'_>, + key: MissionFlagKey, + value: bool, + mirror_to_env: Option, + ) -> Result { + let context = ctx.data::>()?; + match context + .subsystem() + .set_flag(key, value, mirror_to_env.unwrap_or(true)) + { + Ok(state) => Ok(StateMutationResponse { + success: true, + errors: String::new(), + state: Some(state), + }), + Err(err) => Ok(StateMutationResponse { + success: false, + errors: err, + state: None, + }), + } + } + async fn set_deploy_start( + &self, + ctx: &Context<'_>, + timestamp: Option, + mirror_to_env: Option, + ) -> Result { + let context = ctx.data::>()?; + let timestamp = match timestamp { + Some(value) if value < 0 => { + return Ok(StateMutationResponse { + success: false, + errors: "timestamp must be >= 0".to_string(), + state: None, + }); + } + Some(value) => Some(value as u64), + None => None, + }; + + match context + .subsystem() + .set_deploy_start(timestamp, mirror_to_env.unwrap_or(true)) + { + Ok(state) => Ok(StateMutationResponse { + success: true, + errors: String::new(), + state: Some(state), + }), + Err(err) => Ok(StateMutationResponse { + success: false, + errors: err, + state: None, + }), + } + } - + async fn initialize_flight_state( + &self, + ctx: &Context<'_>, + confirm: bool, + ) -> Result { + let context = ctx.data::>()?; + match context.subsystem().initialize_flight_state(confirm) { + Ok(()) => Ok(MutationResponse { + success: true, + errors: String::new(), + }), + Err(err) => Ok(MutationResponse { + success: false, + errors: err, + }), + } + } +} diff --git a/services/hardware-services/fram-service/src/subsystem.rs b/services/hardware-services/fram-service/src/subsystem.rs new file mode 100644 index 0000000..f377945 --- /dev/null +++ b/services/hardware-services/fram-service/src/subsystem.rs @@ -0,0 +1,366 @@ +use std::sync::{Arc, Mutex}; + +use crate::backend::{ByteStorage, DEFAULT_CAPACITY_BYTES, FileImageBackend}; +use crate::env::{CommandEnvStore, EnvStore}; +use crate::layout::FramStorage; +use crate::model::{ + MISSION_KEYS, MissionFlagKey, MissionKey, MissionState, MissionValue, ReconcileResponse, + SyncAction, +}; +use crate::reconcile::{ + SourceValue, build_state, merge_value, now_timestamp, source_from_env, source_matches, +}; + +#[derive(Clone)] +pub struct Subsystem { + fram: Arc>, + env: Arc>>, + backend_name: String, + last_error: Arc>>, +} + +#[derive(Clone, Debug)] +pub struct Health { + pub backend: String, + pub capacity_bytes: u32, + pub fram_reachable: bool, + pub last_error: Option, +} + +impl Subsystem { + pub fn from_config(config: &kubos_service::Config) -> Result { + let backend = config + .get("backend") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "file".to_string()); + + let storage: Box = match backend.as_str() { + "file" => { + let path = config + .get("image_path") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "/tmp/fram-service.img".to_string()); + + let capacity = config + .get("image_capacity_bytes") + .and_then(|v| v.as_integer()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_CAPACITY_BYTES); + + Box::new(FileImageBackend::new(&path, capacity).map_err(|e| e.to_string())?) + } + "i2c" => { + #[cfg(feature = "i2c")] + { + let bus = config + .get("i2c_bus") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "/dev/i2c-1".to_string()); + let addr = config + .get("i2c_addr") + .and_then(|v| v.as_str().map(parse_hex_u8)) + .transpose()? + .unwrap_or(0x50); + let capacity = config + .get("capacity_bytes") + .and_then(|v| v.as_integer()) + .map(|v| v as u32) + .unwrap_or(DEFAULT_CAPACITY_BYTES); + let address_width = config + .get("address_width_bytes") + .and_then(|v| v.as_integer()) + .map(|v| v as u8) + .unwrap_or(2); + let max_transfer = config + .get("max_transfer_bytes") + .and_then(|v| v.as_integer()) + .map(|v| v as usize) + .unwrap_or(crate::backend::DEFAULT_MAX_TRANSFER_BYTES); + + Box::new( + crate::backend::I2cFramBackend::new( + &bus, + addr, + capacity, + address_width, + max_transfer, + ) + .map_err(|e| e.to_string())?, + ) + } + + #[cfg(not(feature = "i2c"))] + { + return Err( + "backend='i2c' requested but service was built without 'i2c' feature" + .to_string(), + ); + } + } + other => { + return Err(format!( + "unsupported backend '{other}'. expected 'file' or 'i2c'" + )); + } + }; + + let printenv = config + .get("fw_printenv") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "/usr/sbin/fw_printenv".to_string()); + let setenv = config + .get("fw_setenv") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "/usr/sbin/fw_setenv".to_string()); + + Self::from_parts( + backend, + storage, + Box::new(CommandEnvStore::new(printenv, setenv)), + ) + } + + pub fn from_parts( + backend_name: String, + storage: Box, + env: Box, + ) -> Result { + let fram = FramStorage::mount(storage).map_err(|err| err.to_string())?; + Ok(Self { + fram: Arc::new(Mutex::new(fram)), + env: Arc::new(Mutex::new(env)), + backend_name, + last_error: Arc::new(Mutex::new(None)), + }) + } + + pub fn health(&self) -> Health { + let capacity = self.capacity(); + let fram_reachable = self + .read_fram_source(MissionKey::RemoveBeforeFlight) + .is_ok(); + + Health { + backend: self.backend_name.clone(), + capacity_bytes: capacity, + fram_reachable, + last_error: self.last_error.lock().ok().and_then(|err| err.clone()), + } + } + + pub fn capacity(&self) -> u32 { + self.fram.lock().map(|fram| fram.capacity()).unwrap_or(0) + } + + pub fn mission_state(&self, reconcile: bool) -> Result { + if reconcile { + return Ok(self.reconcile(false)?.state); + } + + let mut values = Vec::new(); + for key in MISSION_KEYS { + let value = match self.read_fram_source(key)? { + SourceValue::Value(value) => value, + SourceValue::Missing | SourceValue::Invalid(_) | SourceValue::Unavailable(_) => { + key.default_value() + } + }; + values.push((key, value)); + } + + Ok(build_state(&values, Vec::new())) + } + + pub fn reconcile(&self, dry_run: bool) -> Result { + let now = now_timestamp(); + let mut actions = Vec::new(); + let mut values = Vec::new(); + let mut errors = Vec::new(); + + for key in MISSION_KEYS { + let fram_source = match self.read_fram_source(key) { + Ok(source) => source, + Err(err) => { + errors.push(format!("{} FRAM read: {err}", key.env_name())); + SourceValue::Unavailable(err) + } + }; + let env_source = match self.read_env_source(key) { + Ok(source) => source, + Err(err) => { + errors.push(format!("{} env read: {err}", key.env_name())); + SourceValue::Invalid(err) + } + }; + + let merged = merge_value(key, &fram_source, &env_source, now); + let mut action = Vec::new(); + let mut action_errors = Vec::new(); + + if matches!(fram_source, SourceValue::Unavailable(_)) { + action.push("fram_unavailable"); + } else if !source_matches(&fram_source, &merged) { + action.push("write_fram"); + if !dry_run { + if let Err(err) = self.write_fram_value(key, &merged) { + action_errors.push(err.clone()); + errors.push(format!("{} FRAM write: {err}", key.env_name())); + } + } + } + + if !source_matches(&env_source, &merged) { + action.push("write_env"); + if !dry_run { + if let Err(err) = self.write_env_value(key, &merged) { + action_errors.push(err.clone()); + errors.push(format!("{} env write: {err}", key.env_name())); + } + } + } + + actions.push(SyncAction { + key: key.env_name().to_string(), + fram_value: fram_source.display_value(), + env_value: env_source.display_value(), + merged_value: merged.display_value(), + action: if action.is_empty() { + "none".to_string() + } else if dry_run { + format!("dry_run:{}", action.join(",")) + } else { + action.join(",") + }, + errors: collect_source_errors(&fram_source, &env_source, &action_errors), + }); + values.push((key, merged)); + } + + let state = build_state(&values, actions.clone()); + Ok(ReconcileResponse { + success: errors.is_empty(), + errors: errors.join("; "), + actions, + state, + }) + } + + pub fn set_flag( + &self, + key: MissionFlagKey, + value: bool, + mirror_to_env: bool, + ) -> Result { + let key = MissionKey::from(key); + let value = MissionValue::Bool(value); + self.write_fram_value(key, &value)?; + if mirror_to_env { + self.write_env_value(key, &value)?; + } + + self.mission_state(false) + } + + pub fn set_deploy_start( + &self, + timestamp: Option, + mirror_to_env: bool, + ) -> Result { + let value = MissionValue::Timestamp(timestamp); + self.write_fram_value(MissionKey::DeployStart, &value)?; + if mirror_to_env { + self.write_env_value(MissionKey::DeployStart, &value)?; + } + + self.mission_state(false) + } + + pub fn initialize_flight_state(&self, confirm: bool) -> Result<(), String> { + if !confirm { + return Err("initializeFlightState is destructive: pass confirm=true".to_string()); + } + + let mut errors = Vec::new(); + for key in MISSION_KEYS { + let value = key.default_value(); + if let Err(err) = self.write_fram_value(key, &value) { + errors.push(format!("{} FRAM write: {err}", key.env_name())); + } + if let Err(err) = self.write_env_value(key, &value) { + errors.push(format!("{} env write: {err}", key.env_name())); + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } + + fn read_fram_source(&self, key: MissionKey) -> Result { + let mut fram = self.fram.lock().map_err(lock_error)?; + match fram.read_key(key) { + Ok(Some(value)) => Ok(SourceValue::Value(value)), + Ok(None) => Ok(SourceValue::Missing), + Err(err) => { + let err = err.to_string(); + self.set_last_error(err.clone()); + Err(err) + } + } + } + + fn read_env_source(&self, key: MissionKey) -> Result { + let mut env = self.env.lock().map_err(lock_error)?; + let raw = env.read(key.env_name())?; + Ok(source_from_env(key, raw)) + } + + fn write_fram_value(&self, key: MissionKey, value: &MissionValue) -> Result<(), String> { + let mut fram = self.fram.lock().map_err(lock_error)?; + fram.write_key(key, value).map_err(|err| { + let err = err.to_string(); + self.set_last_error(err.clone()); + err + }) + } + + fn write_env_value(&self, key: MissionKey, value: &MissionValue) -> Result<(), String> { + let mut env = self.env.lock().map_err(lock_error)?; + let env_value = value.to_env_value(); + env.write(key.env_name(), env_value.as_deref()) + } + + fn set_last_error(&self, err: String) { + if let Ok(mut last_error) = self.last_error.lock() { + *last_error = Some(err); + } + } +} + +fn collect_source_errors( + fram: &SourceValue, + env: &SourceValue, + action_errors: &[String], +) -> String { + let mut errors = Vec::new(); + if let Some(err) = fram.error() { + errors.push(format!("fram: {err}")); + } + if let Some(err) = env.error() { + errors.push(format!("env: {err}")); + } + errors.extend(action_errors.iter().cloned()); + errors.join("; ") +} + +fn lock_error(_: E) -> String { + "subsystem lock poisoned".to_string() +} + +#[cfg(feature = "i2c")] +fn parse_hex_u8(value: &str) -> Result { + let value = value.trim().strip_prefix("0x").unwrap_or(value.trim()); + u8::from_str_radix(value, 16).map_err(|err| err.to_string()) +} diff --git a/services/hardware-services/fram-service/tests/graphql.rs b/services/hardware-services/fram-service/tests/graphql.rs new file mode 100644 index 0000000..51c3e06 --- /dev/null +++ b/services/hardware-services/fram-service/tests/graphql.rs @@ -0,0 +1,156 @@ +use fram_service::backend::FileImageBackend; +use fram_service::env::MemoryEnvStore; +use fram_service::schema::{MutationRoot, QueryRoot}; +use fram_service::subsystem::Subsystem; +use futures::executor::block_on; +use kubos_service::Service; +use serde_json::Value; +use tempfile::TempDir; + +type FramService = Service; + +fn setup_service() -> (TempDir, FramService) { + let tmp = TempDir::new().expect("tempdir"); + let image_path = tmp.path().join("fram-test.img"); + + let config = kubos_service::Config::new_from_str( + "fram-service", + &format!( + r#" +[fram-service] +backend = "file" +image_path = "{}" +image_capacity_bytes = 8192 + +[fram-service.addr] +ip = "127.0.0.1" +port = 9998 +"#, + image_path.display() + ), + ) + .expect("config"); + + let backend = FileImageBackend::new(image_path.to_str().unwrap(), 8192).expect("backend"); + let subsystem = Subsystem::from_parts( + "file".to_string(), + Box::new(backend), + Box::new(MemoryEnvStore::default()), + ) + .expect("subsystem"); + let service = Service::new(config, subsystem, QueryRoot, MutationRoot); + + (tmp, service) +} + +fn graphql(service: &FramService, query: &str) -> Value { + let response = block_on(service.schema().execute(query)); + serde_json::to_value(response).expect("serialize") +} + +fn data(response: Value) -> Value { + if let Some(errors) = response.get("errors") { + panic!("graphql errors: {errors}"); + } + + response.get("data").cloned().expect("data") +} + +#[test] +fn ping_health_and_default_state() { + let (_tmp, service) = setup_service(); + + let data = data(graphql( + &service, + r#" + { + ping + health { + backend + capacityBytes + framReachable + } + missionState { + deployed + removeBeforeFlight + deployStart + } + } + "#, + )); + + assert_eq!(data["ping"], "pong"); + assert_eq!(data["health"]["backend"], "file"); + assert_eq!(data["health"]["capacityBytes"], 8192); + assert_eq!(data["health"]["framReachable"], true); + assert_eq!(data["missionState"]["deployed"], false); + assert_eq!(data["missionState"]["removeBeforeFlight"], false); + assert!(data["missionState"]["deployStart"].is_null()); +} + +#[test] +fn set_flag_and_deploy_start() { + let (_tmp, service) = setup_service(); + + let flag_data = data(graphql( + &service, + r#" + mutation { + setMissionFlag(key: UHF_ANTENNA_DEPLOYED, value: true, mirrorToEnv: false) { + success + errors + state { uhfAntennaDeployed } + } + } + "#, + )); + + assert_eq!(flag_data["setMissionFlag"]["success"], true); + assert_eq!( + flag_data["setMissionFlag"]["state"]["uhfAntennaDeployed"], + true + ); + + let deploy_start_data = data(graphql( + &service, + r#" + mutation { + setDeployStart(timestamp: 1770000000, mirrorToEnv: false) { + success + state { deployStart } + } + } + "#, + )); + + assert_eq!(deploy_start_data["setDeployStart"]["success"], true); + assert_eq!( + deploy_start_data["setDeployStart"]["state"]["deployStart"], + 1_770_000_000 + ); +} + +#[test] +fn initialize_requires_confirmation() { + let (_tmp, service) = setup_service(); + + let data = data(graphql( + &service, + r#" + mutation { + initializeFlightState(confirm: false) { + success + errors + } + } + "#, + )); + + assert_eq!(data["initializeFlightState"]["success"], false); + assert!( + data["initializeFlightState"]["errors"] + .as_str() + .unwrap() + .contains("confirm=true") + ); +} diff --git a/services/hardware-services/fram-service/tests/reconcile.rs b/services/hardware-services/fram-service/tests/reconcile.rs new file mode 100644 index 0000000..29a596d --- /dev/null +++ b/services/hardware-services/fram-service/tests/reconcile.rs @@ -0,0 +1,89 @@ +use fram_service::backend::FileImageBackend; +use fram_service::env::MemoryEnvStore; +use fram_service::model::{MissionKey, MissionValue}; +use fram_service::reconcile::{SourceValue, merge_value}; +use fram_service::subsystem::Subsystem; +use tempfile::TempDir; + +fn subsystem_with_env(values: Vec<(&str, &str)>) -> (TempDir, Subsystem) { + let tmp = TempDir::new().expect("tempdir"); + let path = tmp.path().join("fram.img"); + let backend = FileImageBackend::new(path.to_str().unwrap(), 8192).expect("backend"); + let env = MemoryEnvStore::new( + values + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())), + ); + let subsystem = Subsystem::from_parts("file".to_string(), Box::new(backend), Box::new(env)) + .expect("subsystem"); + (tmp, subsystem) +} + +#[test] +fn remove_before_flight_uses_or_policy() { + let merged = merge_value( + MissionKey::RemoveBeforeFlight, + &SourceValue::Value(MissionValue::Bool(false)), + &SourceValue::Value(MissionValue::Bool(true)), + 100, + ); + + assert_eq!(merged, MissionValue::Bool(true)); +} + +#[test] +fn completion_flags_require_no_explicit_false() { + let merged = merge_value( + MissionKey::SolarPanelDeployed, + &SourceValue::Value(MissionValue::Bool(true)), + &SourceValue::Value(MissionValue::Bool(false)), + 100, + ); + + assert_eq!(merged, MissionValue::Bool(false)); +} + +#[test] +fn deploy_start_uses_latest_valid_timestamp() { + let merged = merge_value( + MissionKey::DeployStart, + &SourceValue::Value(MissionValue::Timestamp(Some(100))), + &SourceValue::Value(MissionValue::Timestamp(Some(200))), + 300, + ); + + assert_eq!(merged, MissionValue::Timestamp(Some(200))); +} + +#[test] +fn live_reconcile_repairs_missing_fram_from_env() { + let (_tmp, subsystem) = subsystem_with_env(vec![("remove_before_flight", "true")]); + + let report = subsystem.reconcile(false).expect("reconcile"); + + assert!(report.success); + assert!(report.state.remove_before_flight); + let action = report + .actions + .iter() + .find(|action| action.key == "remove_before_flight") + .expect("action"); + assert!(action.action.contains("write_fram")); +} + +#[test] +fn dry_run_reports_repairs_without_changing_state() { + let (_tmp, subsystem) = subsystem_with_env(vec![("deployed", "true")]); + + let report = subsystem.reconcile(true).expect("reconcile"); + assert!(report.success); + assert!( + report + .actions + .iter() + .any(|action| action.key == "deployed" && action.action.contains("dry_run")) + ); + + let state = subsystem.mission_state(false).expect("state"); + assert!(!state.deployed); +} diff --git a/services/hardware-services/fram-service/tests/storage.rs b/services/hardware-services/fram-service/tests/storage.rs new file mode 100644 index 0000000..97a2048 --- /dev/null +++ b/services/hardware-services/fram-service/tests/storage.rs @@ -0,0 +1,90 @@ +use fram_service::backend::{ + ByteStorage, FileImageBackend, memory_address_bytes, validate_fm24cl64b_i2c_addr, +}; +use fram_service::layout::FramStorage; +use fram_service::model::{MissionKey, MissionValue}; +use tempfile::TempDir; + +fn storage() -> (TempDir, FramStorage) { + let tmp = TempDir::new().expect("tempdir"); + let path = tmp.path().join("fram.img"); + let backend = FileImageBackend::new(path.to_str().unwrap(), 8192).expect("backend"); + let storage = FramStorage::mount(Box::new(backend)).expect("mount"); + (tmp, storage) +} + +#[test] +fn file_backend_enforces_8192_byte_capacity() { + let tmp = TempDir::new().expect("tempdir"); + let path = tmp.path().join("fram.img"); + let mut backend = FileImageBackend::new(path.to_str().unwrap(), 8192).expect("backend"); + + assert_eq!(backend.capacity(), 8192); + backend.write(8191, &[0xAA]).expect("last byte write"); + let err = backend.write(8192, &[0xBB]).unwrap_err().to_string(); + assert!(err.contains("out of bounds")); +} + +#[test] +fn fm24cl64b_uses_linux_7_bit_i2c_address_range() { + validate_fm24cl64b_i2c_addr(0x50).expect("A2/A1/A0 all low"); + validate_fm24cl64b_i2c_addr(0x57).expect("A2/A1/A0 all high"); + + let err = validate_fm24cl64b_i2c_addr(0xA0).unwrap_err().to_string(); + assert!(err.contains("7-bit")); + assert!(err.contains("0xA0")); +} + +#[test] +fn fm24cl64b_memory_address_is_two_bytes_msb_first() { + assert_eq!(memory_address_bytes(0x0000, 2).unwrap(), [0x00, 0x00]); + assert_eq!(memory_address_bytes(0x0100, 2).unwrap(), [0x01, 0x00]); + assert_eq!(memory_address_bytes(0x1FFF, 2).unwrap(), [0x1F, 0xFF]); +} + +#[test] +fn fixed_slots_read_empty_as_missing() { + let (_tmp, mut storage) = storage(); + assert_eq!(storage.read_key(MissionKey::Deployed).unwrap(), None); +} + +#[test] +fn redundant_slots_keep_latest_sequence() { + let (_tmp, mut storage) = storage(); + + storage + .write_key(MissionKey::Deployed, &MissionValue::Bool(true)) + .expect("write true"); + storage + .write_key(MissionKey::Deployed, &MissionValue::Bool(false)) + .expect("write false"); + + assert_eq!( + storage.read_key(MissionKey::Deployed).unwrap(), + Some(MissionValue::Bool(false)) + ); +} + +#[test] +fn deploy_start_can_be_unset_or_timestamp() { + let (_tmp, mut storage) = storage(); + + storage + .write_key(MissionKey::DeployStart, &MissionValue::Timestamp(None)) + .expect("write unset"); + assert_eq!( + storage.read_key(MissionKey::DeployStart).unwrap(), + Some(MissionValue::Timestamp(None)) + ); + + storage + .write_key( + MissionKey::DeployStart, + &MissionValue::Timestamp(Some(1_770_000_000)), + ) + .expect("write timestamp"); + assert_eq!( + storage.read_key(MissionKey::DeployStart).unwrap(), + Some(MissionValue::Timestamp(Some(1_770_000_000))) + ); +} From b3cd2a5f404dcc9727123ab9a8ae2bef73a86a09 Mon Sep 17 00:00:00 2001 From: Oren Rotaru <64712576+OrenRotaru@users.noreply.github.com> Date: Wed, 6 May 2026 13:38:32 -0600 Subject: [PATCH 06/11] Update to use just set -e and propper error reading in the test --- .../fram-service/obc-tests/run.sh | 2 +- .../fram-service/obc-tests/src/main.rs | 31 ++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/services/hardware-services/fram-service/obc-tests/run.sh b/services/hardware-services/fram-service/obc-tests/run.sh index 91042c8..837e7d7 100755 --- a/services/hardware-services/fram-service/obc-tests/run.sh +++ b/services/hardware-services/fram-service/obc-tests/run.sh @@ -1,5 +1,5 @@ #!/bin/sh -set -eu +set -e DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) URL="${URL:-http://127.0.0.1:8091/graphql}" diff --git a/services/hardware-services/fram-service/obc-tests/src/main.rs b/services/hardware-services/fram-service/obc-tests/src/main.rs index 797915f..fb91024 100644 --- a/services/hardware-services/fram-service/obc-tests/src/main.rs +++ b/services/hardware-services/fram-service/obc-tests/src/main.rs @@ -375,8 +375,31 @@ fn graphql_mission_write_restore(url: &str) -> Result<(), String> { let original = query_detumbling(url)?; let test_value = !original; - set_detumbling(url, test_value)?; - let observed = query_detumbling(url)?; + if let Err(err) = set_detumbling(url, test_value) { + return match set_detumbling(url, original) { + Ok(()) => Err(format!( + "failed to write detumbling_complete={test_value}: {err}; original value restored" + )), + Err(restore_err) => Err(format!( + "failed to write detumbling_complete={test_value}: {err}; restore also failed: {restore_err}" + )), + }; + } + + let observed = match query_detumbling(url) { + Ok(value) => value, + Err(err) => { + return match set_detumbling(url, original) { + Ok(()) => Err(format!( + "failed to read detumbling_complete after test write: {err}; original value restored" + )), + Err(restore_err) => Err(format!( + "failed to read detumbling_complete after test write: {err}; restore also failed: {restore_err}" + )), + }; + } + }; + if observed != test_value { let restore_result = set_detumbling(url, original); return match restore_result { @@ -464,8 +487,8 @@ fn graphql(url: &str, query: &str) -> Result { if !headers.starts_with("HTTP/1.1 200") && !headers.starts_with("HTTP/1.0 200") { return Err(format!("non-200 response: {headers}\n{body}")); } - if body.contains("\"errors\"") { - return Err(format!("GraphQL returned errors: {body}")); + if body.contains("\"errors\":[") { + return Err(format!("GraphQL returned top-level errors: {body}")); } Ok(body.to_string()) From 2ffa49851266d417d0c4773f2870c81cbdd59c34 Mon Sep 17 00:00:00 2001 From: Oren Rotaru <64712576+OrenRotaru@users.noreply.github.com> Date: Wed, 6 May 2026 13:46:02 -0600 Subject: [PATCH 07/11] Updated all scripts to use set -e and added stripping for the binaries --- .../fram-service/obc-tests/README.md | 9 +++++++ .../fram-service/obc-tests/build.sh | 25 ++++++++++++++++++- .../fram-service/obc-tests/package.sh | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/services/hardware-services/fram-service/obc-tests/README.md b/services/hardware-services/fram-service/obc-tests/README.md index c2757a2..c0dcbc4 100644 --- a/services/hardware-services/fram-service/obc-tests/README.md +++ b/services/hardware-services/fram-service/obc-tests/README.md @@ -30,6 +30,15 @@ From the repository root: services/hardware-services/fram-service/obc-tests/build.sh ``` +The build script strips the release binaries by default using +`arm-linux-gnueabihf-strip` or `llvm-strip` if available. Override the strip tool +or keep symbols with: + +```sh +STRIP=/path/to/arm-linux-gnueabihf-strip services/hardware-services/fram-service/obc-tests/build.sh +NO_STRIP=1 services/hardware-services/fram-service/obc-tests/build.sh +``` + Equivalent explicit commands: ```sh diff --git a/services/hardware-services/fram-service/obc-tests/build.sh b/services/hardware-services/fram-service/obc-tests/build.sh index 5e56207..07a5812 100755 --- a/services/hardware-services/fram-service/obc-tests/build.sh +++ b/services/hardware-services/fram-service/obc-tests/build.sh @@ -1,10 +1,11 @@ #!/bin/sh -set -eu +set -e DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) ROOT=$(CDPATH= cd -- "$DIR/../../../.." && pwd) TARGET="${TARGET:-armv7-unknown-linux-gnueabihf}" CROSS="${CROSS:-cross}" +STRIP="${STRIP:-arm-linux-gnueabihf-strip}" cd "$ROOT" @@ -22,3 +23,25 @@ echo "Building fram-obc-tests for $TARGET" --release \ --features i2c,obc-tests \ --bin fram-obc-tests + +strip_binary() { + bin="$1" + + if [ "${NO_STRIP:-0}" = "1" ]; then + echo "Skipping strip for $bin because NO_STRIP=1" + return 0 + fi + + if command -v "$STRIP" >/dev/null 2>&1; then + echo "Stripping $bin with $STRIP" + "$STRIP" "$bin" + elif command -v llvm-strip >/dev/null 2>&1; then + echo "Stripping $bin with llvm-strip" + llvm-strip "$bin" + else + echo "WARNING: no strip tool found. Set STRIP=arm-linux-gnueabihf-strip or NO_STRIP=1." >&2 + fi +} + +strip_binary "$ROOT/target/$TARGET/release/fram-service" +strip_binary "$ROOT/target/$TARGET/release/fram-obc-tests" diff --git a/services/hardware-services/fram-service/obc-tests/package.sh b/services/hardware-services/fram-service/obc-tests/package.sh index 3f2b304..6a14dac 100755 --- a/services/hardware-services/fram-service/obc-tests/package.sh +++ b/services/hardware-services/fram-service/obc-tests/package.sh @@ -1,5 +1,5 @@ #!/bin/sh -set -eu +set -e DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) ROOT=$(CDPATH= cd -- "$DIR/../../../.." && pwd) From e4caa913fdbd7c6c416726a8744c83f9e27e1be8 Mon Sep 17 00:00:00 2001 From: Oren Rotaru <64712576+OrenRotaru@users.noreply.github.com> Date: Wed, 6 May 2026 14:31:02 -0600 Subject: [PATCH 08/11] Update to include writing to U-Boot environment variables --- .../fram-service/obc-tests/README.md | 19 ++ .../fram-service/obc-tests/run.sh | 8 + .../fram-service/obc-tests/src/main.rs | 196 +++++++++++++++++- .../hardware-services/fram-service/src/env.rs | 11 + .../fram-service/tests/env.rs | 84 ++++++++ .../fram-service/tests/reconcile.rs | 101 ++++++++- 6 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 services/hardware-services/fram-service/tests/env.rs diff --git a/services/hardware-services/fram-service/obc-tests/README.md b/services/hardware-services/fram-service/obc-tests/README.md index c0dcbc4..758cd51 100644 --- a/services/hardware-services/fram-service/obc-tests/README.md +++ b/services/hardware-services/fram-service/obc-tests/README.md @@ -142,6 +142,22 @@ That test toggles `detumbling_complete` with `mirrorToEnv: false`, verifies the readback, and restores the original value. Do not enable it during a real flight configuration unless you intend to touch that mission flag. +To test the U-Boot environment mirror as well: + +```sh +FRAM_TEST_ENV_WRITE=1 ./run.sh +``` + +That test toggles `detumbling_complete` with `mirrorToEnv: true`, verifies the +FRAM state through GraphQL, verifies the U-Boot env value through +`fw_printenv -n detumbling_complete`, then restores the original FRAM and env +values. It writes persistent U-Boot env, so keep it gated. + +If `fw_printenv` prints `Cannot open /envar/uboot.env`, the U-Boot env partition +or file is not mounted/present on the current image. Check `/etc/fw_env.config`, +`mount | grep /envar`, and `ls -l /envar/uboot.env` before running +`FRAM_TEST_ENV_WRITE=1`. + ## Useful Overrides ```sh @@ -151,6 +167,9 @@ TEST_BIN=/path/to/fram-obc-tests CONFIG=/path/to/fram-hw.toml SCRATCH_OFFSET=4096 FRAM_TEST_MISSION_WRITE=1 +FRAM_TEST_ENV_WRITE=1 +FRAM_TEST_FW_PRINTENV=/usr/sbin/fw_printenv +FRAM_TEST_FW_SETENV=/usr/sbin/fw_setenv FRAM_TEST_SCAN_ONLY=1 START_SERVICE=0 ``` diff --git a/services/hardware-services/fram-service/obc-tests/run.sh b/services/hardware-services/fram-service/obc-tests/run.sh index 837e7d7..3200e34 100755 --- a/services/hardware-services/fram-service/obc-tests/run.sh +++ b/services/hardware-services/fram-service/obc-tests/run.sh @@ -13,6 +13,9 @@ I2C_ADDR="${I2C_ADDR:-0x50}" SCRATCH_OFFSET="${SCRATCH_OFFSET:-4096}" SCAN_ONLY="${FRAM_TEST_SCAN_ONLY:-0}" MISSION_WRITE="${FRAM_TEST_MISSION_WRITE:-0}" +ENV_WRITE="${FRAM_TEST_ENV_WRITE:-0}" +FW_PRINTENV="${FRAM_TEST_FW_PRINTENV:-/usr/sbin/fw_printenv}" +FW_SETENV="${FRAM_TEST_FW_SETENV:-/usr/sbin/fw_setenv}" SERVICE_PID="" @@ -98,6 +101,11 @@ if [ "$MISSION_WRITE" = "1" ]; then else echo "Mission flag write/restore is disabled. Set FRAM_TEST_MISSION_WRITE=1 to enable it." fi +if [ "$ENV_WRITE" = "1" ]; then + TEST_ARGS="$TEST_ARGS --env-write --fw-printenv $FW_PRINTENV --fw-setenv $FW_SETENV" +else + echo "U-Boot env mirror write/restore is disabled. Set FRAM_TEST_ENV_WRITE=1 to enable it." +fi # shellcheck disable=SC2086 "$TEST_BIN" $TEST_ARGS diff --git a/services/hardware-services/fram-service/obc-tests/src/main.rs b/services/hardware-services/fram-service/obc-tests/src/main.rs index fb91024..5cb8c4e 100644 --- a/services/hardware-services/fram-service/obc-tests/src/main.rs +++ b/services/hardware-services/fram-service/obc-tests/src/main.rs @@ -1,7 +1,7 @@ use std::env; use std::io::{Read, Write}; use std::net::TcpStream; -use std::process; +use std::process::{self, Command}; use std::time::Duration; use fram_service::backend::{ByteStorage, I2cFramBackend}; @@ -28,6 +28,9 @@ struct Options { skip_graphql: bool, skip_direct: bool, mission_write: bool, + env_write: bool, + fw_printenv: String, + fw_setenv: String, } fn main() { @@ -79,6 +82,20 @@ fn main() { &mut failures, ); } + + if options.env_write { + run( + "GraphQL env mirror write/restore", + || { + graphql_env_write_restore( + &options.url, + &options.fw_printenv, + &options.fw_setenv, + ) + }, + &mut failures, + ); + } } finish(failures); @@ -153,6 +170,11 @@ impl Options { skip_graphql: env::var("FRAM_TEST_SKIP_GRAPHQL").ok().as_deref() == Some("1"), skip_direct: env::var("FRAM_TEST_SKIP_DIRECT").ok().as_deref() == Some("1"), mission_write: env::var("FRAM_TEST_MISSION_WRITE").ok().as_deref() == Some("1"), + env_write: env::var("FRAM_TEST_ENV_WRITE").ok().as_deref() == Some("1"), + fw_printenv: env::var("FRAM_TEST_FW_PRINTENV") + .unwrap_or_else(|_| "/usr/sbin/fw_printenv".to_string()), + fw_setenv: env::var("FRAM_TEST_FW_SETENV") + .unwrap_or_else(|_| "/usr/sbin/fw_setenv".to_string()), }; let mut args = env::args().skip(1); @@ -182,6 +204,9 @@ impl Options { "--skip-graphql" => options.skip_graphql = true, "--skip-direct" => options.skip_direct = true, "--mission-write" => options.mission_write = true, + "--env-write" => options.env_write = true, + "--fw-printenv" => options.fw_printenv = require_arg(&mut args, "--fw-printenv")?, + "--fw-setenv" => options.fw_setenv = require_arg(&mut args, "--fw-setenv")?, "--help" | "-h" => { print_usage(); process::exit(0); @@ -228,7 +253,10 @@ fn print_usage() { --scan-only Read-probe 0x50..0x57 and exit without writes\n\ --skip-graphql Do not call the GraphQL service\n\ --skip-direct Do not run direct I2C scratch test\n\ - --mission-write Toggle and restore detumbling_complete through GraphQL\n" + --mission-write Toggle and restore detumbling_complete in FRAM only\n\ + --env-write Toggle and restore detumbling_complete in FRAM and U-Boot env\n\ + --fw-printenv PATH fw_printenv path for --env-write\n\ + --fw-setenv PATH fw_setenv path for --env-write\n" ); } @@ -424,6 +452,87 @@ fn graphql_mission_write_restore(url: &str) -> Result<(), String> { Ok(()) } +fn graphql_env_write_restore(url: &str, fw_printenv: &str, fw_setenv: &str) -> Result<(), String> { + let original = query_detumbling(url)?; + let original_env = read_uboot_env(fw_printenv, "detumbling_complete")?; + let test_value = !original; + + if let Err(err) = set_detumbling_mirror(url, test_value, true) { + return match restore_detumbling( + url, + fw_printenv, + fw_setenv, + original, + original_env.as_deref(), + ) { + Ok(()) => Err(format!( + "failed to write mirrored detumbling_complete={test_value}: {err}; original values restored" + )), + Err(restore_err) => Err(format!( + "failed to write mirrored detumbling_complete={test_value}: {err}; restore also failed: {restore_err}" + )), + }; + } + + let observed_fram = match query_detumbling(url) { + Ok(value) => value, + Err(err) => { + return match restore_detumbling( + url, + fw_printenv, + fw_setenv, + original, + original_env.as_deref(), + ) { + Ok(()) => Err(format!( + "failed to read FRAM state after mirrored write: {err}; original values restored" + )), + Err(restore_err) => Err(format!( + "failed to read FRAM state after mirrored write: {err}; restore also failed: {restore_err}" + )), + }; + } + }; + + let observed_env = read_uboot_env(fw_printenv, "detumbling_complete")?; + let expected = test_value.to_string(); + + let restore_result = restore_detumbling( + url, + fw_printenv, + fw_setenv, + original, + original_env.as_deref(), + ); + + if observed_fram != test_value { + return match restore_result { + Ok(()) => Err(format!( + "FRAM detumbling_complete was {observed_fram}, expected {test_value}; original values restored" + )), + Err(err) => Err(format!( + "FRAM detumbling_complete was {observed_fram}, expected {test_value}; restore also failed: {err}" + )), + }; + } + + if observed_env.as_deref() != Some(expected.as_str()) { + return match restore_result { + Ok(()) => Err(format!( + "env detumbling_complete was {:?}, expected {expected}; original values restored", + observed_env + )), + Err(err) => Err(format!( + "env detumbling_complete was {:?}, expected {expected}; restore also failed: {err}", + observed_env + )), + }; + } + + restore_result?; + Ok(()) +} + fn query_detumbling(url: &str) -> Result { let body = graphql(url, "{ missionState { detumblingComplete } }")?; json_bool(&body, "detumblingComplete") @@ -431,8 +540,12 @@ fn query_detumbling(url: &str) -> Result { } fn set_detumbling(url: &str, value: bool) -> Result<(), String> { + set_detumbling_mirror(url, value, false) +} + +fn set_detumbling_mirror(url: &str, value: bool, mirror_to_env: bool) -> Result<(), String> { let query = format!( - "mutation {{ setMissionFlag(key: DETUMBLING_COMPLETE, value: {value}, mirrorToEnv: false) {{ success errors state {{ detumblingComplete }} }} }}" + "mutation {{ setMissionFlag(key: DETUMBLING_COMPLETE, value: {value}, mirrorToEnv: {mirror_to_env}) {{ success errors state {{ detumblingComplete }} }} }}" ); let body = graphql(url, &query)?; expect_contains(&body, "\"success\":true")?; @@ -447,6 +560,83 @@ fn set_detumbling(url: &str, value: bool) -> Result<(), String> { } } +fn restore_detumbling( + url: &str, + fw_printenv: &str, + fw_setenv: &str, + fram_value: bool, + env_value: Option<&str>, +) -> Result<(), String> { + set_detumbling_mirror(url, fram_value, false)?; + write_uboot_env(fw_setenv, "detumbling_complete", env_value)?; + + let observed_fram = query_detumbling(url)?; + if observed_fram != fram_value { + return Err(format!( + "FRAM restore readback was {observed_fram}, expected {fram_value}" + )); + } + + let observed_env = read_uboot_env(fw_printenv, "detumbling_complete")?; + if observed_env.as_deref() != env_value { + return Err(format!( + "env restore readback was {:?}, expected {:?}", + observed_env, env_value + )); + } + + Ok(()) +} + +fn read_uboot_env(fw_printenv: &str, key: &str) -> Result, String> { + let output = Command::new(fw_printenv) + .args(["-n", key]) + .output() + .map_err(|err| format!("failed to execute {fw_printenv}: {err}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if is_env_backend_error(&stderr) { + return Err(stderr); + } + return Ok(None); + } + + Ok(Some( + String::from_utf8_lossy(&output.stdout).trim().to_string(), + )) +} + +fn is_env_backend_error(stderr: &str) -> bool { + stderr.contains("Cannot open") + || stderr.contains("No such file or directory") + || stderr.contains("Permission denied") + || stderr.contains("Bad CRC") +} + +fn write_uboot_env(fw_setenv: &str, key: &str, value: Option<&str>) -> Result<(), String> { + let mut command = Command::new(fw_setenv); + command.arg(key); + if let Some(value) = value { + command.arg(value); + } + + let output = command + .output() + .map_err(|err| format!("failed to execute {fw_setenv}: {err}"))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { + format!("{fw_setenv} failed for {key}") + } else { + stderr + }) + } +} + fn graphql(url: &str, query: &str) -> Result { let endpoint = Endpoint::parse(url)?; let json = format!("{{\"query\":\"{}\"}}", json_escape(query)); diff --git a/services/hardware-services/fram-service/src/env.rs b/services/hardware-services/fram-service/src/env.rs index 2cfc2bb..ae5c91a 100644 --- a/services/hardware-services/fram-service/src/env.rs +++ b/services/hardware-services/fram-service/src/env.rs @@ -28,6 +28,10 @@ impl EnvStore for CommandEnvStore { .map_err(|err| format!("failed to execute {}: {err}", self.printenv_path))?; if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if is_env_backend_error(&stderr) { + return Err(stderr); + } return Ok(None); } @@ -60,6 +64,13 @@ impl EnvStore for CommandEnvStore { } } +fn is_env_backend_error(stderr: &str) -> bool { + stderr.contains("Cannot open") + || stderr.contains("No such file or directory") + || stderr.contains("Permission denied") + || stderr.contains("Bad CRC") +} + #[derive(Default)] pub struct MemoryEnvStore { values: HashMap, diff --git a/services/hardware-services/fram-service/tests/env.rs b/services/hardware-services/fram-service/tests/env.rs new file mode 100644 index 0000000..d0e3f81 --- /dev/null +++ b/services/hardware-services/fram-service/tests/env.rs @@ -0,0 +1,84 @@ +#![cfg(unix)] + +use fram_service::env::{CommandEnvStore, EnvStore}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use tempfile::TempDir; + +fn executable_script(dir: &TempDir, name: &str, body: &str) -> String { + let path = dir.path().join(name); + fs::write(&path, body).expect("write script"); + let mut perms = fs::metadata(&path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).expect("chmod"); + path.to_str().unwrap().to_string() +} + +#[test] +fn command_env_store_reads_with_fw_printenv_contract() { + let tmp = TempDir::new().expect("tempdir"); + let printenv = executable_script( + &tmp, + "fw_printenv", + r#"#!/bin/sh +if [ "$1" = "-n" ] && [ "$2" = "detumbling_complete" ]; then + printf true + exit 0 +fi +exit 1 +"#, + ); + + let mut store = CommandEnvStore::new(printenv, "/no/fw_setenv".to_string()); + + assert_eq!( + store.read("detumbling_complete").expect("read").as_deref(), + Some("true") + ); + assert_eq!(store.read("missing_key").expect("read"), None); +} + +#[test] +fn command_env_store_reports_missing_env_backend() { + let tmp = TempDir::new().expect("tempdir"); + let printenv = executable_script( + &tmp, + "fw_printenv", + r#"#!/bin/sh +echo 'Cannot open /envar/uboot.env: No such file or directory' >&2 +exit 1 +"#, + ); + + let mut store = CommandEnvStore::new(printenv, "/no/fw_setenv".to_string()); + let err = store.read("detumbling_complete").unwrap_err(); + + assert!(err.contains("Cannot open /envar/uboot.env")); +} + +#[test] +fn command_env_store_writes_with_fw_setenv_contract() { + let tmp = TempDir::new().expect("tempdir"); + let log = tmp.path().join("fw_setenv.log"); + let setenv = executable_script( + &tmp, + "fw_setenv", + &format!( + r#"#!/bin/sh +printf '%s\n' "$*" >> '{}' +"#, + log.display() + ), + ); + + let mut store = CommandEnvStore::new("/no/fw_printenv".to_string(), setenv); + + store + .write("detumbling_complete", Some("true")) + .expect("write bool"); + store.write("deploy_start", None).expect("unset timestamp"); + + let log = fs::read_to_string(log).expect("read log"); + assert!(log.contains("detumbling_complete true")); + assert!(log.contains("deploy_start")); +} diff --git a/services/hardware-services/fram-service/tests/reconcile.rs b/services/hardware-services/fram-service/tests/reconcile.rs index 29a596d..2afee55 100644 --- a/services/hardware-services/fram-service/tests/reconcile.rs +++ b/services/hardware-services/fram-service/tests/reconcile.rs @@ -1,8 +1,10 @@ use fram_service::backend::FileImageBackend; -use fram_service::env::MemoryEnvStore; -use fram_service::model::{MissionKey, MissionValue}; +use fram_service::env::{EnvStore, MemoryEnvStore}; +use fram_service::model::{MissionFlagKey, MissionKey, MissionValue}; use fram_service::reconcile::{SourceValue, merge_value}; use fram_service::subsystem::Subsystem; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use tempfile::TempDir; fn subsystem_with_env(values: Vec<(&str, &str)>) -> (TempDir, Subsystem) { @@ -19,6 +21,50 @@ fn subsystem_with_env(values: Vec<(&str, &str)>) -> (TempDir, Subsystem) { (tmp, subsystem) } +#[derive(Clone, Default)] +struct SharedEnvStore { + values: Arc>>, +} + +impl SharedEnvStore { + fn get(&self, key: &str) -> Option { + self.values.lock().unwrap().get(key).cloned() + } +} + +impl EnvStore for SharedEnvStore { + fn read(&mut self, name: &str) -> Result, String> { + Ok(self.values.lock().unwrap().get(name).cloned()) + } + + fn write(&mut self, name: &str, value: Option<&str>) -> Result<(), String> { + match value { + Some(value) => { + self.values + .lock() + .unwrap() + .insert(name.to_string(), value.to_string()); + } + None => { + self.values.lock().unwrap().remove(name); + } + } + + Ok(()) + } +} + +fn subsystem_with_shared_env() -> (TempDir, Subsystem, SharedEnvStore) { + let tmp = TempDir::new().expect("tempdir"); + let path = tmp.path().join("fram.img"); + let backend = FileImageBackend::new(path.to_str().unwrap(), 8192).expect("backend"); + let env = SharedEnvStore::default(); + let subsystem = + Subsystem::from_parts("file".to_string(), Box::new(backend), Box::new(env.clone())) + .expect("subsystem"); + (tmp, subsystem, env) +} + #[test] fn remove_before_flight_uses_or_policy() { let merged = merge_value( @@ -87,3 +133,54 @@ fn dry_run_reports_repairs_without_changing_state() { let state = subsystem.mission_state(false).expect("state"); assert!(!state.deployed); } + +#[test] +fn set_flag_with_mirror_writes_fram_and_env() { + let (_tmp, subsystem, env) = subsystem_with_shared_env(); + + let state = subsystem + .set_flag(MissionFlagKey::DetumblingComplete, true, true) + .expect("set flag"); + + assert!(state.detumbling_complete); + assert_eq!(env.get("detumbling_complete").as_deref(), Some("true")); + + let state = subsystem.mission_state(false).expect("state"); + assert!(state.detumbling_complete); +} + +#[test] +fn set_deploy_start_with_mirror_writes_fram_and_env() { + let (_tmp, subsystem, env) = subsystem_with_shared_env(); + + let state = subsystem + .set_deploy_start(Some(1_770_000_000), true) + .expect("set deploy start"); + + assert_eq!(state.deploy_start, Some(1_770_000_000)); + assert_eq!(env.get("deploy_start").as_deref(), Some("1770000000")); + + let state = subsystem.mission_state(false).expect("state"); + assert_eq!(state.deploy_start, Some(1_770_000_000)); +} + +#[test] +fn reconcile_repairs_missing_env_from_fram() { + let (_tmp, subsystem, env) = subsystem_with_shared_env(); + + subsystem + .set_flag(MissionFlagKey::InitialSafeStateComplete, true, false) + .expect("set fram only"); + + let report = subsystem.reconcile(false).expect("reconcile"); + + assert!(report.success); + assert!(report.state.initial_safe_state_complete); + assert_eq!( + env.get("initial_safe_state_complete").as_deref(), + Some("true") + ); + assert!(report.actions.iter().any(|action| { + action.key == "initial_safe_state_complete" && action.action.contains("write_env") + })); +} From 53a560474ff18b03746ef52aaefa3580df5e2ae1 Mon Sep 17 00:00:00 2001 From: Oren Rotaru Date: Sun, 10 May 2026 13:59:16 -0600 Subject: [PATCH 09/11] v1 for snn service --- Cargo.lock | 17 + Cargo.toml | 1 + .../payload-services/snn-service/Cargo.toml | 24 + .../payload-services/snn-service/README.md | 242 +++++++ .../payload-services/snn-service/config.toml | 29 + .../snn-service/src/driver.rs | 663 ++++++++++++++++++ .../payload-services/snn-service/src/error.rs | 53 ++ .../payload-services/snn-service/src/lib.rs | 5 + .../payload-services/snn-service/src/main.rs | 33 + .../snn-service/src/protocol.rs | 291 ++++++++ .../snn-service/src/schema.rs | 302 ++++++++ .../snn-service/src/subsystem.rs | 337 +++++++++ 12 files changed, 1997 insertions(+) create mode 100644 services/payload-services/snn-service/Cargo.toml create mode 100644 services/payload-services/snn-service/README.md create mode 100644 services/payload-services/snn-service/config.toml create mode 100644 services/payload-services/snn-service/src/driver.rs create mode 100644 services/payload-services/snn-service/src/error.rs create mode 100644 services/payload-services/snn-service/src/lib.rs create mode 100644 services/payload-services/snn-service/src/main.rs create mode 100644 services/payload-services/snn-service/src/protocol.rs create mode 100644 services/payload-services/snn-service/src/schema.rs create mode 100644 services/payload-services/snn-service/src/subsystem.rs diff --git a/Cargo.lock b/Cargo.lock index f35672a..39f5091 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5462,6 +5462,23 @@ dependencies = [ "version_check 0.9.5", ] +[[package]] +name = "snn-service" +version = "0.1.0" +dependencies = [ + "async-graphql", + "async-graphql-axum", + "base64 0.22.1", + "crc32fast", + "kubos-service", + "log 0.4.29", + "rust-uart", + "serde_json", + "serial", + "thiserror 2.0.18", + "tokio 1.49.0", +] + [[package]] name = "socket2" version = "0.5.10" diff --git a/Cargo.toml b/Cargo.toml index 2b79464..a170681 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ members = [ "kubos/test/integration/linux/isis-ants", "services/payload-services/star-risc", "services/payload-services/dosimeter", + "services/payload-services/snn-service", "services/hardware-services/mram-service", "services/hardware-services/fram-service", ] diff --git a/services/payload-services/snn-service/Cargo.toml b/services/payload-services/snn-service/Cargo.toml new file mode 100644 index 0000000..e47b913 --- /dev/null +++ b/services/payload-services/snn-service/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "snn-service" +edition = "2024" +version.workspace = true +description.workspace = true +documentation.workspace = true +repository.workspace = true +license.workspace = true + +[dependencies] +async-graphql = "7.0.17" +async-graphql-axum = "7.0.17" +base64 = "0.22.1" +crc32fast = "1.5" +log = "^0.4.0" +serial = "0.4.0" +thiserror = "2.0" +tokio = { version = "1.45.1", features = ["sync", "macros", "rt-multi-thread", "time"] } + +kubos-service = { path = "../../../kubos/services/kubos-service" } +rust-uart = { path = "../../../kubos/hal/rust-hal/rust-uart" } + +[dev-dependencies] +serde_json = "1.0" diff --git a/services/payload-services/snn-service/README.md b/services/payload-services/snn-service/README.md new file mode 100644 index 0000000..3cfbf76 --- /dev/null +++ b/services/payload-services/snn-service/README.md @@ -0,0 +1,242 @@ +# SNN Service + +GraphQL service that owns the UART link to the SNN payload board and runs the +multi-step inference protocol on its behalf. Mission applications submit a JPEG +and pull back a bitmap result without ever touching the wire. + +## Architecture + +- `protocol.rs` — line parser (`PayloadLine`), command serializers (`cmd_*`), CRC-32 helper. +- `driver.rs` — long-running OS thread that owns the `rust-uart` `Connection`. Drives the + protocol state machine end-to-end for one image at a time. Waits on a `Condvar` when + there's no work; updates `Arc>` so GraphQL queries can observe progress. +- `subsystem.rs` — cloneable handle that GraphQL handlers use. Owns the FIFO of pending + jobs and the LRU cache of completed results. `submit`, `cancel`, `infer`, `get_result`, + `inference_status`, `state`, `health`. +- `schema.rs` — `async-graphql` `QueryRoot` / `MutationRoot`. +- `error.rs` — `SnnError`, surfaced to GraphQL as the `error` field on each response. + +UART I/O is blocking, so the driver lives on its own `std::thread` rather than the tokio +runtime. The handlers and the driver communicate exclusively through `SharedState` +(a `std::sync::Mutex`) plus a `Condvar` to wake the driver when a job is queued or +when shutdown is signalled. + +## Wire protocol + +A single inference cycle, OBC ↔ payload: + +``` + payload OBC + │ │ + │ PAYLOAD_READY (boot, optional) │ + │ ───────────────────────────────► │ + │ │ STATUS + │ ◄─────────────────────────────── │ + │ IDLE │ + │ ───────────────────────────────► │ + │ │ SEND + │ ◄─────────────────────────────── │ + │ READY │ + │ ───────────────────────────────► │ + │ │ + │ ◄─────────────────────────────── │ + │ RX_OK │ + │ ───────────────────────────────► │ + │ │ + │ ┄┄┄ payload runs SNN ┄┄┄ │ + │ │ + │ RESULT_READY │ + │ ───────────────────────────────► │ + │ │ GET_RESULT_INFO + │ ◄─────────────────────────────── │ + │ RESULT_INFO READY │ + │ │ + │ ───────────────────────────────► │ + │ │ GET_RESULT + │ ◄─────────────────────────────── │ + │ RESULT_READY │ + │ │ + │ ───────────────────────────────► │ + │ │ READY + │ ◄─────────────────────────────── │ + │ │ + │ ───────────────────────────────► │ + │ │ RESULT_RX_OK + │ │ +``` + +Notes: + +- Lines are ASCII, terminated by `\n` (a trailing `\r` is tolerated). +- `id` is a `u32`, `size` is decimal bytes, `crc32` is uppercase hex (e.g. `6DE17C6C`). +- The CRC is IEEE/zlib (`crc32fast::Hasher`). The service computes CRC32 of the JPEG it + is about to send and verifies CRC32 of the bitmap it just received. +- `PROCESSING `, if the payload emits one between `RX_OK` and `RESULT_READY`, is + consumed and discarded. +- An `ERR ` line at any point aborts the cycle and surfaces as + `SnnError::PayloadNak`. The service stays `Idle` (the wire is back in sync after the + payload error) and the next submission proceeds normally. + +## Concurrency & queueing + +- The driver processes one image at a time — the payload itself is single-threaded. +- A configurable FIFO (`queue_capacity`, default `4`) holds *pending* jobs. Each slot + retains the JPEG in memory, so this is a real RAM cost; tune for the mission profile. + Submissions past the limit return `accepted: false` with `error: "queue full …"`. +- Completed results live in an LRU cache (`result_retention`, default `4`). After a + successful `getResult` a result is marked `DELIVERED`; LRU eviction happens regardless + of `DELIVERED` status. A mission app that does not fetch in time gets + `result for image N expired from cache`. +- `cancel(id)` only succeeds while the job is still `QUEUED`. From `SENDING_IMAGE` onward + the protocol cannot be aborted mid-cycle without desynchronising the wire. + +State is **ephemeral**. Restarting `snn-service` clears the queue, the in-flight job, and +the result cache. There is no persistence; mission apps must treat the service as +short-term result custody only. + +## Config + +`/etc/kubos-config.toml`: + +```toml +[snn-service] +uart_bus = "/dev/ttyUL2" +uart_baud = 115200 + +# Per-step UART timeouts (ms). `processing_timeout_ms` bounds the wait for the SNN +# inference to complete and is also reused as the bulk-bitmap read timeout. +read_line_timeout_ms = 2000 +ready_timeout_ms = 2000 +rx_ok_timeout_ms = 5000 +processing_timeout_ms = 60000 +result_info_timeout_ms = 2000 +result_header_timeout_ms = 2000 + +queue_capacity = 4 +result_retention = 4 +max_image_bytes = 4194304 # rejects oversize submissions before they hit the wire + +[snn-service.addr] +ip = "127.0.0.1" +port = 8092 +``` + +## GraphQL surface + +### Queries + +| Field | Returns | Notes | +| --- | --- | --- | +| `ping` | `String` | `"pong"` | +| `health` | `HealthInfo` | UART config, phase, queue depth, jobs completed/failed, last error | +| `state` | `SnnState` | Driver phase, current image id, queued image ids, last error | +| `inferenceStatus(imageId)` | `JobStatus?` | Per-job phase + queue position + error | +| `getResult(imageId)` | `ResultPayload` | Bitmap as base64, with size + CRC. Marks job `DELIVERED`. | + +`DriverPhase`: `INITIALIZING`, `IDLE`, `BUSY`, `SHUTTING_DOWN`, `FAULTED`. +`JobPhase`: `QUEUED`, `SENDING_IMAGE`, `PROCESSING`, `RESULT_READY`, `DELIVERED`, +`FAILED`, `CANCELLED`. + +### Mutations + +| Field | Returns | Notes | +| --- | --- | --- | +| `submitImage(imageBase64)` | `SubmitResponse` | Async path. Assigns image id, enqueues, returns immediately. | +| `infer(imageBase64)` | `InferenceResult` | Sync convenience: submit + poll-to-completion server-side. Holds the HTTP request open for the full cycle. | +| `cancel(imageId)` | `CancelResponse` | Best-effort. Only honoured while `QUEUED`. | + +The async path (`submitImage` → `inferenceStatus` → `getResult`) is the canonical one +and is more robust to transient HTTP/network hiccups during the multi-second processing +window. `infer` is provided for tests and one-shot mission apps that want a single +blocking call. + +## Example: a mission app inferring an image + +Async path (recommended): + +```graphql +mutation Submit($img: String!) { + submitImage(imageBase64: $img) { + success + accepted + imageId + queuePosition + error + } +} + +query Status($id: Int!) { + inferenceStatus(imageId: $id) { + phase + error + } +} + +query Fetch($id: Int!) { + getResult(imageId: $id) { + imageId + sizeBytes + crc32 + bitmapBase64 + } +} +``` + +Mission-app pseudocode: + +```rust +let id = client.submit_image(&base64_jpeg).await?; +loop { + match client.inference_status(id).await?.phase { + JobPhase::ResultReady | JobPhase::Delivered => break, + JobPhase::Failed | JobPhase::Cancelled => bail!("inference failed"), + _ => tokio::time::sleep(Duration::from_millis(500)).await, + } +} +let result = client.get_result(id).await?; +let bitmap = base64::decode(result.bitmap_base64)?; +``` + +Sync convenience: + +```graphql +mutation Infer($img: String!) { + infer(imageBase64: $img) { + success + imageId + sizeBytes + crc32 + bitmapBase64 + error + } +} +``` + +## Tests + +```sh +cargo test -p snn-service +``` + +The suite covers the line parser, every command formatter, the CRC32 against the +canonical `"123456789"` test vector, and three driver integration tests against a +`MockStream`: a happy-path full inference cycle, a CRC mismatch on the returned bitmap, +and a payload `ERR …` propagated as `SnnError::PayloadNak`. + +Hardware bring-up is not covered by the unit tests; verify against the real payload +board with a small mission-app harness exercising submit + poll + fetch and a known +JPEG/expected bitmap pair, plus a power-cycle of the payload mid-`PROCESSING` to confirm +the driver returns to `IDLE` cleanly on the next handshake. + +## Operational notes + +- The service is intended to run from the user partition and be restartable on orbit + (it has no Buildroot package). Restarting clears all state — mission apps that want + to survive a service restart should fetch results promptly rather than relying on the + retention window. +- A `FAULTED` driver phase indicates an unrecoverable UART error; the service does not + attempt to reopen the port automatically. Restart the service after diagnosing the + bus. +- `max_image_bytes` is a guardrail against accidentally submitting something larger + than the payload's expected input size — keep it in sync with the SNN's actual JPEG + buffer. diff --git a/services/payload-services/snn-service/config.toml b/services/payload-services/snn-service/config.toml new file mode 100644 index 0000000..5ffe77a --- /dev/null +++ b/services/payload-services/snn-service/config.toml @@ -0,0 +1,29 @@ +[snn-service] +# UART bus path the SNN payload board is wired to. +uart_bus = "/dev/ttyUL2" +uart_baud = 115200 + +# Per-step timeouts (milliseconds). Distinct from the wall-clock processing time +# which is bounded by `processing_timeout_ms` below. +read_line_timeout_ms = 2000 +ready_timeout_ms = 2000 +rx_ok_timeout_ms = 5000 +processing_timeout_ms = 60000 +result_info_timeout_ms = 2000 +result_header_timeout_ms = 2000 + +# Pending-job FIFO depth. Each slot holds an in-memory JPEG, so keep this small +# unless you understand the RAM cost. +queue_capacity = 4 + +# Maximum number of completed inference results retained in-memory (LRU). +# Mission apps must call getResult before eviction. +result_retention = 4 + +# Maximum accepted image size in bytes. Rejects oversize submissions before +# they hit the wire. +max_image_bytes = 4194304 + +[snn-service.addr] +ip = "127.0.0.1" +port = 8092 diff --git a/services/payload-services/snn-service/src/driver.rs b/services/payload-services/snn-service/src/driver.rs new file mode 100644 index 0000000..103abd0 --- /dev/null +++ b/services/payload-services/snn-service/src/driver.rs @@ -0,0 +1,663 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +use log::{error, info, warn}; +use rust_uart::{Connection, UartError}; +use serial::{BaudRate, CharSize, FlowControl, Parity, PortSettings, StopBits}; + +use crate::error::SnnError; +use crate::protocol::{self, PayloadLine}; + +pub const MAX_LINE_BYTES: usize = 256; + +/// Per-driver tunables (mirrored from `config.toml`). +#[derive(Clone, Debug)] +pub struct DriverConfig { + pub uart_bus: String, + pub uart_baud: u32, + pub read_line_timeout: Duration, + pub ready_timeout: Duration, + pub rx_ok_timeout: Duration, + pub processing_timeout: Duration, + pub result_info_timeout: Duration, + pub result_header_timeout: Duration, + pub queue_capacity: usize, + pub result_retention: usize, + pub max_image_bytes: usize, +} + +/// Overall driver lifecycle phase, surfaced via GraphQL `state` query. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DriverPhase { + /// Service has started but hasn't confirmed the payload is alive yet. + Initializing, + /// Payload reachable and idle; ready to accept work. + Idle, + /// Currently executing a job (`current_image_id` will be set). + Busy, + /// Driver is shutting down; no new work accepted. + ShuttingDown, + /// Hard fault; UART unreachable. Inspect `last_error` for details. + Faulted, +} + +impl DriverPhase { + pub fn as_str(&self) -> &'static str { + match self { + DriverPhase::Initializing => "INITIALIZING", + DriverPhase::Idle => "IDLE", + DriverPhase::Busy => "BUSY", + DriverPhase::ShuttingDown => "SHUTTING_DOWN", + DriverPhase::Faulted => "FAULTED", + } + } +} + +/// Per-job lifecycle phase, surfaced via GraphQL `inferenceStatus(id)`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum JobPhase { + Queued, + SendingImage, + Processing, + ResultReady, + Delivered, + Failed, + Cancelled, +} + +impl JobPhase { + pub fn as_str(&self) -> &'static str { + match self { + JobPhase::Queued => "QUEUED", + JobPhase::SendingImage => "SENDING_IMAGE", + JobPhase::Processing => "PROCESSING", + JobPhase::ResultReady => "RESULT_READY", + JobPhase::Delivered => "DELIVERED", + JobPhase::Failed => "FAILED", + JobPhase::Cancelled => "CANCELLED", + } + } +} + +#[derive(Clone, Debug)] +pub struct PendingJob { + pub image_id: u32, + pub image: Vec, + pub crc32: u32, +} + +#[derive(Clone, Debug)] +pub struct ResultEntry { + pub image_id: u32, + pub bitmap: Vec, + pub size: u32, + pub crc32: u32, + pub phase: JobPhase, + pub error: Option, +} + +#[derive(Clone, Debug)] +pub struct JobStatus { + pub image_id: u32, + pub phase: JobPhase, + pub queue_position: Option, + pub error: Option, +} + +/// Mutable state shared between the driver thread and GraphQL handlers. +/// Always behind `Arc>` paired with a `Condvar` for wake-ups. +pub struct SharedState { + pub phase: DriverPhase, + pub current_image_id: Option, + pub pending: VecDeque, + /// Per-job status, keyed by image id. Includes pending, in-flight, and recently finished. + pub jobs: HashMap, + pub results: HashMap, + /// Insertion order for LRU eviction of `results`. + pub result_lru: VecDeque, + pub last_error: Option, + pub jobs_completed: u64, + pub jobs_failed: u64, + pub next_image_id: u32, + pub shutdown: bool, +} + +impl Default for SharedState { + fn default() -> Self { + Self { + phase: DriverPhase::Initializing, + current_image_id: None, + pending: VecDeque::new(), + jobs: HashMap::new(), + results: HashMap::new(), + result_lru: VecDeque::new(), + last_error: None, + jobs_completed: 0, + jobs_failed: 0, + next_image_id: 1, + shutdown: false, + } + } +} + +impl SharedState { + pub fn new() -> Self { + Self::default() + } +} + +#[derive(Clone)] +pub struct DriverHandle { + pub state: Arc>, + pub cond: Arc, +} + +impl Default for DriverHandle { + fn default() -> Self { + Self { + state: Arc::new(Mutex::new(SharedState::new())), + cond: Arc::new(Condvar::new()), + } + } +} + +impl DriverHandle { + pub fn new() -> Self { + Self::default() + } + + pub fn signal_shutdown(&self) { + if let Ok(mut guard) = self.state.lock() { + guard.shutdown = true; + } + self.cond.notify_all(); + } +} + +/// Spawn the driver on a dedicated OS thread. UART I/O is blocking, so we deliberately +/// stay off the tokio runtime here. +pub fn spawn(config: DriverConfig, handle: DriverHandle) -> std::thread::JoinHandle<()> { + std::thread::Builder::new() + .name("snn-driver".to_string()) + .spawn(move || run(config, handle)) + .expect("failed to spawn snn driver thread") +} + +fn run(config: DriverConfig, handle: DriverHandle) { + info!("snn driver starting on {} @ {}bps", config.uart_bus, config.uart_baud); + + let connection = match open_uart(&config) { + Ok(c) => c, + Err(err) => { + error!("failed to open uart {}: {err}", config.uart_bus); + let mut guard = handle.state.lock().expect("state lock"); + guard.phase = DriverPhase::Faulted; + guard.last_error = Some(format!("uart open failed: {err}")); + return; + } + }; + + // Best-effort initial handshake: send STATUS, expect IDLE (or BUSY → wait briefly). + match initial_handshake(&connection, &config) { + Ok(()) => { + let mut guard = handle.state.lock().expect("state lock"); + guard.phase = DriverPhase::Idle; + } + Err(err) => { + warn!("initial handshake failed (continuing): {err}"); + let mut guard = handle.state.lock().expect("state lock"); + // Stay Initializing; first job submission will retry the handshake implicitly. + guard.last_error = Some(format!("handshake: {err}")); + } + } + + loop { + let job = { + let mut guard = handle.state.lock().expect("state lock"); + while guard.pending.is_empty() && !guard.shutdown { + guard = handle.cond.wait(guard).expect("cond wait"); + } + if guard.shutdown { + guard.phase = DriverPhase::ShuttingDown; + break; + } + // Mark job as in-flight. + let job = guard.pending.pop_front().expect("pending non-empty"); + guard.current_image_id = Some(job.image_id); + guard.phase = DriverPhase::Busy; + if let Some(status) = guard.jobs.get_mut(&job.image_id) { + status.phase = JobPhase::SendingImage; + status.queue_position = None; + } + recalc_queue_positions(&mut guard); + job + }; + + let result = execute_job(&connection, &config, &job, &handle); + + let mut guard = handle.state.lock().expect("state lock"); + guard.current_image_id = None; + guard.phase = DriverPhase::Idle; + match result { + Ok(entry) => { + guard.jobs_completed += 1; + if let Some(status) = guard.jobs.get_mut(&job.image_id) { + status.phase = JobPhase::ResultReady; + } + store_result(&mut guard, &config, entry); + } + Err(err) => { + guard.jobs_failed += 1; + let msg = err.to_string(); + guard.last_error = Some(msg.clone()); + if let Some(status) = guard.jobs.get_mut(&job.image_id) { + status.phase = JobPhase::Failed; + status.error = Some(msg); + } + // Unrecoverable wire errors push us to Faulted; logical NAKs leave us Idle. + if matches!(err, SnnError::Uart(_) | SnnError::Timeout(_)) { + guard.phase = DriverPhase::Faulted; + } + } + } + // Wake any infer-style waiters polling the shared state. + handle.cond.notify_all(); + } + + info!("snn driver shut down"); +} + +fn open_uart(config: &DriverConfig) -> Result { + Connection::from_path( + &config.uart_bus, + PortSettings { + baud_rate: BaudRate::from_speed(config.uart_baud as usize), + char_size: CharSize::Bits8, + parity: Parity::ParityNone, + stop_bits: StopBits::Stop1, + flow_control: FlowControl::FlowNone, + }, + config.read_line_timeout, + ) +} + +/// On startup, send STATUS once and look for IDLE. We tolerate a stray PAYLOAD_READY +/// preceding it (boot announcement) and a BUSY response (payload mid-cycle from before +/// we started — wait it out for one processing window). +fn initial_handshake(conn: &Connection, config: &DriverConfig) -> Result<(), SnnError> { + conn.write(&protocol::cmd_status())?; + let deadline = Instant::now() + config.processing_timeout; + loop { + let line = read_line(conn, config.read_line_timeout)?; + match PayloadLine::parse(&line) { + PayloadLine::Idle => return Ok(()), + PayloadLine::PayloadReady => continue, + PayloadLine::Busy { .. } | PayloadLine::Processing { .. } => { + if Instant::now() >= deadline { + return Err(SnnError::NotIdle("BUSY".to_string())); + } + std::thread::sleep(Duration::from_millis(500)); + conn.write(&protocol::cmd_status())?; + } + PayloadLine::ResultReadyNotify { .. } => { + // Stale completion from a previous run we don't know about. + // Drain it by reading whatever the payload offers next. + continue; + } + other => return Err(protocol::unexpected_line("IDLE", &other)), + } + } +} + +/// Run the full multi-step protocol for one image. Updates `JobPhase` along the way. +pub(crate) fn execute_job( + conn: &Connection, + config: &DriverConfig, + job: &PendingJob, + handle: &DriverHandle, +) -> Result { + let id = job.image_id; + + // Send SEND header, expect READY. + conn.write(&protocol::cmd_send(id, job.image.len() as u32, job.crc32))?; + expect_line(conn, config.ready_timeout, |line| { + matches!(line, PayloadLine::Ready) + }, "READY (after SEND)")?; + + // Stream raw image bytes. + conn.write(&job.image)?; + + // Expect RX_OK . + expect_line(conn, config.rx_ok_timeout, |line| { + matches!(line, PayloadLine::RxOk { image_id } if *image_id == id) + }, "RX_OK ")?; + + set_job_phase(handle, id, JobPhase::Processing); + + // Wait for RESULT_READY notify form. `expect_line` discards intermediate lines + // (e.g. an informational `PROCESSING `) and only returns once the predicate matches. + expect_line( + conn, + config.processing_timeout, + |line| matches!(line, PayloadLine::ResultReadyNotify { image_id } if *image_id == id), + "RESULT_READY (notify)", + )?; + + // GET_RESULT_INFO -> RESULT_INFO READY + conn.write(&protocol::cmd_get_result_info(id))?; + let (info_size, info_crc) = match expect_line( + conn, + config.result_info_timeout, + |line| matches!(line, PayloadLine::ResultInfo { image_id, .. } if *image_id == id), + "RESULT_INFO ...", + )? { + PayloadLine::ResultInfo { + phase, size, crc32, .. + } => { + if phase != "READY" { + return Err(SnnError::Protocol(format!( + "RESULT_INFO phase != READY (got {phase})" + ))); + } + (size, crc32) + } + other => return Err(protocol::unexpected_line("RESULT_INFO", &other)), + }; + + // GET_RESULT -> RESULT_READY + conn.write(&protocol::cmd_get_result(id))?; + let (hdr_size, hdr_crc) = match expect_line( + conn, + config.result_header_timeout, + |line| matches!(line, PayloadLine::ResultHeader { image_id, .. } if *image_id == id), + "RESULT_READY ", + )? { + PayloadLine::ResultHeader { size, crc32, .. } => (size, crc32), + other => return Err(protocol::unexpected_line("RESULT_READY
", &other)), + }; + + if hdr_size != info_size || hdr_crc != info_crc { + return Err(SnnError::Protocol(format!( + "RESULT_INFO/RESULT_READY mismatch: info=({info_size}, {info_crc:08X}) hdr=({hdr_size}, {hdr_crc:08X})" + ))); + } + + // OBC sends READY, then payload streams the bitmap bytes. + conn.write(&protocol::cmd_ready())?; + let bitmap = conn.read(hdr_size as usize, config.processing_timeout)?; + + let actual_crc = protocol::crc32(&bitmap); + if actual_crc != hdr_crc { + return Err(SnnError::CrcMismatch { + expected: hdr_crc, + actual: actual_crc, + }); + } + + // Acknowledge. + conn.write(&protocol::cmd_result_rx_ok(id))?; + + Ok(ResultEntry { + image_id: id, + bitmap, + size: hdr_size, + crc32: hdr_crc, + phase: JobPhase::ResultReady, + error: None, + }) +} + +/// Read a single line (terminated by '\n'). Strips trailing '\r'. Bounded by `MAX_LINE_BYTES` +/// to prevent runaway reads when the wire is producing junk. +fn read_line(conn: &Connection, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let mut buf = Vec::with_capacity(64); + loop { + if Instant::now() >= deadline { + return Err(SnnError::Timeout("line")); + } + if buf.len() >= MAX_LINE_BYTES { + return Err(SnnError::Protocol(format!( + "line exceeded {MAX_LINE_BYTES} bytes without newline" + ))); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + let chunk = conn.read(1, remaining.max(Duration::from_millis(1)))?; + let byte = chunk[0]; + if byte == b'\n' { + // Strip optional trailing '\r'. + if buf.last().copied() == Some(b'\r') { + buf.pop(); + } + return Ok(String::from_utf8_lossy(&buf).into_owned()); + } + buf.push(byte); + } +} + +/// Read lines until one matches `pred`. Returns the matching parsed line. +/// Unknown / unrelated lines are logged and discarded. +fn expect_line( + conn: &Connection, + timeout: Duration, + pred: impl Fn(&PayloadLine) -> bool, + context: &'static str, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(SnnError::Timeout(context)); + } + let raw = read_line(conn, remaining)?; + let line = PayloadLine::parse(&raw); + if pred(&line) { + return Ok(line); + } + if let PayloadLine::Error { .. } = &line { + return Err(protocol::unexpected_line(context, &line)); + } + warn!("snn: discarding unexpected line while waiting for {context}: {raw:?}"); + } +} + +fn set_job_phase(handle: &DriverHandle, image_id: u32, phase: JobPhase) { + if let Ok(mut guard) = handle.state.lock() + && let Some(status) = guard.jobs.get_mut(&image_id) + { + status.phase = phase; + } +} + +fn recalc_queue_positions(state: &mut SharedState) { + for (idx, job) in state.pending.iter().enumerate() { + if let Some(status) = state.jobs.get_mut(&job.image_id) { + status.queue_position = Some(idx); + status.phase = JobPhase::Queued; + } + } +} + +fn store_result(state: &mut SharedState, config: &DriverConfig, entry: ResultEntry) { + let id = entry.image_id; + state.results.insert(id, entry); + state.result_lru.push_back(id); + while state.result_lru.len() > config.result_retention { + if let Some(evicted) = state.result_lru.pop_front() { + state.results.remove(&evicted); + // Drop job-status entries for evicted results too, so memory stays bounded. + if let Some(status) = state.jobs.get(&evicted) + && matches!( + status.phase, + JobPhase::ResultReady + | JobPhase::Delivered + | JobPhase::Failed + | JobPhase::Cancelled + ) + { + state.jobs.remove(&evicted); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_uart::mock::MockStream; + + fn test_config() -> DriverConfig { + DriverConfig { + uart_bus: "mock".to_string(), + uart_baud: 115200, + read_line_timeout: Duration::from_secs(1), + ready_timeout: Duration::from_secs(1), + rx_ok_timeout: Duration::from_secs(1), + processing_timeout: Duration::from_secs(2), + result_info_timeout: Duration::from_secs(1), + result_header_timeout: Duration::from_secs(1), + queue_capacity: 4, + result_retention: 4, + max_image_bytes: 1024 * 1024, + } + } + + /// Build a Connection wrapping a MockStream pre-loaded with `output` and accepting + /// any writes (no input verification — the protocol output is what we care about). + fn mock_connection(output: Vec) -> Connection { + let mut mock = MockStream::default(); + mock.write.set_result(Ok(())); + mock.read.set_output(output); + Connection::new(Box::new(mock)) + } + + #[test] + fn read_line_strips_trailing_cr() { + let conn = mock_connection(b"PAYLOAD_READY\r\n".to_vec()); + let line = read_line(&conn, Duration::from_secs(1)).unwrap(); + assert_eq!(line, "PAYLOAD_READY"); + } + + #[test] + fn read_line_handles_bare_lf() { + let conn = mock_connection(b"IDLE\nREADY\n".to_vec()); + assert_eq!(read_line(&conn, Duration::from_secs(1)).unwrap(), "IDLE"); + assert_eq!(read_line(&conn, Duration::from_secs(1)).unwrap(), "READY"); + } + + #[test] + fn execute_job_happy_path() { + let image = b"fake-jpeg-bytes".to_vec(); + let bitmap = vec![0xAA; 32]; + let bitmap_crc = protocol::crc32(&bitmap); + + // Prebake the payload's side of the conversation. This must match exactly the + // sequence `execute_job` reads. + let mut wire = Vec::new(); + // After SEND header → READY + wire.extend_from_slice(b"READY\n"); + // After image bytes → RX_OK + wire.extend_from_slice(b"RX_OK 42\n"); + // PROCESSING + RESULT_READY notify + wire.extend_from_slice(b"PROCESSING 42\n"); + wire.extend_from_slice(b"RESULT_READY 42\n"); + // GET_RESULT_INFO → RESULT_INFO line + wire.extend_from_slice(format!("RESULT_INFO 42 READY {} {:08X}\n", bitmap.len(), bitmap_crc).as_bytes()); + // GET_RESULT → RESULT_READY header line + wire.extend_from_slice(format!("RESULT_READY 42 {} {:08X}\n", bitmap.len(), bitmap_crc).as_bytes()); + // bitmap blob + wire.extend_from_slice(&bitmap); + + let conn = mock_connection(wire); + let handle = DriverHandle::new(); + // Seed a job-status entry so set_job_phase has something to update. + handle.state.lock().unwrap().jobs.insert( + 42, + JobStatus { + image_id: 42, + phase: JobPhase::SendingImage, + queue_position: None, + error: None, + }, + ); + + let job = PendingJob { + image_id: 42, + image, + crc32: 0xDEADBEEF, + }; + let entry = execute_job(&conn, &test_config(), &job, &handle).expect("happy path"); + assert_eq!(entry.image_id, 42); + assert_eq!(entry.size, bitmap.len() as u32); + assert_eq!(entry.crc32, bitmap_crc); + assert_eq!(entry.bitmap, bitmap); + } + + #[test] + fn execute_job_detects_crc_mismatch() { + let image = b"fake-jpeg-bytes".to_vec(); + let bitmap = vec![0xBB; 16]; + // Lie about the CRC in the header line. + let bad_crc: u32 = 0x00000000; + + let mut wire = Vec::new(); + wire.extend_from_slice(b"READY\n"); + wire.extend_from_slice(b"RX_OK 7\n"); + wire.extend_from_slice(b"RESULT_READY 7\n"); + wire.extend_from_slice(format!("RESULT_INFO 7 READY {} {:08X}\n", bitmap.len(), bad_crc).as_bytes()); + wire.extend_from_slice(format!("RESULT_READY 7 {} {:08X}\n", bitmap.len(), bad_crc).as_bytes()); + wire.extend_from_slice(&bitmap); + + let conn = mock_connection(wire); + let handle = DriverHandle::new(); + handle.state.lock().unwrap().jobs.insert( + 7, + JobStatus { + image_id: 7, + phase: JobPhase::SendingImage, + queue_position: None, + error: None, + }, + ); + + let job = PendingJob { + image_id: 7, + image, + crc32: 0xDEADBEEF, + }; + let err = execute_job(&conn, &test_config(), &job, &handle).expect_err("should fail"); + match err { + SnnError::CrcMismatch { expected, actual } => { + assert_eq!(expected, bad_crc); + assert_eq!(actual, protocol::crc32(&bitmap)); + } + other => panic!("expected CrcMismatch, got {other:?}"), + } + } + + #[test] + fn execute_job_propagates_payload_error() { + // Payload NAKs the SEND header. + let wire = b"ERR BAD_SIZE image too big\n".to_vec(); + let conn = mock_connection(wire); + let handle = DriverHandle::new(); + handle.state.lock().unwrap().jobs.insert( + 1, + JobStatus { + image_id: 1, + phase: JobPhase::SendingImage, + queue_position: None, + error: None, + }, + ); + + let job = PendingJob { + image_id: 1, + image: vec![0; 8], + crc32: 0, + }; + let err = execute_job(&conn, &test_config(), &job, &handle).expect_err("should fail"); + assert!(matches!(err, SnnError::PayloadNak(_)), "got {err:?}"); + } +} diff --git a/services/payload-services/snn-service/src/error.rs b/services/payload-services/snn-service/src/error.rs new file mode 100644 index 0000000..c1983c2 --- /dev/null +++ b/services/payload-services/snn-service/src/error.rs @@ -0,0 +1,53 @@ +use rust_uart::UartError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SnnError { + #[error("uart: {0}")] + Uart(#[from] UartError), + + #[error("payload protocol: {0}")] + Protocol(String), + + #[error("payload reported failure: {0}")] + PayloadNak(String), + + #[error("crc mismatch: expected {expected:08X}, got {actual:08X}")] + CrcMismatch { expected: u32, actual: u32 }, + + #[error("timed out waiting for {0}")] + Timeout(&'static str), + + #[error("payload not idle (current state: {0})")] + NotIdle(String), + + #[error("queue full (capacity {0})")] + QueueFull(usize), + + #[error("image too large: {size} bytes exceeds limit {limit}")] + ImageTooLarge { size: usize, limit: usize }, + + #[error("unknown image id {0}")] + UnknownImageId(u32), + + #[error("result for image {0} not ready (phase: {1})")] + ResultNotReady(u32, String), + + #[error("result for image {0} expired from cache")] + ResultExpired(u32), + + #[error("invalid base64: {0}")] + InvalidBase64(String), + + #[error("driver shut down")] + DriverGone, + + #[error("internal: {0}")] + Internal(String), +} + +impl From for SnnError { + fn from(value: base64::DecodeError) -> Self { + SnnError::InvalidBase64(value.to_string()) + } +} diff --git a/services/payload-services/snn-service/src/lib.rs b/services/payload-services/snn-service/src/lib.rs new file mode 100644 index 0000000..2c5b350 --- /dev/null +++ b/services/payload-services/snn-service/src/lib.rs @@ -0,0 +1,5 @@ +pub mod driver; +pub mod error; +pub mod protocol; +pub mod schema; +pub mod subsystem; diff --git a/services/payload-services/snn-service/src/main.rs b/services/payload-services/snn-service/src/main.rs new file mode 100644 index 0000000..ccad8d1 --- /dev/null +++ b/services/payload-services/snn-service/src/main.rs @@ -0,0 +1,33 @@ +use kubos_service::{Config, Logger, Service}; + +use snn_service::schema::{MutationRoot, QueryRoot}; +use snn_service::subsystem::Subsystem; + +#[tokio::main] +async fn main() { + if let Err(err) = Logger::init("snn-service") { + eprintln!("failed to initialize logger: {err:?}"); + std::process::exit(1); + } + + let config = match Config::new("snn-service") { + Ok(config) => config, + Err(err) => { + log::error!("failed to load service config: {err:?}"); + eprintln!("failed to load service config: {err}"); + std::process::exit(2); + } + }; + + let subsystem = match Subsystem::from_config(&config) { + Ok(s) => s, + Err(err) => { + log::error!("failed to initialize SNN subsystem: {err}"); + eprintln!("failed to initialize SNN subsystem: {err}"); + std::process::exit(3); + } + }; + + log::info!("snn-service started"); + Service::new(config, subsystem, QueryRoot, MutationRoot).start_async().await; +} diff --git a/services/payload-services/snn-service/src/protocol.rs b/services/payload-services/snn-service/src/protocol.rs new file mode 100644 index 0000000..f62d0aa --- /dev/null +++ b/services/payload-services/snn-service/src/protocol.rs @@ -0,0 +1,291 @@ +use crate::error::SnnError; + +/// Parsed lines we expect from the payload board. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PayloadLine { + /// Sent unsolicited at boot. + PayloadReady, + /// Status query response: payload is currently idle. + Idle, + /// Status query response: payload is processing image `id`. + Busy { phase: String, image_id: Option }, + /// Payload is ready to accept the next byte stream (image bytes or our READY ack). + Ready, + /// Image `id` was received successfully. + RxOk { image_id: u32 }, + /// Payload acknowledges and starts processing. + Processing { image_id: u32 }, + /// Inference completed for image `id` (notification form, no size/crc). + ResultReadyNotify { image_id: u32 }, + /// Response to `GET_RESULT_INFO id`. Phase is e.g. "READY". + ResultInfo { + image_id: u32, + phase: String, + size: u32, + crc32: u32, + }, + /// Response to `GET_RESULT id` immediately before the bitmap blob. + ResultHeader { + image_id: u32, + size: u32, + crc32: u32, + }, + /// Payload reports an error: e.g. `ERR `. + Error { code: String, message: String }, + /// Anything we don't recognise. Kept for diagnostics. + Unknown(String), +} + +impl PayloadLine { + /// Parse a single line (without trailing newline) into a structured response. + pub fn parse(line: &str) -> PayloadLine { + let line = line.trim(); + if line.is_empty() { + return PayloadLine::Unknown(String::new()); + } + + let mut parts = line.split_ascii_whitespace(); + let head = match parts.next() { + Some(h) => h, + None => return PayloadLine::Unknown(line.to_string()), + }; + let rest: Vec<&str> = parts.collect(); + + match head { + "PAYLOAD_READY" => PayloadLine::PayloadReady, + "IDLE" => PayloadLine::Idle, + "READY" => PayloadLine::Ready, + "RX_OK" => match rest.first().and_then(|s| parse_u32(s)) { + Some(id) => PayloadLine::RxOk { image_id: id }, + None => PayloadLine::Unknown(line.to_string()), + }, + "PROCESSING" => match rest.first().and_then(|s| parse_u32(s)) { + Some(id) => PayloadLine::Processing { image_id: id }, + None => PayloadLine::Unknown(line.to_string()), + }, + "RESULT_READY" => parse_result_ready(&rest, line), + "RESULT_INFO" => parse_result_info(&rest, line), + "BUSY" => PayloadLine::Busy { + phase: rest.first().map(|s| s.to_string()).unwrap_or_default(), + image_id: rest.get(1).and_then(|s| parse_u32(s)), + }, + "ERR" | "ERROR" => PayloadLine::Error { + code: rest.first().map(|s| s.to_string()).unwrap_or_default(), + message: rest.get(1..).map(|t| t.join(" ")).unwrap_or_default(), + }, + _ => PayloadLine::Unknown(line.to_string()), + } + } +} + +/// `RESULT_READY` overload: +/// * `RESULT_READY ` — completion notification +/// * `RESULT_READY ` — header before bitmap blob +fn parse_result_ready(rest: &[&str], line: &str) -> PayloadLine { + match rest.len() { + 1 => match parse_u32(rest[0]) { + Some(id) => PayloadLine::ResultReadyNotify { image_id: id }, + None => PayloadLine::Unknown(line.to_string()), + }, + 3 => match (parse_u32(rest[0]), parse_u32(rest[1]), parse_hex32(rest[2])) { + (Some(id), Some(size), Some(crc)) => PayloadLine::ResultHeader { + image_id: id, + size, + crc32: crc, + }, + _ => PayloadLine::Unknown(line.to_string()), + }, + _ => PayloadLine::Unknown(line.to_string()), + } +} + +/// `RESULT_INFO ` +fn parse_result_info(rest: &[&str], line: &str) -> PayloadLine { + if rest.len() != 4 { + return PayloadLine::Unknown(line.to_string()); + } + match ( + parse_u32(rest[0]), + parse_u32(rest[2]), + parse_hex32(rest[3]), + ) { + (Some(id), Some(size), Some(crc)) => PayloadLine::ResultInfo { + image_id: id, + phase: rest[1].to_string(), + size, + crc32: crc, + }, + _ => PayloadLine::Unknown(line.to_string()), + } +} + +fn parse_u32(s: &str) -> Option { + s.parse::().ok() +} + +fn parse_hex32(s: &str) -> Option { + let stripped = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s); + u32::from_str_radix(stripped, 16).ok() +} + +/// Render an OBC-to-payload command as a single ASCII line, terminated by `\n`. +pub fn cmd_status() -> Vec { + b"STATUS\n".to_vec() +} + +pub fn cmd_send(image_id: u32, size: u32, crc32: u32) -> Vec { + format!("SEND {image_id} {size} {crc32:08X}\n").into_bytes() +} + +pub fn cmd_get_result_info(image_id: u32) -> Vec { + format!("GET_RESULT_INFO {image_id}\n").into_bytes() +} + +pub fn cmd_get_result(image_id: u32) -> Vec { + format!("GET_RESULT {image_id}\n").into_bytes() +} + +pub fn cmd_ready() -> Vec { + b"READY\n".to_vec() +} + +pub fn cmd_result_rx_ok(image_id: u32) -> Vec { + format!("RESULT_RX_OK {image_id}\n").into_bytes() +} + +/// CRC-32 (IEEE) of a byte slice — same algorithm the payload uses. +pub fn crc32(data: &[u8]) -> u32 { + let mut hasher = crc32fast::Hasher::new(); + hasher.update(data); + hasher.finalize() +} + +/// Convert a parsed `PayloadLine::Error` (or any unexpected line) into an `SnnError`. +pub fn unexpected_line(context: &str, line: &PayloadLine) -> SnnError { + match line { + PayloadLine::Error { code, message } => { + SnnError::PayloadNak(format!("[{code}] {message}").trim().to_string()) + } + other => SnnError::Protocol(format!("expected {context}, got {other:?}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_payload_ready() { + assert_eq!(PayloadLine::parse("PAYLOAD_READY"), PayloadLine::PayloadReady); + assert_eq!(PayloadLine::parse("PAYLOAD_READY\r"), PayloadLine::PayloadReady); + } + + #[test] + fn parses_idle_and_ready() { + assert_eq!(PayloadLine::parse("IDLE"), PayloadLine::Idle); + assert_eq!(PayloadLine::parse("READY"), PayloadLine::Ready); + } + + #[test] + fn parses_rx_ok() { + assert_eq!( + PayloadLine::parse("RX_OK 42"), + PayloadLine::RxOk { image_id: 42 } + ); + } + + #[test] + fn parses_processing() { + assert_eq!( + PayloadLine::parse("PROCESSING 42"), + PayloadLine::Processing { image_id: 42 } + ); + } + + #[test] + fn parses_result_ready_notification() { + assert_eq!( + PayloadLine::parse("RESULT_READY 42"), + PayloadLine::ResultReadyNotify { image_id: 42 } + ); + } + + #[test] + fn parses_result_ready_header() { + assert_eq!( + PayloadLine::parse("RESULT_READY 42 12288 A1B2C3D4"), + PayloadLine::ResultHeader { + image_id: 42, + size: 12288, + crc32: 0xA1B2C3D4, + } + ); + } + + #[test] + fn parses_result_info() { + assert_eq!( + PayloadLine::parse("RESULT_INFO 42 READY 12288 A1B2C3D4"), + PayloadLine::ResultInfo { + image_id: 42, + phase: "READY".to_string(), + size: 12288, + crc32: 0xA1B2C3D4, + } + ); + } + + #[test] + fn parses_busy_with_phase() { + assert_eq!( + PayloadLine::parse("BUSY PROCESSING 42"), + PayloadLine::Busy { + phase: "PROCESSING".to_string(), + image_id: Some(42), + } + ); + } + + #[test] + fn parses_error() { + assert_eq!( + PayloadLine::parse("ERR BAD_CRC image 42"), + PayloadLine::Error { + code: "BAD_CRC".to_string(), + message: "image 42".to_string(), + } + ); + } + + #[test] + fn unknown_line_preserved() { + assert_eq!( + PayloadLine::parse("WAT IS THIS"), + PayloadLine::Unknown("WAT IS THIS".to_string()) + ); + } + + #[test] + fn formats_send_command() { + assert_eq!( + cmd_send(42, 813244, 0x6DE17C6C), + b"SEND 42 813244 6DE17C6C\n".to_vec() + ); + } + + #[test] + fn formats_get_result_info() { + assert_eq!(cmd_get_result_info(42), b"GET_RESULT_INFO 42\n".to_vec()); + } + + #[test] + fn formats_result_rx_ok() { + assert_eq!(cmd_result_rx_ok(42), b"RESULT_RX_OK 42\n".to_vec()); + } + + #[test] + fn crc32_known_vector() { + // CRC-32 of "123456789" == 0xCBF43926, the canonical test vector. + assert_eq!(crc32(b"123456789"), 0xCBF43926); + } +} diff --git a/services/payload-services/snn-service/src/schema.rs b/services/payload-services/snn-service/src/schema.rs new file mode 100644 index 0000000..00a5fb3 --- /dev/null +++ b/services/payload-services/snn-service/src/schema.rs @@ -0,0 +1,302 @@ +use async_graphql::{Context, Enum, Object, Result, SimpleObject}; +use base64::Engine; + +use crate::driver::{DriverPhase, JobPhase}; +use crate::subsystem::Subsystem; + +pub struct QueryRoot; +pub struct MutationRoot; + +#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)] +pub enum DriverPhaseGql { + Initializing, + Idle, + Busy, + ShuttingDown, + Faulted, +} + +impl From for DriverPhaseGql { + fn from(value: DriverPhase) -> Self { + match value { + DriverPhase::Initializing => DriverPhaseGql::Initializing, + DriverPhase::Idle => DriverPhaseGql::Idle, + DriverPhase::Busy => DriverPhaseGql::Busy, + DriverPhase::ShuttingDown => DriverPhaseGql::ShuttingDown, + DriverPhase::Faulted => DriverPhaseGql::Faulted, + } + } +} + +#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)] +pub enum JobPhaseGql { + Queued, + SendingImage, + Processing, + ResultReady, + Delivered, + Failed, + Cancelled, +} + +impl From for JobPhaseGql { + fn from(value: JobPhase) -> Self { + match value { + JobPhase::Queued => JobPhaseGql::Queued, + JobPhase::SendingImage => JobPhaseGql::SendingImage, + JobPhase::Processing => JobPhaseGql::Processing, + JobPhase::ResultReady => JobPhaseGql::ResultReady, + JobPhase::Delivered => JobPhaseGql::Delivered, + JobPhase::Failed => JobPhaseGql::Failed, + JobPhase::Cancelled => JobPhaseGql::Cancelled, + } + } +} + +#[derive(SimpleObject)] +pub struct SnnState { + pub phase: DriverPhaseGql, + pub current_image_id: Option, + pub queue_depth: i64, + pub queue_capacity: i64, + pub queued_image_ids: Vec, + pub last_error: Option, +} + +#[derive(SimpleObject)] +pub struct JobStatus { + pub image_id: i64, + pub phase: JobPhaseGql, + pub queue_position: Option, + pub error: Option, +} + +#[derive(SimpleObject)] +pub struct SubmitResponse { + pub success: bool, + pub accepted: bool, + pub image_id: Option, + pub queue_position: Option, + pub queue_depth: Option, + pub error: Option, +} + +#[derive(SimpleObject)] +pub struct ResultPayload { + pub image_id: i64, + pub size_bytes: i64, + pub crc32: String, + pub bitmap_base64: String, + pub phase: JobPhaseGql, +} + +#[derive(SimpleObject)] +pub struct InferenceResult { + pub success: bool, + pub image_id: Option, + pub size_bytes: Option, + pub crc32: Option, + pub bitmap_base64: Option, + pub error: Option, +} + +#[derive(SimpleObject)] +pub struct CancelResponse { + pub success: bool, + pub cancelled: bool, + pub error: Option, +} + +#[derive(SimpleObject)] +pub struct HealthInfo { + pub uart_bus: String, + pub uart_baud: i64, + pub phase: DriverPhaseGql, + pub queue_depth: i64, + pub queue_capacity: i64, + pub jobs_completed: i64, + pub jobs_failed: i64, + pub last_error: Option, +} + +#[Object] +impl QueryRoot { + async fn ping(&self) -> &str { + "pong" + } + + async fn health(&self, ctx: &Context<'_>) -> Result { + let context = ctx.data::>()?; + let h = context.subsystem().health(); + Ok(HealthInfo { + uart_bus: h.uart_bus, + uart_baud: h.uart_baud as i64, + phase: h.phase.into(), + queue_depth: h.queue_depth as i64, + queue_capacity: h.queue_capacity as i64, + jobs_completed: h.jobs_completed as i64, + jobs_failed: h.jobs_failed as i64, + last_error: h.last_error, + }) + } + + async fn state(&self, ctx: &Context<'_>) -> Result { + let context = ctx.data::>()?; + let s = context.subsystem().state(); + Ok(SnnState { + phase: s.phase.into(), + current_image_id: s.current_image_id.map(|v| v as i64), + queue_depth: s.queue_depth as i64, + queue_capacity: s.queue_capacity as i64, + queued_image_ids: s.queued_image_ids.into_iter().map(|v| v as i64).collect(), + last_error: s.last_error, + }) + } + + async fn inference_status( + &self, + ctx: &Context<'_>, + image_id: i64, + ) -> Result> { + if image_id < 0 { + return Err(async_graphql::Error::new("imageId must be >= 0")); + } + let context = ctx.data::>()?; + Ok(context + .subsystem() + .inference_status(image_id as u32) + .map(|s| JobStatus { + image_id: s.image_id as i64, + phase: s.phase.into(), + queue_position: s.queue_position.map(|p| p as i64), + error: s.error, + })) + } + + async fn get_result(&self, ctx: &Context<'_>, image_id: i64) -> Result { + if image_id < 0 { + return Err(async_graphql::Error::new("imageId must be >= 0")); + } + let context = ctx.data::>()?; + let entry = context + .subsystem() + .get_result(image_id as u32) + .map_err(|e| async_graphql::Error::new(e.to_string()))?; + let bitmap_base64 = base64::engine::general_purpose::STANDARD.encode(&entry.bitmap); + Ok(ResultPayload { + image_id: entry.image_id as i64, + size_bytes: entry.size as i64, + crc32: format!("{:08X}", entry.crc32), + bitmap_base64, + phase: entry.phase.into(), + }) + } +} + +#[Object] +impl MutationRoot { + async fn submit_image( + &self, + ctx: &Context<'_>, + image_base64: String, + ) -> Result { + let context = ctx.data::>()?; + let bytes = match base64::engine::general_purpose::STANDARD.decode(image_base64.as_bytes()) + { + Ok(b) => b, + Err(err) => { + return Ok(SubmitResponse { + success: false, + accepted: false, + image_id: None, + queue_position: None, + queue_depth: None, + error: Some(format!("invalid base64: {err}")), + }); + } + }; + match context.subsystem().submit(bytes) { + Ok(outcome) => Ok(SubmitResponse { + success: true, + accepted: true, + image_id: Some(outcome.image_id as i64), + queue_position: Some(outcome.queue_position as i64), + queue_depth: Some(outcome.queue_depth as i64), + error: None, + }), + Err(err) => Ok(SubmitResponse { + success: false, + accepted: false, + image_id: None, + queue_position: None, + queue_depth: None, + error: Some(err.to_string()), + }), + } + } + + /// Submit + poll-to-completion convenience wrapper. Goes through the same queue and + /// driver as `submitImage`; the only difference is who is holding the HTTP request open. + async fn infer(&self, ctx: &Context<'_>, image_base64: String) -> Result { + let context = ctx.data::>()?; + let bytes = match base64::engine::general_purpose::STANDARD.decode(image_base64.as_bytes()) + { + Ok(b) => b, + Err(err) => { + return Ok(InferenceResult { + success: false, + image_id: None, + size_bytes: None, + crc32: None, + bitmap_base64: None, + error: Some(format!("invalid base64: {err}")), + }); + } + }; + match context.subsystem().infer(bytes).await { + Ok(entry) => { + let bitmap_base64 = + base64::engine::general_purpose::STANDARD.encode(&entry.bitmap); + Ok(InferenceResult { + success: true, + image_id: Some(entry.image_id as i64), + size_bytes: Some(entry.size as i64), + crc32: Some(format!("{:08X}", entry.crc32)), + bitmap_base64: Some(bitmap_base64), + error: None, + }) + } + Err(err) => Ok(InferenceResult { + success: false, + image_id: None, + size_bytes: None, + crc32: None, + bitmap_base64: None, + error: Some(err.to_string()), + }), + } + } + + async fn cancel(&self, ctx: &Context<'_>, image_id: i64) -> Result { + if image_id < 0 { + return Ok(CancelResponse { + success: false, + cancelled: false, + error: Some("imageId must be >= 0".to_string()), + }); + } + let context = ctx.data::>()?; + match context.subsystem().cancel(image_id as u32) { + Ok(cancelled) => Ok(CancelResponse { + success: true, + cancelled, + error: None, + }), + Err(err) => Ok(CancelResponse { + success: false, + cancelled: false, + error: Some(err.to_string()), + }), + } + } +} diff --git a/services/payload-services/snn-service/src/subsystem.rs b/services/payload-services/snn-service/src/subsystem.rs new file mode 100644 index 0000000..3096c02 --- /dev/null +++ b/services/payload-services/snn-service/src/subsystem.rs @@ -0,0 +1,337 @@ +use std::sync::Arc; +use std::time::Duration; + +use kubos_service::Config; + +use crate::driver::{ + self, DriverConfig, DriverHandle, DriverPhase, JobPhase, JobStatus, PendingJob, ResultEntry, +}; +use crate::error::SnnError; +use crate::protocol; + +const POLL_INTERVAL: Duration = Duration::from_millis(200); + +#[derive(Clone)] +pub struct Subsystem { + handle: DriverHandle, + config: Arc, + /// Kept alive so the driver thread is joined on drop (well, we don't strictly join, + /// but holding the handle in an Arc lets us drop the service cleanly during tests). + _join: Arc>, +} + +#[derive(Clone, Debug)] +pub struct SubmitOutcome { + pub image_id: u32, + pub queue_position: usize, + pub queue_depth: usize, +} + +#[derive(Clone, Debug)] +pub struct SnnState { + pub phase: DriverPhase, + pub current_image_id: Option, + pub queue_depth: usize, + pub queue_capacity: usize, + pub queued_image_ids: Vec, + pub last_error: Option, +} + +#[derive(Clone, Debug)] +pub struct HealthInfo { + pub uart_bus: String, + pub uart_baud: u32, + pub queue_depth: usize, + pub queue_capacity: usize, + pub jobs_completed: u64, + pub jobs_failed: u64, + pub last_error: Option, + pub phase: DriverPhase, +} + +impl Subsystem { + pub fn from_config(config: &Config) -> Result { + let driver_config = load_driver_config(config)?; + Ok(Self::start(driver_config)) + } + + pub fn start(driver_config: DriverConfig) -> Self { + let handle = DriverHandle::new(); + let join = driver::spawn(driver_config.clone(), handle.clone()); + Self { + handle, + config: Arc::new(driver_config), + _join: Arc::new(join), + } + } + + pub fn submit(&self, image: Vec) -> Result { + if image.is_empty() { + return Err(SnnError::Protocol("image is empty".to_string())); + } + if image.len() > self.config.max_image_bytes { + return Err(SnnError::ImageTooLarge { + size: image.len(), + limit: self.config.max_image_bytes, + }); + } + + let crc = protocol::crc32(&image); + + let mut state = self.handle.state.lock().map_err(|_| { + SnnError::Internal("subsystem state lock poisoned".to_string()) + })?; + + if matches!(state.phase, DriverPhase::ShuttingDown | DriverPhase::Faulted) { + return Err(SnnError::Internal(format!( + "driver in unrecoverable phase: {}", + state.phase.as_str() + ))); + } + if state.pending.len() >= self.config.queue_capacity { + return Err(SnnError::QueueFull(self.config.queue_capacity)); + } + + let image_id = state.next_image_id; + state.next_image_id = state.next_image_id.wrapping_add(1).max(1); + + state.pending.push_back(PendingJob { + image_id, + image, + crc32: crc, + }); + let queue_position = state.pending.len() - 1; + let queue_depth = state.pending.len(); + state.jobs.insert( + image_id, + JobStatus { + image_id, + phase: JobPhase::Queued, + queue_position: Some(queue_position), + error: None, + }, + ); + + drop(state); + self.handle.cond.notify_one(); + + Ok(SubmitOutcome { + image_id, + queue_position, + queue_depth, + }) + } + + /// Best-effort cancellation. Only succeeds if the job is still `Queued`. + pub fn cancel(&self, image_id: u32) -> Result { + let mut state = self.handle.state.lock().map_err(|_| { + SnnError::Internal("subsystem state lock poisoned".to_string()) + })?; + if let Some(pos) = state + .pending + .iter() + .position(|job| job.image_id == image_id) + { + state.pending.remove(pos); + if let Some(status) = state.jobs.get_mut(&image_id) { + status.phase = JobPhase::Cancelled; + status.queue_position = None; + } + // Recompute positions for remaining pending jobs. + let positions: Vec<(u32, usize)> = state + .pending + .iter() + .enumerate() + .map(|(idx, job)| (job.image_id, idx)) + .collect(); + for (id, idx) in positions { + if let Some(status) = state.jobs.get_mut(&id) { + status.queue_position = Some(idx); + } + } + return Ok(true); + } + Ok(false) + } + + pub fn inference_status(&self, image_id: u32) -> Option { + self.handle + .state + .lock() + .ok() + .and_then(|guard| guard.jobs.get(&image_id).cloned()) + } + + pub fn get_result(&self, image_id: u32) -> Result { + let mut state = self.handle.state.lock().map_err(|_| { + SnnError::Internal("subsystem state lock poisoned".to_string()) + })?; + + match state.results.get(&image_id) { + Some(entry) => { + let mut entry = entry.clone(); + if matches!(entry.phase, JobPhase::ResultReady) { + entry.phase = JobPhase::Delivered; + } + if let Some(stored) = state.results.get_mut(&image_id) { + stored.phase = JobPhase::Delivered; + } + if let Some(status) = state.jobs.get_mut(&image_id) + && matches!(status.phase, JobPhase::ResultReady) + { + status.phase = JobPhase::Delivered; + } + Ok(entry) + } + None => match state.jobs.get(&image_id) { + Some(status) => Err(SnnError::ResultNotReady(image_id, status.phase.as_str().to_string())), + None => Err(SnnError::UnknownImageId(image_id)), + }, + } + } + + /// Submit + poll-to-completion. The poll happens on the tokio executor so the + /// driver thread is unaffected. + pub async fn infer(&self, image: Vec) -> Result { + let outcome = self.submit(image)?; + let id = outcome.image_id; + loop { + let status = self + .inference_status(id) + .ok_or(SnnError::UnknownImageId(id))?; + match status.phase { + JobPhase::ResultReady | JobPhase::Delivered => { + return self.get_result(id); + } + JobPhase::Failed => { + return Err(SnnError::Internal( + status.error.unwrap_or_else(|| "inference failed".to_string()), + )); + } + JobPhase::Cancelled => { + return Err(SnnError::Internal("inference cancelled".to_string())); + } + _ => tokio::time::sleep(POLL_INTERVAL).await, + } + } + } + + pub fn state(&self) -> SnnState { + let guard = match self.handle.state.lock() { + Ok(g) => g, + Err(_) => { + return SnnState { + phase: DriverPhase::Faulted, + current_image_id: None, + queue_depth: 0, + queue_capacity: self.config.queue_capacity, + queued_image_ids: Vec::new(), + last_error: Some("subsystem state lock poisoned".to_string()), + }; + } + }; + SnnState { + phase: guard.phase.clone(), + current_image_id: guard.current_image_id, + queue_depth: guard.pending.len(), + queue_capacity: self.config.queue_capacity, + queued_image_ids: guard.pending.iter().map(|j| j.image_id).collect(), + last_error: guard.last_error.clone(), + } + } + + pub fn health(&self) -> HealthInfo { + let guard = match self.handle.state.lock() { + Ok(g) => g, + Err(_) => { + return HealthInfo { + uart_bus: self.config.uart_bus.clone(), + uart_baud: self.config.uart_baud, + queue_depth: 0, + queue_capacity: self.config.queue_capacity, + jobs_completed: 0, + jobs_failed: 0, + last_error: Some("subsystem state lock poisoned".to_string()), + phase: DriverPhase::Faulted, + }; + } + }; + HealthInfo { + uart_bus: self.config.uart_bus.clone(), + uart_baud: self.config.uart_baud, + queue_depth: guard.pending.len(), + queue_capacity: self.config.queue_capacity, + jobs_completed: guard.jobs_completed, + jobs_failed: guard.jobs_failed, + last_error: guard.last_error.clone(), + phase: guard.phase.clone(), + } + } + + pub fn shutdown(&self) { + self.handle.signal_shutdown(); + } +} + +fn load_driver_config(config: &Config) -> Result { + let uart_bus = config + .get("uart_bus") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "/dev/ttyUL2".to_string()); + let uart_baud = config + .get("uart_baud") + .and_then(|v| v.as_integer()) + .map(|v| v as u32) + .unwrap_or(115200); + let read_line_timeout = duration_ms(config, "read_line_timeout_ms", 2000); + let ready_timeout = duration_ms(config, "ready_timeout_ms", 2000); + let rx_ok_timeout = duration_ms(config, "rx_ok_timeout_ms", 5000); + let processing_timeout = duration_ms(config, "processing_timeout_ms", 60_000); + let result_info_timeout = duration_ms(config, "result_info_timeout_ms", 2000); + let result_header_timeout = duration_ms(config, "result_header_timeout_ms", 2000); + let queue_capacity = config + .get("queue_capacity") + .and_then(|v| v.as_integer()) + .map(|v| v as usize) + .unwrap_or(4); + let result_retention = config + .get("result_retention") + .and_then(|v| v.as_integer()) + .map(|v| v as usize) + .unwrap_or(4); + let max_image_bytes = config + .get("max_image_bytes") + .and_then(|v| v.as_integer()) + .map(|v| v as usize) + .unwrap_or(4 * 1024 * 1024); + + if queue_capacity == 0 { + return Err("queue_capacity must be >= 1".to_string()); + } + if result_retention == 0 { + return Err("result_retention must be >= 1".to_string()); + } + + Ok(DriverConfig { + uart_bus, + uart_baud, + read_line_timeout, + ready_timeout, + rx_ok_timeout, + processing_timeout, + result_info_timeout, + result_header_timeout, + queue_capacity, + result_retention, + max_image_bytes, + }) +} + +fn duration_ms(config: &Config, key: &str, default_ms: u64) -> Duration { + let ms = config + .get(key) + .and_then(|v| v.as_integer()) + .map(|v| v as u64) + .unwrap_or(default_ms); + Duration::from_millis(ms) +} From d7200d312fa2cfcdf7237bc811da3fbc11ac1405 Mon Sep 17 00:00:00 2001 From: Luke Marks Date: Sun, 22 Mar 2026 14:46:52 -0600 Subject: [PATCH 10/11] additional files pushed from PCPowerEdge --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a170681..00034f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,7 @@ members = [ "services/payload-services/snn-service", "services/hardware-services/mram-service", "services/hardware-services/fram-service", -] + "services/payload-services/dosimeter", exclude = ["kubos/apis/nosengine-rust", "kubos/test/integration/nosengine-rust"] From 0827a126816d9920e529f0bcb19c5431ecdeff49 Mon Sep 17 00:00:00 2001 From: Oren Rotaru <64712576+OrenRotaru@users.noreply.github.com> Date: Wed, 6 May 2026 13:06:20 -0600 Subject: [PATCH 11/11] Write F-RAM service and tests --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 00034f5..f591b93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ members = [ "services/hardware-services/mram-service", "services/hardware-services/fram-service", "services/payload-services/dosimeter", +] exclude = ["kubos/apis/nosengine-rust", "kubos/test/integration/nosengine-rust"]