From f15427b7853cbe8cad869751f354d10367eb7f4c Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Fri, 17 Apr 2026 15:13:30 +0200 Subject: [PATCH 01/26] add crates serde, toml & config --- Cargo.lock | 388 +++++++++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 3 + 2 files changed, 381 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e7b94e..834e24f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "astral-tokio-tar" version = "0.6.0" @@ -147,6 +153,18 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] name = "bollard" @@ -276,6 +294,55 @@ dependencies = [ "windows-link", ] +[[package]] +name = "config" +version = "0.15.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" +dependencies = [ + "async-trait", + "convert_case", + "json5", + "pathdiff", + "ron", + "rust-ini", + "serde-untagged", + "serde_core", + "serde_json", + "toml 1.1.2+spec-1.1.0", + "winnow 1.0.0", + "yaml-rust2", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -292,12 +359,37 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.21.3" @@ -440,6 +532,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -451,6 +553,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "docker_credential" version = "1.3.2" @@ -503,12 +614,32 @@ dependencies = [ "nb", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -516,7 +647,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -535,13 +666,16 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "config", "dashmap", "diesel", "diesel_migrations", "liquidcan_rust", + "serde", "serde_json", "socketcan", "testcontainers", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -578,6 +712,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -675,6 +815,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -729,12 +879,30 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -882,7 +1050,7 @@ dependencies = [ "hyper", "libc", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1092,6 +1260,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "libc" version = "0.2.183" @@ -1197,7 +1376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" dependencies = [ "serde", - "toml", + "toml 0.9.12+spec-1.1.0", ] [[package]] @@ -1384,6 +1563,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "parking_lot_core" version = "0.9.12" @@ -1422,12 +1611,61 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -1539,7 +1777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -1679,6 +1917,30 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1695,7 +1957,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1829,6 +2091,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1875,9 +2149,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -1925,6 +2199,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2160,6 +2445,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2239,11 +2533,26 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.0", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -2253,15 +2562,30 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow 1.0.0", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tonic" version = "0.14.5" @@ -2370,12 +2694,36 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "untrusted" version = "0.9.0" @@ -2440,6 +2788,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -2693,6 +3047,9 @@ name = "winnow" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" @@ -2716,6 +3073,17 @@ dependencies = [ "rustix", ] +[[package]] +name = "yaml-rust2" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index fe37e1f..e4a56e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,9 @@ chrono = "0.4" serde_json = "1.0" anyhow = "1.0.102" dashmap = "6.1.0" +toml = "1.1.2" +serde = "1.0.228" +config = "0.15.22" [dev-dependencies] diesel_migrations = "2.3" From f391e020da8e0bc853c5dadb1442e647aa1d7186 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Fri, 17 Apr 2026 15:45:09 +0200 Subject: [PATCH 02/26] add json schema for sequences --- sequences/_schema.json | 187 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 sequences/_schema.json diff --git a/sequences/_schema.json b/sequences/_schema.json new file mode 100644 index 0000000..546b3da --- /dev/null +++ b/sequences/_schema.json @@ -0,0 +1,187 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$defs": { + "setParam": { + "type": "object", + "additionalProperties": false, + "required": [ + "timestamp", + "param", + "value" + ], + "properties": { + "timestamp": { + "type": "number" + }, + "param": { + "type": "string" + }, + "value": { + "type": "number" + } + } + }, + "holdcondition": { + "type": "object", + "additionalProperties": false, + "required": [ + "field", + "is", + "value" + ], + "properties": { + "field": { + "type": "string" + }, + "is": { + "enum": [ + "equal", + "not_eq", + "less", + "less_eq", + "greater", + "greater_eq" + ] + }, + "value": { + "type": "number" + } + } + } + }, + "title": "FerroFlow-Sequence", + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "globals", + "steps" + ], + "properties": { + "$schema": { + "type": "string" + }, + "name": { + "type": "string" + }, + "globals": { + "type": "object", + "additionalProperties": false, + "required": [ + "start_time", + "end_time", + "interpolations", + "interpolation_interval" + ], + "properties": { + "start_time": { + "type": "number" + }, + "end_time": { + "type": "number" + }, + "interpolation_interval": { + "type": "number", + "exclusiveMinimum": 0 + }, + "interpolations": { + "type": "object", + "patternProperties": { + "^.+$": { + "type": "string", + "enum": [ + "linear", + "none" + ] + } + } + } + } + }, + "steps": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "timestamp", + "hold" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "hold": { + "type": "array", + "items": { + "$ref": "#/$defs/holdcondition", + "minItems": 1 + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "timestamp", + "hold" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "hold": { + "type": "string", + "const": "always" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "timestamp", + "set_params" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "set_params": { + "type": "array", + "items": { + "$ref": "#/$defs/setParam", + "minItems": 1 + } + } + } + } + ] + } + } + } +} \ No newline at end of file From cb2f2aa59e3d4b1f28dbc1abb955bdc97f0a9578 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Fri, 17 Apr 2026 15:53:16 +0200 Subject: [PATCH 03/26] add derive feature for crate serde --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e4a56e8..6519e0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ serde_json = "1.0" anyhow = "1.0.102" dashmap = "6.1.0" toml = "1.1.2" -serde = "1.0.228" +serde = { version = "1.0.228", features = ["derive"] } config = "0.15.22" [dev-dependencies] From ef303b48f01b71246a7d82d445cbe3af2fcbc4ed Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Sun, 19 Apr 2026 00:49:47 +0200 Subject: [PATCH 04/26] add toml sequences for sequence parsing tests --- tests/sequences/invalid_action_times.toml | 23 ++++++++++++ tests/sequences/invalid_global_times.toml | 9 +++++ .../invalid_interpolation_interval.toml | 9 +++++ tests/sequences/invalid_step_times.toml | 21 +++++++++++ tests/sequences/valid.toml | 37 +++++++++++++++++++ 5 files changed, 99 insertions(+) create mode 100644 tests/sequences/invalid_action_times.toml create mode 100644 tests/sequences/invalid_global_times.toml create mode 100644 tests/sequences/invalid_interpolation_interval.toml create mode 100644 tests/sequences/invalid_step_times.toml create mode 100644 tests/sequences/valid.toml diff --git a/tests/sequences/invalid_action_times.toml b/tests/sequences/invalid_action_times.toml new file mode 100644 index 0000000..9c1a66f --- /dev/null +++ b/tests/sequences/invalid_action_times.toml @@ -0,0 +1,23 @@ +##:schema ../../sequences/_schema.json + +name = "Test Sequence Invalid: Action times" + +[globals] +start_time = -2 +end_time = 5 +interpolations = { servo1 = "linear", servo2 = "none" } +interpolation_interval = 0.1 + +[[steps]] +name = "Step1" +description = "Description of Step1" +timestamp = 0 +set_params = [ + { timestamp = 3, field = "servo1", value = 0 } +] + +[[steps]] +name = "Step2" +description = "Description of Step2" +timestamp = 1 +set_params = [] \ No newline at end of file diff --git a/tests/sequences/invalid_global_times.toml b/tests/sequences/invalid_global_times.toml new file mode 100644 index 0000000..40df94b --- /dev/null +++ b/tests/sequences/invalid_global_times.toml @@ -0,0 +1,9 @@ +##:schema ../../sequences/_schema.json + +name = "Test Sequence Invalid: global times" + +[globals] +start_time = 5 +end_time = 2 +interpolations = {} +interpolation_interval = 0.01 \ No newline at end of file diff --git a/tests/sequences/invalid_interpolation_interval.toml b/tests/sequences/invalid_interpolation_interval.toml new file mode 100644 index 0000000..53d6766 --- /dev/null +++ b/tests/sequences/invalid_interpolation_interval.toml @@ -0,0 +1,9 @@ +##:schema ../../sequences/_schema.json + +name = "Test Sequence Invalid: interpolations interval" + +[globals] +start_time = -2 +end_time = 5 +interpolations = {} +interpolation_interval = -0.1 \ No newline at end of file diff --git a/tests/sequences/invalid_step_times.toml b/tests/sequences/invalid_step_times.toml new file mode 100644 index 0000000..064e235 --- /dev/null +++ b/tests/sequences/invalid_step_times.toml @@ -0,0 +1,21 @@ +##:schema ../../sequences/_schema.json + +name = "Test Sequence Invalid: step times" + +[globals] +start_time = -2 +end_time = 5 +interpolations = { servo1 = "linear", servo2 = "none" } +interpolation_interval = 0.1 + +[[steps]] +name = "Step1" +description = "Description of Step1" +timestamp = -9 +set_params = [] + +[[steps]] +name = "Step2" +description = "Description of Step2" +timestamp = 9 +set_params = [] \ No newline at end of file diff --git a/tests/sequences/valid.toml b/tests/sequences/valid.toml new file mode 100644 index 0000000..acb1e75 --- /dev/null +++ b/tests/sequences/valid.toml @@ -0,0 +1,37 @@ +#:schema ../../sequences/_schema.json + +name = "Test Sequence Valid" + +[globals] +start_time = -2 +end_time = 5 +interpolations = { servo1 = "linear", servo2 = "none" } +interpolation_interval = 0.1 + +[[steps]] +name = "Step1" +description = "Description of Step1" +timestamp = 1.0 +set_params = [ + { timestamp = 0.0, param = "servo1", value = 12 }, + { timestamp = 0.5, param = "valve1", value = 12 }, +] + +[[steps]] +name = "Hold1" +timestamp = 2.0 +hold = "always" + +[[steps]] +name = "Hold2" +timestamp = 3.0 +hold = [ + { field = "servo1", is = "greater", value = 5 }, + { field = "valve1", is = "equal", value = 0 }, +] + +[[steps]] +name = "Step2" +description = "Description of Step2" +timestamp = 4.0 +set_params = [{ timestamp = 0.1, param = "servo1", value = 12 }] From ae11b14f7dfd7e970b7869280e66b4668f620f62 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Sun, 19 Apr 2026 00:50:27 +0200 Subject: [PATCH 05/26] add sequence control events --- src/events/mod.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/events/mod.rs b/src/events/mod.rs index 0aa8383..1a94193 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -22,6 +22,13 @@ pub enum Event { from_interface: String, frame: CanAnyFrame, }, + StartSequence { + seq_name: String, + abort_seq_name: String, + }, + PauseSequence, + ResumeSequence, + AbortSequence, } struct EventListener { From 4fee8d2e17e5455d9b05cd4e3f39175aaad8d3f8 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Sun, 19 Apr 2026 00:51:01 +0200 Subject: [PATCH 06/26] add sequence definitions and parsing --- src/sequence/mod.rs | 86 +-------- src/sequence/sequence_definition.rs | 268 ++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+), 85 deletions(-) create mode 100644 src/sequence/sequence_definition.rs diff --git a/src/sequence/mod.rs b/src/sequence/mod.rs index fafa68d..edf249e 100644 --- a/src/sequence/mod.rs +++ b/src/sequence/mod.rs @@ -1,88 +1,4 @@ //! Code for managing and running sequences. -#![allow(unused)] +mod sequence_definition; -use std::{ - sync::mpsc, - thread, - time::{Duration, Instant}, -}; - -pub struct Sequence { - name: String, - steps: Vec, -} - -struct SequenceStep { - description: String, - delay_from_start_ms: u64, - action: (), // TODO: how are steps defined? -} - -pub struct SequenceHandle { - cancel_tx: mpsc::Sender<()>, - thread_handle: thread::JoinHandle<()>, -} - -impl SequenceHandle { - /// Signals the sequence to stop executing further steps. - pub fn cancel(self) { - let _ = self.cancel_tx.send(()); - } - - /// Blocks until the sequence finishes (or is cancelled). - pub fn wait(self) -> thread::Result<()> { - self.thread_handle.join() - } -} - -pub fn run_sequence(mut seq: Sequence) -> SequenceHandle { - // Create a channel for our interrupt signal - let (cancel_tx, cancel_rx) = mpsc::channel(); - - // Sort steps. - // TODO: We could probably require that they are already sorted at this point. - seq.steps.sort_by_key(|s| s.delay_from_start_ms); - - let thread_handle = thread::spawn(move || { - println!("Starting sequence: {}", seq.name); - - let start_time = Instant::now(); - - for step in seq.steps { - // Calculate the absolute target time for this specific step - let target_time = start_time + Duration::from_millis(step.delay_from_start_ms); - let now = Instant::now(); - - // If the target time is in the future, we need to wait - if target_time > now { - let wait_duration = target_time - now; - - // recv_timeout blocks until a message is received OR the timeout is reached. - match cancel_rx.recv_timeout(wait_duration) { - Ok(_) => { - println!("Sequence '{}' interrupted! Aborting.", seq.name); - return; - } - Err(mpsc::RecvTimeoutError::Disconnected) => { - // The caller dropped the handle without explicitly calling cancel(). - println!("Sequence handle dropped. Aborting '{}'.", seq.name); - return; - } - Err(mpsc::RecvTimeoutError::Timeout) => { - // Timeout reached without interruption. Run the step. - } - } - } - - println!("Executing step: {}", step.description); - } - - println!("Sequence '{}' completed successfully.", seq.name); - }); - - SequenceHandle { - cancel_tx, - thread_handle, - } -} diff --git a/src/sequence/sequence_definition.rs b/src/sequence/sequence_definition.rs new file mode 100644 index 0000000..601b8bb --- /dev/null +++ b/src/sequence/sequence_definition.rs @@ -0,0 +1,268 @@ +#![allow(unused)] + +use anyhow::{Context, Result}; +use std::{collections::HashMap, path::Path}; + +use config::{Config, File}; +use serde::{Deserialize, Deserializer, de}; + +type TimestampSec = f64; + +#[derive(Debug, Deserialize)] +pub struct Sequence { + pub name: String, + pub globals: Globals, + #[serde(default)] + pub steps: Vec, +} + +impl Sequence { + pub fn load_from_path(path: &Path) -> Result { + let config = Config::builder().add_source(File::from(path)).build()?; + + let mut sequence: Self = config + .try_deserialize() + .with_context(|| format!("Failed to deserialize config from {}", path.display()))?; + + sequence + .steps + .sort_by(|a, b| a.timestamp.total_cmp(&b.timestamp)); + sequence.validate()?; + Ok(sequence) + } + + fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.globals.start_time <= self.globals.end_time, + "Invalid start time is after end time", + ); + anyhow::ensure!( + self.globals.interpolation_interval > 0.0, + "Invalid interpolations interval must be greater 0", + ); + + for window in self.steps.windows(2) { + let step = &window[0]; + let next = &window[1]; + + anyhow::ensure!( + step.timestamp >= self.globals.start_time, + "Invalid step timestamp in step '{}', before global start time", + step.name, + ); + + anyhow::ensure!( + step.timestamp <= self.globals.end_time, + "Invalid step timestamp in step '{}', after global end time", + step.name, + ); + + for action in &step.actions { + if let Action::SetParam(fv) = action { + anyhow::ensure!( + fv.timestamp >= step.timestamp, + "Invalid action timestamp in step '{}' action '{}', before step timestamp", + step.name, + fv.param, + ); + + anyhow::ensure!( + fv.timestamp <= self.globals.end_time, + "Invalid action timestamp in step '{}' action '{}', after global end time", + step.name, + fv.param, + ); + + anyhow::ensure!( + fv.timestamp <= next.timestamp, + "Invalid action timestamp in step '{}' action '{}', after next step timestamp", + step.name, + fv.param, + ); + } + } + } + + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +pub struct Globals { + pub start_time: TimestampSec, + pub end_time: TimestampSec, + pub interpolation_interval: TimestampSec, + #[serde(default)] + pub interpolations: HashMap, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum InterpolationMode { + #[default] + None, + Linear, +} + +#[derive(Debug)] +pub struct Step { + pub name: String, + pub description: Option, + pub timestamp: TimestampSec, + pub actions: Vec, +} + +impl<'de> Deserialize<'de> for Step { + fn deserialize>(d: D) -> Result { + #[derive(Deserialize)] + struct RawStep { + name: String, + #[serde(default)] + description: Option, + timestamp: TimestampSec, + #[serde(default)] + set_params: Vec, + hold: Option, + } + + let raw = RawStep::deserialize(d)?; + let actions = match (raw.hold, raw.set_params.is_empty()) { + (Some(mode), true) => vec![Action::Hold(mode)], + (Some(_), false) => { + return Err(de::Error::custom( + "step can either have `hold` or `set_Params`", + )); + } + (None, _) => raw + .set_params + .into_iter() + .map(|fv| { + Action::SetParam(ParamValue { + timestamp: raw.timestamp + fv.timestamp, + param: fv.param, + value: fv.value, + }) + }) + .collect(), + }; + + Ok(Step { + name: raw.name, + description: raw.description, + timestamp: raw.timestamp, + actions, + }) + } +} + +#[derive(Debug, Deserialize)] +pub enum Action { + Hold(HoldMode), + SetParam(ParamValue), +} + +#[derive(Debug)] +pub enum HoldMode { + Always, + Conditional(Vec), +} + +impl<'de> Deserialize<'de> for HoldMode { + fn deserialize>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum RawHoldMode { + Always(String), + Conditional(Vec), + } + + let raw = RawHoldMode::deserialize(d)?; + match raw { + RawHoldMode::Always(s) if s.eq_ignore_ascii_case("always") => Ok(HoldMode::Always), + RawHoldMode::Always(s) => Err(de::Error::unknown_variant(&s, &["always"])), + RawHoldMode::Conditional(c) => Ok(HoldMode::Conditional(c)), + } + } +} + +#[derive(Debug)] +pub struct ScheduledAction { + pub timestamp: TimestampSec, + pub action: Action, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ParamValue { + pub timestamp: TimestampSec, + pub param: String, + pub value: f64, +} + +#[derive(Debug, Deserialize)] +pub struct HoldCondition { + field: String, + is: FieldComparison, + value: f64, +} + +impl HoldCondition { + pub fn evaluate(&self) -> bool { + // TODO: evaluate condition if it's true based on the actual field values + todo!() + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FieldComparison { + Equal, + NotEq, + Less, + LessEq, + Greater, + GreaterEq, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn sequence_path(filename: &str) -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("sequences") + .join(filename) + } + + #[test] + fn test_load_success() { + let seq = Sequence::load_from_path(&sequence_path("valid.toml")) + .expect("valid sequence should be loaded"); + assert_eq!("Test Sequence Valid", seq.name); + } + + #[test] + fn test_load_invalid_global_times() { + let seq = Sequence::load_from_path(&sequence_path("invalid_global_times.toml")); + assert!(seq.is_err()); + } + + #[test] + fn test_load_invalid_interpolation_interval() { + let seq = Sequence::load_from_path(&sequence_path("invalid_interpolation_interval.toml")); + assert!(seq.is_err()); + } + + #[test] + fn test_load_invalid_step_times() { + let seq = Sequence::load_from_path(&sequence_path("invalid_step_times.toml")); + assert!(seq.is_err()); + } + + #[test] + fn test_load_invalid_action_times() { + let seq = Sequence::load_from_path(&sequence_path("invalid_action_times.toml")); + assert!(seq.is_err()); + } +} From 1bc7a141b3b0e0a6b4794874a52e9e32a8314bef Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Sun, 19 Apr 2026 01:45:32 +0200 Subject: [PATCH 07/26] add sequence runner --- src/sequence/mod.rs | 41 +++++ src/sequence/sequence_runner.rs | 285 ++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 src/sequence/sequence_runner.rs diff --git a/src/sequence/mod.rs b/src/sequence/mod.rs index edf249e..6fc2415 100644 --- a/src/sequence/mod.rs +++ b/src/sequence/mod.rs @@ -1,4 +1,45 @@ //! Code for managing and running sequences. mod sequence_definition; +mod sequence_runner; +use crate::{ + events, + sequence::sequence_runner::{SequenceCmd, SequenceRunner}, +}; + +pub fn spawn_sequence_runner_thread<'scope>( + event_dispatcher: &'scope events::EventDispatcher, + scope: &'scope std::thread::Scope<'scope, '_>, +) { + scope.spawn(move || { + let (tx, rx) = std::sync::mpsc::channel::(); + event_dispatcher.subscribe(tx, "Sequence Runner thread"); + let mut sequence_runner = SequenceRunner::new(event_dispatcher, scope); + + while let Ok(event) = rx.recv() { + match event { + events::Event::Shutdown => break, + events::Event::StartSequence { + seq_name, + abort_seq_name, + } => { + let result = sequence_runner.run_sequence(seq_name, abort_seq_name); + if let Err(err) = result { + eprintln!("Error while running sequence: {err:#}"); + } + } + events::Event::PauseSequence => { + sequence_runner.control_sequence(SequenceCmd::Pause) + } + events::Event::ResumeSequence => { + sequence_runner.control_sequence(SequenceCmd::Resume) + } + events::Event::AbortSequence => { + sequence_runner.control_sequence(SequenceCmd::Abort) + } + _ => continue, + }; + } + }); +} diff --git a/src/sequence/sequence_runner.rs b/src/sequence/sequence_runner.rs new file mode 100644 index 0000000..42bc160 --- /dev/null +++ b/src/sequence/sequence_runner.rs @@ -0,0 +1,285 @@ +#[allow(unused)] +use anyhow::Result; +use anyhow::anyhow; +use std::{ + collections::HashMap, + path::Path, + sync::mpsc::{self, Receiver, RecvTimeoutError}, + thread, + time::{Duration, Instant}, +}; + +use crate::{ + events::{self, EventDispatcher}, + sequence::sequence_definition::{ + Action, HoldMode, InterpolationMode, ParamValue, ScheduledAction, Sequence, + }, +}; + +pub struct SequenceHandle<'scope> { + controller_tx: mpsc::Sender, + thread_handle: thread::ScopedJoinHandle<'scope, Result<(), SequenceRunError>>, +} + +#[derive(Debug)] +pub enum SequenceCmd { + Pause, + Resume, + Abort, +} + +pub enum SequenceRunError { + Aborted, + HandleDropped, +} + +pub struct SequenceRunner<'scope, 'env> { + last_sequence_handle: Option>, + event_dispatcher: &'scope events::EventDispatcher, + scope: &'scope thread::Scope<'scope, 'env>, +} + +impl<'scope, 'env> SequenceRunner<'scope, 'env> { + pub fn new( + event_dispatcher: &'scope events::EventDispatcher, + scope: &'scope thread::Scope<'scope, 'env>, + ) -> Self { + Self { + last_sequence_handle: None, + event_dispatcher, + scope, + } + } + + /// Run a sequence and an abort sequence if the sequence is aborted. + /// + /// Returns an error if another sequence is running. + pub fn run_sequence(&mut self, seq_name: String, abort_seq_name: String) -> Result<()> { + if self.is_sequence_running() { + return Err(anyhow!("another sequence is still running")); + } + + let seq = Self::load_sequence(seq_name)?; + let abort_seq = Self::load_sequence(abort_seq_name)?; + let (controller_tx, controller_rx) = mpsc::channel(); + let event_dispatcher = self.event_dispatcher; + + let thread_handle = self.scope.spawn(move || { + let schedule = Self::build_schedule(seq); + let abort_schedule = Self::build_schedule(abort_seq); + let result = Self::execute_schedule(schedule, &controller_rx, event_dispatcher); + + match result { + Ok(_) => Ok(()), // execution finished nominal + Err(_) => Self::execute_schedule(abort_schedule, &controller_rx, event_dispatcher), // execution was aborted, start abort sequence + } + }); + + self.last_sequence_handle = Some(SequenceHandle { + controller_tx, + thread_handle, + }); + + Ok(()) + } + + /// Send pause, resume and abort commands to a running sequence. + /// If no sequence is running, nothing happens. + pub fn control_sequence(&mut self, cmd: SequenceCmd) { + if !self.is_sequence_running() { + return; + } + if let Some(handle) = &self.last_sequence_handle { + let _ = handle.controller_tx.send(cmd); + }; + } + + fn is_sequence_running(&self) -> bool { + self.last_sequence_handle + .as_ref() + .is_some_and(|handle| !handle.thread_handle.is_finished()) + } + + fn load_sequence(file_name: String) -> Result { + // TODO: implement a better way of loading sequences, from the frontend + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("sequences") + .join(format!("{file_name}")); + Sequence::load_from_path(path.as_path()) + } + + /// Converts a `Sequence` into a list of `ScheduledAction` by interpolating any values that need it. + /// Also, negative timestamps are removed by offsetting by the global start time, + /// to make the execution easier by using `Duration` and `Instant` from `std`. + fn build_schedule(seq: Sequence) -> Vec { + let mut scheduled_actions = Vec::new(); + let mut last_param_values = HashMap::new(); + let interpolations = &seq.globals.interpolations; + + for step in seq.steps { + for action in step.actions { + match action { + Action::Hold(_) => { + scheduled_actions.push(ScheduledAction { + // Offset timestamp to remove negative timestamps + timestamp: step.timestamp - seq.globals.start_time, + action, + }); + } + Action::SetParam(mut pv) => { + // Offset timestamp to remove negative timestamps + pv.timestamp -= seq.globals.start_time; + + let interpolation_mode = + interpolations.get(&pv.param).copied().unwrap_or_default(); + + if interpolation_mode == InterpolationMode::Linear { + // If previous value for this parameter existed and values changed, interpolate values + if let Some(last) = last_param_values.get(&pv.param) { + let mut points = Self::interpolate_linear( + last, + &pv, + seq.globals.interpolation_interval, + ); + scheduled_actions.append(&mut points); + } + last_param_values.insert(pv.param.clone(), pv.clone()); + } + + scheduled_actions.push(ScheduledAction { + timestamp: pv.timestamp, + action: Action::SetParam(pv), + }); + } + } + } + } + + scheduled_actions.sort_by(|f1, f2| f1.timestamp.total_cmp(&f2.timestamp)); + scheduled_actions + } + + /// Executes a list of `ScheduledAction`, created using `build_schedule`. Can be paused, resumed and aborted using the receiver parameter. + /// + /// Returns `Ok`, if the execution finished successfully or a `SequenceRunError`, + /// if the execution was aborted or the controller to control the sequence was dropped. + fn execute_schedule( + schedule: Vec, + controller: &Receiver, + event_dispatcher: &EventDispatcher, + ) -> Result<(), SequenceRunError> { + let origin = Instant::now(); + let mut pause_offset = Duration::ZERO; + + for action in schedule { + loop { + let deadline = origin + Duration::from_secs_f64(action.timestamp) + pause_offset; + let now = Instant::now(); + if now >= deadline { + break; + } + let remaining = deadline - now; + + match controller.recv_timeout(remaining) { + Ok(SequenceCmd::Resume) => {} // already running, ignore + Ok(SequenceCmd::Pause) => { + let pause_duration = Self::wait_for_resume(controller)?; + pause_offset += pause_duration; + } + Ok(SequenceCmd::Abort) => return Err(SequenceRunError::Aborted), // abort + Err(RecvTimeoutError::Disconnected) => { + return Err(SequenceRunError::HandleDropped); + } + // The caller dropped the handle without explicitly calling cancel(), abort + Err(RecvTimeoutError::Timeout) => break, // deadline reached, break loop + }; + } + + match action.action { + Action::Hold(mode) => { + let should_hold = match mode { + HoldMode::Always => true, + // TODO: implement conditions evaluation + HoldMode::Conditional(conditions) => { + conditions.iter().all(|cond| cond.evaluate()) + } + }; + + if should_hold { + let pause_duration = Self::wait_for_resume(controller)?; + pause_offset += pause_duration; + } + } + + #[allow(unused)] + Action::SetParam(_param_value) => { + // TODO: send can message with correct data + event_dispatcher.dispatch(events::Event::SendCanMessage { + receiver_node_id: todo!(), + message: liquidcan::CanMessage::ParameterSetReq { + payload: liquidcan::payloads::ParameterSetReqPayload { + parameter_id: todo!(), + value: todo!(), + }, + }, + }); + } + } + } + + Ok(()) + } + + /// Wait and block the thread until `Resume` or `Abort` is received, or the sender is disconnected. + /// + /// Returns the total duration spent waiting. + fn wait_for_resume(controller: &Receiver) -> Result { + let paused_at = Instant::now(); + loop { + match controller.recv() { + Ok(SequenceCmd::Resume) => return Ok(paused_at.elapsed()), + Ok(SequenceCmd::Pause) => continue, // already paused, ignore + Ok(SequenceCmd::Abort) => return Err(SequenceRunError::Aborted), // abort + Err(_) => return Err(SequenceRunError::HandleDropped), // The caller dropped the handle without explicitly calling cancel(), abort + } + } + } + + /// Interpolates the values using the interval, if `from` and `to` are different. + /// The first value in the interpolation is omitted and not returned, + /// because it's handled in `build_schedule` already. + fn interpolate_linear( + from: &ParamValue, + to: &ParamValue, + interval: f64, + ) -> Vec { + if from.value == to.value { + return vec![]; + } + + let mut interpolated_values = Vec::new(); + + let timespan = to.timestamp - from.timestamp; + let tick_count = (timespan / interval).floor() as usize; + + for tick in 1..=tick_count { + let tick_timestamp = from.timestamp + tick as f64 * interval; + if tick_timestamp >= to.timestamp { + break; + } + let alpha = (tick_timestamp - from.timestamp) / timespan; + let value = from.value + alpha * (to.value - from.value); + interpolated_values.push(ScheduledAction { + timestamp: tick_timestamp, + action: Action::SetParam(ParamValue { + timestamp: tick_timestamp, + param: from.param.clone(), + value, + }), + }); + } + + interpolated_values + } +} From 564014a44ca7a6df015b4eadebb6422c81dddb9a Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Sun, 19 Apr 2026 01:46:02 +0200 Subject: [PATCH 08/26] spawn sequence runner thread --- src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index b9ba3d6..9f5b4d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ #![allow(clippy::single_match)] use anyhow::Result; -use ferro_flow::{can, config, db, events, nodes}; +use ferro_flow::{can, config, db, events, nodes, sequence}; fn main() -> Result<()> { let _config = config::load_config()?; @@ -22,6 +22,7 @@ fn main() -> Result<()> { &event_dispatcher, scope, ); + sequence::spawn_sequence_runner_thread(&event_dispatcher, scope); Ok(()) }); From 8f8d4647249341925681647cd95b5d81a06dffc1 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Sun, 19 Apr 2026 02:01:55 +0200 Subject: [PATCH 09/26] remove duplicate sequence loading logic --- src/sequence/sequence_runner.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/sequence/sequence_runner.rs b/src/sequence/sequence_runner.rs index 42bc160..e6641fc 100644 --- a/src/sequence/sequence_runner.rs +++ b/src/sequence/sequence_runner.rs @@ -59,8 +59,9 @@ impl<'scope, 'env> SequenceRunner<'scope, 'env> { return Err(anyhow!("another sequence is still running")); } - let seq = Self::load_sequence(seq_name)?; - let abort_seq = Self::load_sequence(abort_seq_name)?; + let seq_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("sequences"); + let seq = Sequence::load_from_path(&seq_dir.join(seq_name))?; + let abort_seq = Sequence::load_from_path(&seq_dir.join(abort_seq_name))?; let (controller_tx, controller_rx) = mpsc::channel(); let event_dispatcher = self.event_dispatcher; @@ -100,15 +101,6 @@ impl<'scope, 'env> SequenceRunner<'scope, 'env> { .is_some_and(|handle| !handle.thread_handle.is_finished()) } - fn load_sequence(file_name: String) -> Result { - // TODO: implement a better way of loading sequences, from the frontend - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests") - .join("sequences") - .join(format!("{file_name}")); - Sequence::load_from_path(path.as_path()) - } - /// Converts a `Sequence` into a list of `ScheduledAction` by interpolating any values that need it. /// Also, negative timestamps are removed by offsetting by the global start time, /// to make the execution easier by using `Duration` and `Instant` from `std`. From 248d90e34b2439d27473cea3d476fbba45da9984 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Mon, 20 Apr 2026 21:52:16 +0200 Subject: [PATCH 10/26] fix shutdown behaviour of running sequences --- src/sequence/mod.rs | 5 ++++- src/sequence/sequence_runner.rs | 22 ++++++++++++++-------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/sequence/mod.rs b/src/sequence/mod.rs index 6fc2415..8238a92 100644 --- a/src/sequence/mod.rs +++ b/src/sequence/mod.rs @@ -19,7 +19,10 @@ pub fn spawn_sequence_runner_thread<'scope>( while let Ok(event) = rx.recv() { match event { - events::Event::Shutdown => break, + events::Event::Shutdown => { + sequence_runner.control_sequence(SequenceCmd::Shutdown); + break; + } events::Event::StartSequence { seq_name, abort_seq_name, diff --git a/src/sequence/sequence_runner.rs b/src/sequence/sequence_runner.rs index e6641fc..4f3fa6f 100644 --- a/src/sequence/sequence_runner.rs +++ b/src/sequence/sequence_runner.rs @@ -26,11 +26,12 @@ pub enum SequenceCmd { Pause, Resume, Abort, + Shutdown, } pub enum SequenceRunError { Aborted, - HandleDropped, + Shutdown, } pub struct SequenceRunner<'scope, 'env> { @@ -62,6 +63,7 @@ impl<'scope, 'env> SequenceRunner<'scope, 'env> { let seq_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("sequences"); let seq = Sequence::load_from_path(&seq_dir.join(seq_name))?; let abort_seq = Sequence::load_from_path(&seq_dir.join(abort_seq_name))?; + let (controller_tx, controller_rx) = mpsc::channel(); let event_dispatcher = self.event_dispatcher; @@ -72,7 +74,11 @@ impl<'scope, 'env> SequenceRunner<'scope, 'env> { match result { Ok(_) => Ok(()), // execution finished nominal - Err(_) => Self::execute_schedule(abort_schedule, &controller_rx, event_dispatcher), // execution was aborted, start abort sequence + Err(SequenceRunError::Aborted) => { + // execution was aborted, start abort sequence + Self::execute_schedule(abort_schedule, &controller_rx, event_dispatcher) + } + Err(SequenceRunError::Shutdown) => Err(SequenceRunError::Shutdown), // server shutdown } }); @@ -165,6 +171,7 @@ impl<'scope, 'env> SequenceRunner<'scope, 'env> { let mut pause_offset = Duration::ZERO; for action in schedule { + // loop to wait for next action loop { let deadline = origin + Duration::from_secs_f64(action.timestamp) + pause_offset; let now = Instant::now(); @@ -180,10 +187,8 @@ impl<'scope, 'env> SequenceRunner<'scope, 'env> { pause_offset += pause_duration; } Ok(SequenceCmd::Abort) => return Err(SequenceRunError::Aborted), // abort - Err(RecvTimeoutError::Disconnected) => { - return Err(SequenceRunError::HandleDropped); - } - // The caller dropped the handle without explicitly calling cancel(), abort + Ok(SequenceCmd::Shutdown) => return Err(SequenceRunError::Shutdown), // server shutdown + Err(RecvTimeoutError::Disconnected) => return Err(SequenceRunError::Shutdown), // The caller dropped the handle without explicitly calling cancel(), abort Err(RecvTimeoutError::Timeout) => break, // deadline reached, break loop }; } @@ -230,10 +235,11 @@ impl<'scope, 'env> SequenceRunner<'scope, 'env> { let paused_at = Instant::now(); loop { match controller.recv() { - Ok(SequenceCmd::Resume) => return Ok(paused_at.elapsed()), Ok(SequenceCmd::Pause) => continue, // already paused, ignore + Ok(SequenceCmd::Resume) => return Ok(paused_at.elapsed()), Ok(SequenceCmd::Abort) => return Err(SequenceRunError::Aborted), // abort - Err(_) => return Err(SequenceRunError::HandleDropped), // The caller dropped the handle without explicitly calling cancel(), abort + Ok(SequenceCmd::Shutdown) => return Err(SequenceRunError::Shutdown), // server shutdown + Err(_) => return Err(SequenceRunError::Shutdown), // The caller dropped the handle without explicitly calling cancel(), shutdown } } } From 79818ee3a8657015ac027d89ca653972206c46d6 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Wed, 22 Apr 2026 21:53:29 +0200 Subject: [PATCH 11/26] fix `minItems` for hold and set_param step --- sequences/_schema.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sequences/_schema.json b/sequences/_schema.json index 546b3da..2e1c6c4 100644 --- a/sequences/_schema.json +++ b/sequences/_schema.json @@ -122,9 +122,9 @@ }, "hold": { "type": "array", + "minItems": 1, "items": { - "$ref": "#/$defs/holdcondition", - "minItems": 1 + "$ref": "#/$defs/holdcondition" } } } @@ -173,9 +173,9 @@ }, "set_params": { "type": "array", + "minItems": 1, "items": { - "$ref": "#/$defs/setParam", - "minItems": 1 + "$ref": "#/$defs/setParam" } } } From bc25e8ad7cc550d7f98f14153769c6afba828526 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Wed, 22 Apr 2026 22:48:05 +0200 Subject: [PATCH 12/26] add check that sequence start time <= 0 --- src/sequence/sequence_definition.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/sequence/sequence_definition.rs b/src/sequence/sequence_definition.rs index 601b8bb..1fcb044 100644 --- a/src/sequence/sequence_definition.rs +++ b/src/sequence/sequence_definition.rs @@ -32,6 +32,10 @@ impl Sequence { } fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.globals.start_time <= 0.0, + "Invalid start time is after timestamp 0", + ); anyhow::ensure!( self.globals.start_time <= self.globals.end_time, "Invalid start time is after end time", From cecbadda8e3e22778f537af53e72324378ebe1e7 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Wed, 22 Apr 2026 23:12:34 +0200 Subject: [PATCH 13/26] add check that sequence step needs at least 1 action --- src/sequence/sequence_definition.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/sequence/sequence_definition.rs b/src/sequence/sequence_definition.rs index 1fcb044..ed7ea76 100644 --- a/src/sequence/sequence_definition.rs +++ b/src/sequence/sequence_definition.rs @@ -61,6 +61,12 @@ impl Sequence { step.name, ); + anyhow::ensure!( + !step.actions.is_empty(), + "Invalid step '{}' has no actions", + step.name, + ); + for action in &step.actions { if let Action::SetParam(fv) = action { anyhow::ensure!( From 85633dd6daacaa5c261cb613ea9ff51bcba9c7f9 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Thu, 23 Apr 2026 00:14:40 +0200 Subject: [PATCH 14/26] add check that conditional hold needs at least 1 condition --- src/sequence/sequence_definition.rs | 53 +++++++++++++++++------------ 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/src/sequence/sequence_definition.rs b/src/sequence/sequence_definition.rs index ed7ea76..ac76dfe 100644 --- a/src/sequence/sequence_definition.rs +++ b/src/sequence/sequence_definition.rs @@ -68,27 +68,38 @@ impl Sequence { ); for action in &step.actions { - if let Action::SetParam(fv) = action { - anyhow::ensure!( - fv.timestamp >= step.timestamp, - "Invalid action timestamp in step '{}' action '{}', before step timestamp", - step.name, - fv.param, - ); - - anyhow::ensure!( - fv.timestamp <= self.globals.end_time, - "Invalid action timestamp in step '{}' action '{}', after global end time", - step.name, - fv.param, - ); - - anyhow::ensure!( - fv.timestamp <= next.timestamp, - "Invalid action timestamp in step '{}' action '{}', after next step timestamp", - step.name, - fv.param, - ); + match action { + Action::SetParam(fv) => { + anyhow::ensure!( + fv.timestamp >= step.timestamp, + "Invalid action timestamp in step '{}' action '{}', before step timestamp", + step.name, + fv.param, + ); + + anyhow::ensure!( + fv.timestamp <= self.globals.end_time, + "Invalid action timestamp in step '{}' action '{}', after global end time", + step.name, + fv.param, + ); + + anyhow::ensure!( + fv.timestamp <= next.timestamp, + "Invalid action timestamp in step '{}' action '{}', after next step timestamp", + step.name, + fv.param, + ); + } + Action::Hold(hold_mode) => { + if let HoldMode::Conditional(conditions) = hold_mode { + anyhow::ensure!( + !conditions.is_empty(), + "Invalid hold in step '{}' has conditions", + step.name, + ); + } + } } } } From 5dc36f55fd80515b4d9d2b631eb39f7da35acfe9 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Thu, 23 Apr 2026 00:31:03 +0200 Subject: [PATCH 15/26] fixed test sequences syntax --- tests/sequences/invalid_action_times.toml | 8 +++++--- tests/sequences/invalid_global_times.toml | 3 ++- tests/sequences/invalid_interpolation_interval.toml | 1 + tests/sequences/invalid_step_times.toml | 10 +++++++--- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/sequences/invalid_action_times.toml b/tests/sequences/invalid_action_times.toml index 9c1a66f..bb9c5f7 100644 --- a/tests/sequences/invalid_action_times.toml +++ b/tests/sequences/invalid_action_times.toml @@ -1,4 +1,4 @@ -##:schema ../../sequences/_schema.json +#:schema ../../sequences/_schema.json name = "Test Sequence Invalid: Action times" @@ -13,11 +13,13 @@ name = "Step1" description = "Description of Step1" timestamp = 0 set_params = [ - { timestamp = 3, field = "servo1", value = 0 } + { timestamp = 3, param = "servo1", value = 0 } ] [[steps]] name = "Step2" description = "Description of Step2" timestamp = 1 -set_params = [] \ No newline at end of file +set_params = [ + { timestamp = 0, param = "servo1", value = 0 } +] \ No newline at end of file diff --git a/tests/sequences/invalid_global_times.toml b/tests/sequences/invalid_global_times.toml index 40df94b..59d4903 100644 --- a/tests/sequences/invalid_global_times.toml +++ b/tests/sequences/invalid_global_times.toml @@ -1,6 +1,7 @@ -##:schema ../../sequences/_schema.json +#:schema ../../sequences/_schema.json name = "Test Sequence Invalid: global times" +steps = [] [globals] start_time = 5 diff --git a/tests/sequences/invalid_interpolation_interval.toml b/tests/sequences/invalid_interpolation_interval.toml index 53d6766..7046621 100644 --- a/tests/sequences/invalid_interpolation_interval.toml +++ b/tests/sequences/invalid_interpolation_interval.toml @@ -1,6 +1,7 @@ ##:schema ../../sequences/_schema.json name = "Test Sequence Invalid: interpolations interval" +steps = [] [globals] start_time = -2 diff --git a/tests/sequences/invalid_step_times.toml b/tests/sequences/invalid_step_times.toml index 064e235..b628522 100644 --- a/tests/sequences/invalid_step_times.toml +++ b/tests/sequences/invalid_step_times.toml @@ -1,4 +1,4 @@ -##:schema ../../sequences/_schema.json +#:schema ../../sequences/_schema.json name = "Test Sequence Invalid: step times" @@ -12,10 +12,14 @@ interpolation_interval = 0.1 name = "Step1" description = "Description of Step1" timestamp = -9 -set_params = [] +set_params = [ + { timestamp = 0, param = "servo1", value = 0 } +] [[steps]] name = "Step2" description = "Description of Step2" timestamp = 9 -set_params = [] \ No newline at end of file +set_params = [ + { timestamp = 0, param = "servo1", value = 0 } +] \ No newline at end of file From bf6226e0097607e7980f1c436a3d228870f97ba9 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Thu, 23 Apr 2026 00:39:01 +0200 Subject: [PATCH 16/26] fixed tests in sequence definition by checking for error messages --- src/sequence/sequence_definition.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/sequence/sequence_definition.rs b/src/sequence/sequence_definition.rs index ac76dfe..bcf9a74 100644 --- a/src/sequence/sequence_definition.rs +++ b/src/sequence/sequence_definition.rs @@ -267,23 +267,31 @@ mod tests { fn test_load_invalid_global_times() { let seq = Sequence::load_from_path(&sequence_path("invalid_global_times.toml")); assert!(seq.is_err()); + let err_msg = format!("{:#}", seq.unwrap_err()); + assert!(err_msg.contains("Invalid start time is after timestamp")); } #[test] fn test_load_invalid_interpolation_interval() { let seq = Sequence::load_from_path(&sequence_path("invalid_interpolation_interval.toml")); assert!(seq.is_err()); + let err_msg = format!("{:#}", seq.unwrap_err()); + assert!(err_msg.contains("Invalid interpolations interval")); } #[test] fn test_load_invalid_step_times() { let seq = Sequence::load_from_path(&sequence_path("invalid_step_times.toml")); assert!(seq.is_err()); + let err_msg = format!("{:#}", seq.unwrap_err()); + assert!(err_msg.contains("Invalid step timestamp")); } #[test] fn test_load_invalid_action_times() { let seq = Sequence::load_from_path(&sequence_path("invalid_action_times.toml")); assert!(seq.is_err()); + let err_msg = format!("{:#}", seq.unwrap_err()); + assert!(err_msg.contains("Invalid action timestamp")); } } From c81802aa32c2f6e8c40ea92be0f3418b60d1ebc7 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Thu, 23 Apr 2026 02:25:08 +0200 Subject: [PATCH 17/26] improve sequence validation --- src/sequence/sequence_definition.rs | 69 +++++++++++++++++++---------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/src/sequence/sequence_definition.rs b/src/sequence/sequence_definition.rs index bcf9a74..9670eff 100644 --- a/src/sequence/sequence_definition.rs +++ b/src/sequence/sequence_definition.rs @@ -24,9 +24,6 @@ impl Sequence { .try_deserialize() .with_context(|| format!("Failed to deserialize config from {}", path.display()))?; - sequence - .steps - .sort_by(|a, b| a.timestamp.total_cmp(&b.timestamp)); sequence.validate()?; Ok(sequence) } @@ -45,10 +42,12 @@ impl Sequence { "Invalid interpolations interval must be greater 0", ); - for window in self.steps.windows(2) { - let step = &window[0]; - let next = &window[1]; - + // validate each step independently + // steps need to: + // - be sorted by timestamp + // - be in between global start and end time + // - have at least 1 action + for step in &self.steps { anyhow::ensure!( step.timestamp >= self.globals.start_time, "Invalid step timestamp in step '{}', before global start time", @@ -67,28 +66,26 @@ impl Sequence { step.name, ); + // actions need to: + // - be at of after the parent step + // - before the global end time + // conditional holds need to: + // - have at least one condition for action in &step.actions { match action { - Action::SetParam(fv) => { + Action::SetParam(set_param) => { anyhow::ensure!( - fv.timestamp >= step.timestamp, + set_param.timestamp >= step.timestamp, "Invalid action timestamp in step '{}' action '{}', before step timestamp", step.name, - fv.param, + set_param.param, ); anyhow::ensure!( - fv.timestamp <= self.globals.end_time, + set_param.timestamp <= self.globals.end_time, "Invalid action timestamp in step '{}' action '{}', after global end time", step.name, - fv.param, - ); - - anyhow::ensure!( - fv.timestamp <= next.timestamp, - "Invalid action timestamp in step '{}' action '{}', after next step timestamp", - step.name, - fv.param, + set_param.param, ); } Action::Hold(hold_mode) => { @@ -104,6 +101,32 @@ impl Sequence { } } + // validate steps with the following step + // steps need to: + // - be in order + // - have actions that are before the next step + for window in self.steps.windows(2) { + let step = &window[0]; + let next = &window[1]; + + anyhow::ensure!( + step.timestamp < next.timestamp, + "Invalid step '{}', after next step", + step.name, + ); + + for action in &step.actions { + if let Action::SetParam(set_param) = action { + anyhow::ensure!( + set_param.timestamp < next.timestamp, + "Invalid action timestamp in step '{}' action '{}', after next step timestamp", + step.name, + set_param.param, + ); + } + } + } + Ok(()) } } @@ -267,7 +290,7 @@ mod tests { fn test_load_invalid_global_times() { let seq = Sequence::load_from_path(&sequence_path("invalid_global_times.toml")); assert!(seq.is_err()); - let err_msg = format!("{:#}", seq.unwrap_err()); + let err_msg = format!("{:#}", seq.unwrap_err()); assert!(err_msg.contains("Invalid start time is after timestamp")); } @@ -275,7 +298,7 @@ mod tests { fn test_load_invalid_interpolation_interval() { let seq = Sequence::load_from_path(&sequence_path("invalid_interpolation_interval.toml")); assert!(seq.is_err()); - let err_msg = format!("{:#}", seq.unwrap_err()); + let err_msg = format!("{:#}", seq.unwrap_err()); assert!(err_msg.contains("Invalid interpolations interval")); } @@ -283,7 +306,7 @@ mod tests { fn test_load_invalid_step_times() { let seq = Sequence::load_from_path(&sequence_path("invalid_step_times.toml")); assert!(seq.is_err()); - let err_msg = format!("{:#}", seq.unwrap_err()); + let err_msg = format!("{:#}", seq.unwrap_err()); assert!(err_msg.contains("Invalid step timestamp")); } @@ -291,7 +314,7 @@ mod tests { fn test_load_invalid_action_times() { let seq = Sequence::load_from_path(&sequence_path("invalid_action_times.toml")); assert!(seq.is_err()); - let err_msg = format!("{:#}", seq.unwrap_err()); + let err_msg = format!("{:#}", seq.unwrap_err()); assert!(err_msg.contains("Invalid action timestamp")); } } From 0c964ccb1a7505b8dca05484d9e208431c921316 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Thu, 30 Apr 2026 16:58:43 +0200 Subject: [PATCH 18/26] add crate ntest for test timeouts --- Cargo.lock | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + 2 files changed, 56 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 834e24f..4168787 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -671,6 +671,7 @@ dependencies = [ "diesel", "diesel_migrations", "liquidcan_rust", + "ntest", "serde", "serde_json", "socketcan", @@ -1472,6 +1473,39 @@ dependencies = [ "memoffset", ] +[[package]] +name = "ntest" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54d1aa56874c2152c24681ed0df95ee155cc06c5c61b78e2d1e8c0cae8bc5326" +dependencies = [ + "ntest_test_cases", + "ntest_timeout", +] + +[[package]] +name = "ntest_test_cases" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6913433c6319ef9b2df316bb8e3db864a41724c2bb8f12555e07dc4ec69d3db1" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ntest_timeout" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9224be3459a0c1d6e9b0f42ab0e76e98b29aef5aba33c0487dfcf47ea08b5150" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num" version = "0.4.3" @@ -1751,6 +1785,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2571,6 +2614,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6519e0a..d9b4927 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,4 +17,5 @@ config = "0.15.22" [dev-dependencies] diesel_migrations = "2.3" +ntest = "0.9.5" testcontainers = { version = "0.27", features = ["blocking"] } From 0f72f85f935aa7409a6b5e217387235d48cbc1e5 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Thu, 30 Apr 2026 17:02:40 +0200 Subject: [PATCH 19/26] extract sequence validation function into separate functions --- src/sequence/mod.rs | 2 + src/sequence/sequence_definition.rs | 102 ------------------------- src/sequence/sequence_validation.rs | 111 ++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 102 deletions(-) create mode 100644 src/sequence/sequence_validation.rs diff --git a/src/sequence/mod.rs b/src/sequence/mod.rs index 8238a92..9204a36 100644 --- a/src/sequence/mod.rs +++ b/src/sequence/mod.rs @@ -2,6 +2,8 @@ mod sequence_definition; mod sequence_runner; +mod sequence_validation; + use crate::{ events, diff --git a/src/sequence/sequence_definition.rs b/src/sequence/sequence_definition.rs index 9670eff..d1870a5 100644 --- a/src/sequence/sequence_definition.rs +++ b/src/sequence/sequence_definition.rs @@ -27,108 +27,6 @@ impl Sequence { sequence.validate()?; Ok(sequence) } - - fn validate(&self) -> anyhow::Result<()> { - anyhow::ensure!( - self.globals.start_time <= 0.0, - "Invalid start time is after timestamp 0", - ); - anyhow::ensure!( - self.globals.start_time <= self.globals.end_time, - "Invalid start time is after end time", - ); - anyhow::ensure!( - self.globals.interpolation_interval > 0.0, - "Invalid interpolations interval must be greater 0", - ); - - // validate each step independently - // steps need to: - // - be sorted by timestamp - // - be in between global start and end time - // - have at least 1 action - for step in &self.steps { - anyhow::ensure!( - step.timestamp >= self.globals.start_time, - "Invalid step timestamp in step '{}', before global start time", - step.name, - ); - - anyhow::ensure!( - step.timestamp <= self.globals.end_time, - "Invalid step timestamp in step '{}', after global end time", - step.name, - ); - - anyhow::ensure!( - !step.actions.is_empty(), - "Invalid step '{}' has no actions", - step.name, - ); - - // actions need to: - // - be at of after the parent step - // - before the global end time - // conditional holds need to: - // - have at least one condition - for action in &step.actions { - match action { - Action::SetParam(set_param) => { - anyhow::ensure!( - set_param.timestamp >= step.timestamp, - "Invalid action timestamp in step '{}' action '{}', before step timestamp", - step.name, - set_param.param, - ); - - anyhow::ensure!( - set_param.timestamp <= self.globals.end_time, - "Invalid action timestamp in step '{}' action '{}', after global end time", - step.name, - set_param.param, - ); - } - Action::Hold(hold_mode) => { - if let HoldMode::Conditional(conditions) = hold_mode { - anyhow::ensure!( - !conditions.is_empty(), - "Invalid hold in step '{}' has conditions", - step.name, - ); - } - } - } - } - } - - // validate steps with the following step - // steps need to: - // - be in order - // - have actions that are before the next step - for window in self.steps.windows(2) { - let step = &window[0]; - let next = &window[1]; - - anyhow::ensure!( - step.timestamp < next.timestamp, - "Invalid step '{}', after next step", - step.name, - ); - - for action in &step.actions { - if let Action::SetParam(set_param) = action { - anyhow::ensure!( - set_param.timestamp < next.timestamp, - "Invalid action timestamp in step '{}' action '{}', after next step timestamp", - step.name, - set_param.param, - ); - } - } - } - - Ok(()) - } } #[derive(Debug, Deserialize)] diff --git a/src/sequence/sequence_validation.rs b/src/sequence/sequence_validation.rs new file mode 100644 index 0000000..cc47c2a --- /dev/null +++ b/src/sequence/sequence_validation.rs @@ -0,0 +1,111 @@ +use crate::sequence::sequence_definition::{Action, HoldMode, Sequence, Step, TimedAction}; + +impl Sequence { + pub fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.globals.start_time <= 0., + "Invalid start time is after timestamp 0", + ); + anyhow::ensure!( + self.globals.start_time <= self.globals.end_time, + "Invalid start time is after end time", + ); + anyhow::ensure!( + !self.steps.is_empty(), + "Invalid sequence '{}' has no steps", + self.name, + ); + + for step in &self.steps { + self.validate_step(step)?; + } + + // validate steps with the following step + // steps need to: + // - be in order + // - have actions that are before the next step + for window in self.steps.windows(2) { + let step = &window[0]; + let next = &window[1]; + + anyhow::ensure!( + step.timestamp < next.timestamp, + "Invalid step '{}', after next step", + step.name, + ); + + for timed_action in &step.actions { + anyhow::ensure!( + timed_action.timestamp < next.timestamp, + "Invalid action timestamp in step '{}' action '{:?}', after next step timestamp", + step.name, + timed_action.action, + ); + } + } + + Ok(()) + } + + // validate each step independently + // steps need to: + // - be sorted by timestamp + // - be in between global start and end time + // - have at least 1 action + fn validate_step(&self, step: &Step) -> anyhow::Result<()> { + anyhow::ensure!( + step.timestamp >= self.globals.start_time, + "Invalid step timestamp in step '{}', before global start time", + step.name, + ); + + anyhow::ensure!( + step.timestamp <= self.globals.end_time, + "Invalid step timestamp in step '{}', after global end time", + step.name, + ); + + anyhow::ensure!( + !step.actions.is_empty(), + "Invalid step '{}' has no actions", + step.name, + ); + + for timed_action in &step.actions { + self.validate_timed_action(timed_action, step)?; + } + Ok(()) + } + + // actions need to: + // - be at of after the parent step + // - before the global end time + // conditional holds need to: + // - have at least one condition + fn validate_timed_action( + &self, + timed_action: &TimedAction, + parent_step: &Step, + ) -> anyhow::Result<()> { + anyhow::ensure!( + timed_action.timestamp >= parent_step.timestamp, + "Invalid action timestamp in step '{}' action '{:?}', before step timestamp", + parent_step.name, + timed_action.action, + ); + anyhow::ensure!( + timed_action.timestamp <= self.globals.end_time, + "Invalid action timestamp in step '{}' action '{:?}', after global end time", + parent_step.name, + timed_action.action, + ); + if let Action::Hold(HoldMode::Conditional(conditions)) = &timed_action.action { + anyhow::ensure!( + !conditions.is_empty(), + "Invalid hold in step '{}' has no conditions", + parent_step.name, + ); + } + Ok(()) + } +} From 628f000541084032108a8b48db06325219031a66 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Thu, 30 Apr 2026 17:06:49 +0200 Subject: [PATCH 20/26] rework sequence building and running + tests --- src/sequence/mod.rs | 31 ++- src/sequence/sequence_builder.rs | 167 +++++++++++++++++ src/sequence/sequence_definition.rs | 100 ++++++---- src/sequence/sequence_runner.rs | 270 +++++++++++++++------------ tests/sequences/abort.toml | 16 ++ tests/sequences/valid_hold.toml | 14 ++ tests/sequences/valid_set_param.toml | 18 ++ 7 files changed, 453 insertions(+), 163 deletions(-) create mode 100644 src/sequence/sequence_builder.rs create mode 100644 tests/sequences/abort.toml create mode 100644 tests/sequences/valid_hold.toml create mode 100644 tests/sequences/valid_set_param.toml diff --git a/src/sequence/mod.rs b/src/sequence/mod.rs index 9204a36..807bec2 100644 --- a/src/sequence/mod.rs +++ b/src/sequence/mod.rs @@ -1,13 +1,18 @@ //! Code for managing and running sequences. +mod sequence_builder; mod sequence_definition; mod sequence_runner; mod sequence_validation; +use std::path::Path; use crate::{ events, - sequence::sequence_runner::{SequenceCmd, SequenceRunner}, + sequence::{ + sequence_definition::Sequence, + sequence_runner::{SequenceCmd, SequenceRunner}, + }, }; pub fn spawn_sequence_runner_thread<'scope>( @@ -29,7 +34,29 @@ pub fn spawn_sequence_runner_thread<'scope>( seq_name, abort_seq_name, } => { - let result = sequence_runner.run_sequence(seq_name, abort_seq_name); + // TODO: replace with loading sequences from the frontend + let seq_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("sequences"); + let seq = Sequence::load_from_path(&seq_dir.join(&seq_name)); + let abort_seq = Sequence::load_from_path(&seq_dir.join(&abort_seq_name)); + + let seq = match seq { + Ok(seq) => seq, + Err(err) => { + eprintln!("Error while loading sequence '{seq_name}': {err:#}"); + continue; + } + }; + let abort_seq = match abort_seq { + Ok(abort_seq) => abort_seq, + Err(err) => { + eprintln!( + "Error while loading abort sequence '{abort_seq_name}': {err:#}" + ); + continue; + } + }; + + let result = sequence_runner.run_sequence(seq, abort_seq); if let Err(err) = result { eprintln!("Error while running sequence: {err:#}"); } diff --git a/src/sequence/sequence_builder.rs b/src/sequence/sequence_builder.rs new file mode 100644 index 0000000..408f993 --- /dev/null +++ b/src/sequence/sequence_builder.rs @@ -0,0 +1,167 @@ +use crate::sequence::sequence_definition::{ + Action, InterpolationMode, ParamState, Sequence, TimedAction, TimestampSec, +}; +use std::{ + collections::{HashMap, VecDeque}, + time::Duration, +}; + +#[derive(Debug, Clone, Copy)] +struct TimedValue { + timestamp: TimestampSec, + value: f64, +} + +pub fn flatten_and_interpolate(seq: Sequence) -> Vec { + let mut final_actions = Vec::with_capacity(seq.steps.len()); + let mut last_param_states: HashMap = HashMap::new(); + + let flattened_actions = seq.steps.into_iter().flat_map(|step| step.actions); + + for mut timed_action in flattened_actions { + // offset timestamps by global start time to remove negative times + timed_action.timestamp -= seq.globals.start_time; + + let Action::SetParam(param_state) = &timed_action.action else { + // Action is not a SetParam action + final_actions.push(timed_action); + continue; + }; + + let interpolation_mode = seq.globals.interpolations.get(¶m_state.param); + let Some(&InterpolationMode::Linear) = interpolation_mode else { + // Action does not need to be interpolated + final_actions.push(timed_action); + continue; + }; + + let new_param_value = TimedValue { + timestamp: timed_action.timestamp, + value: param_state.value, + }; + if let Some(last_param_value) = last_param_states.remove(¶m_state.param) { + let mut interpolated = interpolate_linear( + last_param_value, + new_param_value, + seq.globals.interpolation_interval, + ); + // Remove the first and last element since they are already contained in the sequence definition + interpolated.pop_front(); + interpolated.pop_back(); + let interpolated_actions = interpolated.into_iter().map(|timed_value| TimedAction { + timestamp: timed_value.timestamp, + action: Action::SetParam(ParamState { + param: param_state.param.clone(), + value: timed_value.value, + }), + }); + + final_actions.extend(interpolated_actions); + } + last_param_states.insert(param_state.param.clone(), new_param_value); + final_actions.push(timed_action); + } + + final_actions +} + +/// Interpolates the values using the interval, if the values of `from` and `to` are different. +/// +/// Returns a list of `TimedValues`, including the `from` and `to` values (inclusive). +/// Returns an empty list, if `from` and `to` have the same value or the interpolation interval is to large (interval <= timespan/2). +fn interpolate_linear( + from: TimedValue, + to: TimedValue, + interval: Duration, +) -> VecDeque { + if from.value == to.value { + return VecDeque::new(); + } + + let interval = interval.as_secs_f64(); + let timespan = to.timestamp - from.timestamp; + + if interval > timespan / 2. { + return VecDeque::new(); + } + + let mut interpolated_values = VecDeque::new(); + let tick_count = (timespan / interval).floor() as usize; + + for tick in 0..=tick_count { + let tick_timestamp = from.timestamp + tick as f64 * interval; + let alpha = (tick_timestamp - from.timestamp) / timespan; + let value = from.value + alpha * (to.value - from.value); + interpolated_values.push_back(TimedValue { + timestamp: tick_timestamp, + value, + }); + } + + interpolated_values +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_interpolate_linear() { + let from = TimedValue { + timestamp: 0., + value: 0., + }; + let to = TimedValue { + timestamp: 10., + value: 100., + }; + let interval = Duration::from_secs(2); + let results = interpolate_linear(from, to, interval); + + let timestamps = [0., 2., 4., 6., 8., 10.]; + let values = [0., 20., 40., 60., 80., 100.]; + + assert_eq!(results.len(), 6); + + for (i, result) in results.iter().enumerate() { + assert_eq!(result.timestamp, timestamps[i]); + assert_eq!(result.value, values[i]); + } + } + + #[test] + fn test_interpolate_linear_same_value() { + let from = TimedValue { + timestamp: 0., + value: 100., + }; + let to = TimedValue { + timestamp: 10., + value: 100., + }; + let interval = Duration::from_secs(2); + let results = interpolate_linear(from, to, interval); + assert!( + results.is_empty(), + "Should return empty vec because values are identical" + ); + } + + #[test] + fn test_interpolate_interval_too_large() { + let from = TimedValue { + timestamp: 0., + value: 0., + }; + let to = TimedValue { + timestamp: 10., + value: 100., + }; + let interval = Duration::from_secs(7); + let results = interpolate_linear(from, to, interval); + assert!( + results.is_empty(), + "Should return empty vec because interpolation interval is too large" + ); + } +} diff --git a/src/sequence/sequence_definition.rs b/src/sequence/sequence_definition.rs index d1870a5..9ec11f2 100644 --- a/src/sequence/sequence_definition.rs +++ b/src/sequence/sequence_definition.rs @@ -1,12 +1,12 @@ #![allow(unused)] use anyhow::{Context, Result}; -use std::{collections::HashMap, path::Path}; +use std::{collections::HashMap, path::Path, time::Duration}; use config::{Config, File}; use serde::{Deserialize, Deserializer, de}; -type TimestampSec = f64; +pub type TimestampSec = f64; #[derive(Debug, Deserialize)] pub struct Sequence { @@ -20,7 +20,7 @@ impl Sequence { pub fn load_from_path(path: &Path) -> Result { let config = Config::builder().add_source(File::from(path)).build()?; - let mut sequence: Self = config + let sequence: Self = config .try_deserialize() .with_context(|| format!("Failed to deserialize config from {}", path.display()))?; @@ -33,11 +33,26 @@ impl Sequence { pub struct Globals { pub start_time: TimestampSec, pub end_time: TimestampSec, - pub interpolation_interval: TimestampSec, + #[serde(deserialize_with = "duration_from_f64")] + pub interpolation_interval: Duration, #[serde(default)] pub interpolations: HashMap, } +fn duration_from_f64<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let secs = f64::deserialize(deserializer)?; + if !secs.is_finite() { + return Err(serde::de::Error::custom("duration cannot infinite or NaN")); + } + if secs.is_sign_negative() { + return Err(serde::de::Error::custom("duration cannot be negative")); + } + Ok(Duration::from_secs_f64(secs)) +} + #[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum InterpolationMode { @@ -51,56 +66,70 @@ pub struct Step { pub name: String, pub description: Option, pub timestamp: TimestampSec, - pub actions: Vec, + pub actions: Vec, } impl<'de> Deserialize<'de> for Step { fn deserialize>(d: D) -> Result { + #[derive(Deserialize)] + struct RawParamState { + #[serde(rename = "timestamp")] + relative_timestamp: TimestampSec, + param: String, + value: f64, + } #[derive(Deserialize)] struct RawStep { name: String, - #[serde(default)] description: Option, timestamp: TimestampSec, - #[serde(default)] - set_params: Vec, + set_params: Option>, hold: Option, } - let raw = RawStep::deserialize(d)?; - let actions = match (raw.hold, raw.set_params.is_empty()) { - (Some(mode), true) => vec![Action::Hold(mode)], - (Some(_), false) => { - return Err(de::Error::custom( - "step can either have `hold` or `set_Params`", - )); - } - (None, _) => raw - .set_params + let raw_step = RawStep::deserialize(d)?; + let actions = match (raw_step.hold, raw_step.set_params) { + (Some(hold), None) => vec![TimedAction { + timestamp: raw_step.timestamp, + action: Action::Hold(hold), + }], + (None, Some(set_param)) => set_param .into_iter() - .map(|fv| { - Action::SetParam(ParamValue { - timestamp: raw.timestamp + fv.timestamp, - param: fv.param, - value: fv.value, - }) + .map(|param_state| TimedAction { + // offset relative timestamps to global timestamps + timestamp: raw_step.timestamp + param_state.relative_timestamp, + action: Action::SetParam(ParamState { + param: param_state.param, + value: param_state.value, + }), }) .collect(), + _ => { + return Err(de::Error::custom( + "step must have exactly `hold` or `set_params`", + )); + } }; Ok(Step { - name: raw.name, - description: raw.description, - timestamp: raw.timestamp, + name: raw_step.name, + description: raw_step.description, + timestamp: raw_step.timestamp, actions, }) } } -#[derive(Debug, Deserialize)] +#[derive(Debug)] +pub struct TimedAction { + pub timestamp: TimestampSec, + pub action: Action, +} + +#[derive(Debug)] pub enum Action { Hold(HoldMode), - SetParam(ParamValue), + SetParam(ParamState), } #[derive(Debug)] @@ -127,15 +156,8 @@ impl<'de> Deserialize<'de> for HoldMode { } } -#[derive(Debug)] -pub struct ScheduledAction { - pub timestamp: TimestampSec, - pub action: Action, -} - #[derive(Debug, Deserialize, Clone)] -pub struct ParamValue { - pub timestamp: TimestampSec, +pub struct ParamState { pub param: String, pub value: f64, } @@ -150,7 +172,7 @@ pub struct HoldCondition { impl HoldCondition { pub fn evaluate(&self) -> bool { // TODO: evaluate condition if it's true based on the actual field values - todo!() + todo!("evaluate condition if it's true based on the actual field values") } } @@ -197,7 +219,7 @@ mod tests { let seq = Sequence::load_from_path(&sequence_path("invalid_interpolation_interval.toml")); assert!(seq.is_err()); let err_msg = format!("{:#}", seq.unwrap_err()); - assert!(err_msg.contains("Invalid interpolations interval")); + assert!(err_msg.contains("duration cannot be negative")); } #[test] diff --git a/src/sequence/sequence_runner.rs b/src/sequence/sequence_runner.rs index 4f3fa6f..5d1e786 100644 --- a/src/sequence/sequence_runner.rs +++ b/src/sequence/sequence_runner.rs @@ -1,9 +1,6 @@ -#[allow(unused)] -use anyhow::Result; -use anyhow::anyhow; +use anyhow::{Result, anyhow}; use std::{ - collections::HashMap, - path::Path, + panic, sync::mpsc::{self, Receiver, RecvTimeoutError}, thread, time::{Duration, Instant}, @@ -11,8 +8,9 @@ use std::{ use crate::{ events::{self, EventDispatcher}, - sequence::sequence_definition::{ - Action, HoldMode, InterpolationMode, ParamValue, ScheduledAction, Sequence, + sequence::{ + sequence_builder::flatten_and_interpolate, + sequence_definition::{Action, HoldMode, Sequence, TimedAction}, }, }; @@ -32,6 +30,7 @@ pub enum SequenceCmd { pub enum SequenceRunError { Aborted, Shutdown, + Panicked, } pub struct SequenceRunner<'scope, 'env> { @@ -55,30 +54,37 @@ impl<'scope, 'env> SequenceRunner<'scope, 'env> { /// Run a sequence and an abort sequence if the sequence is aborted. /// /// Returns an error if another sequence is running. - pub fn run_sequence(&mut self, seq_name: String, abort_seq_name: String) -> Result<()> { + pub fn run_sequence(&mut self, seq: Sequence, abort_seq: Sequence) -> Result<()> { if self.is_sequence_running() { return Err(anyhow!("another sequence is still running")); } - - let seq_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("sequences"); - let seq = Sequence::load_from_path(&seq_dir.join(seq_name))?; - let abort_seq = Sequence::load_from_path(&seq_dir.join(abort_seq_name))?; - let (controller_tx, controller_rx) = mpsc::channel(); let event_dispatcher = self.event_dispatcher; let thread_handle = self.scope.spawn(move || { - let schedule = Self::build_schedule(seq); - let abort_schedule = Self::build_schedule(abort_seq); - let result = Self::execute_schedule(schedule, &controller_rx, event_dispatcher); - - match result { - Ok(_) => Ok(()), // execution finished nominal - Err(SequenceRunError::Aborted) => { - // execution was aborted, start abort sequence - Self::execute_schedule(abort_schedule, &controller_rx, event_dispatcher) + let panic_result = panic::catch_unwind( || { + let seq_name = seq.name.clone(); + let abort_seq_name = abort_seq.name.clone(); + + let schedule = flatten_and_interpolate(seq); + let abort_schedule = flatten_and_interpolate(abort_seq); + let result = Self::execute_actions(schedule, &controller_rx, event_dispatcher); + + if let Err(SequenceRunError::Aborted) = &result { + // TODO: add logging to the frontend + eprintln!("Execution of sequence '{seq_name}' was aborted, now running abort sequence '{abort_seq_name}'"); + let _ = Self::execute_actions(abort_schedule, &controller_rx, event_dispatcher); + } + result + }); + + match panic_result { + Ok(sequence_result) => sequence_result, + Err(err) => { + // TODO: add logging to the frontend + eprintln!("Sequence Runner thread panicked with error '{:?}'", err); + Err(SequenceRunError::Panicked) } - Err(SequenceRunError::Shutdown) => Err(SequenceRunError::Shutdown), // server shutdown } }); @@ -107,73 +113,23 @@ impl<'scope, 'env> SequenceRunner<'scope, 'env> { .is_some_and(|handle| !handle.thread_handle.is_finished()) } - /// Converts a `Sequence` into a list of `ScheduledAction` by interpolating any values that need it. - /// Also, negative timestamps are removed by offsetting by the global start time, - /// to make the execution easier by using `Duration` and `Instant` from `std`. - fn build_schedule(seq: Sequence) -> Vec { - let mut scheduled_actions = Vec::new(); - let mut last_param_values = HashMap::new(); - let interpolations = &seq.globals.interpolations; - - for step in seq.steps { - for action in step.actions { - match action { - Action::Hold(_) => { - scheduled_actions.push(ScheduledAction { - // Offset timestamp to remove negative timestamps - timestamp: step.timestamp - seq.globals.start_time, - action, - }); - } - Action::SetParam(mut pv) => { - // Offset timestamp to remove negative timestamps - pv.timestamp -= seq.globals.start_time; - - let interpolation_mode = - interpolations.get(&pv.param).copied().unwrap_or_default(); - - if interpolation_mode == InterpolationMode::Linear { - // If previous value for this parameter existed and values changed, interpolate values - if let Some(last) = last_param_values.get(&pv.param) { - let mut points = Self::interpolate_linear( - last, - &pv, - seq.globals.interpolation_interval, - ); - scheduled_actions.append(&mut points); - } - last_param_values.insert(pv.param.clone(), pv.clone()); - } - - scheduled_actions.push(ScheduledAction { - timestamp: pv.timestamp, - action: Action::SetParam(pv), - }); - } - } - } - } - - scheduled_actions.sort_by(|f1, f2| f1.timestamp.total_cmp(&f2.timestamp)); - scheduled_actions - } - - /// Executes a list of `ScheduledAction`, created using `build_schedule`. Can be paused, resumed and aborted using the receiver parameter. + /// Executes a list of `TimedActions`. Can be paused, resumed and aborted using the receiver parameter. /// /// Returns `Ok`, if the execution finished successfully or a `SequenceRunError`, /// if the execution was aborted or the controller to control the sequence was dropped. - fn execute_schedule( - schedule: Vec, + fn execute_actions( + schedule: Vec, controller: &Receiver, - event_dispatcher: &EventDispatcher, + #[allow(unused)] event_dispatcher: &EventDispatcher, ) -> Result<(), SequenceRunError> { let origin = Instant::now(); let mut pause_offset = Duration::ZERO; - for action in schedule { + for timed_action in schedule { // loop to wait for next action loop { - let deadline = origin + Duration::from_secs_f64(action.timestamp) + pause_offset; + let deadline = + origin + Duration::from_secs_f64(timed_action.timestamp) + pause_offset; let now = Instant::now(); if now >= deadline { break; @@ -193,7 +149,7 @@ impl<'scope, 'env> SequenceRunner<'scope, 'env> { }; } - match action.action { + match timed_action.action { Action::Hold(mode) => { let should_hold = match mode { HoldMode::Always => true, @@ -209,18 +165,18 @@ impl<'scope, 'env> SequenceRunner<'scope, 'env> { } } - #[allow(unused)] Action::SetParam(_param_value) => { // TODO: send can message with correct data - event_dispatcher.dispatch(events::Event::SendCanMessage { - receiver_node_id: todo!(), - message: liquidcan::CanMessage::ParameterSetReq { - payload: liquidcan::payloads::ParameterSetReqPayload { - parameter_id: todo!(), - value: todo!(), - }, - }, - }); + // event_dispatcher.dispatch(events::Event::SendCanMessage { + // receiver_node_id: todo!(), + // #[allow(unreachable_code)] + // message: liquidcan::CanMessage::ParameterSetReq { + // payload: liquidcan::payloads::ParameterSetReqPayload { + // parameter_id: todo!(), + // value: todo!(), + // }, + // }, + // }); } } } @@ -243,41 +199,111 @@ impl<'scope, 'env> SequenceRunner<'scope, 'env> { } } } +} - /// Interpolates the values using the interval, if `from` and `to` are different. - /// The first value in the interpolation is omitted and not returned, - /// because it's handled in `build_schedule` already. - fn interpolate_linear( - from: &ParamValue, - to: &ParamValue, - interval: f64, - ) -> Vec { - if from.value == to.value { - return vec![]; - } +#[cfg(test)] +mod tests { + use super::*; + use ntest::timeout; + use std::{path::Path, thread, time::Duration}; + + fn load_seq(name: &str) -> Sequence { + let seq_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("sequences"); + Sequence::load_from_path(&seq_dir.join(name)).expect("failed to load test sequence") + } - let mut interpolated_values = Vec::new(); + #[test] + #[timeout(2000)] + fn test_run_sequence_execution_completes() { + let dispatcher = events::EventDispatcher::new(); - let timespan = to.timestamp - from.timestamp; - let tick_count = (timespan / interval).floor() as usize; + thread::scope(|scope| { + let mut runner = SequenceRunner::new(&dispatcher, scope); - for tick in 1..=tick_count { - let tick_timestamp = from.timestamp + tick as f64 * interval; - if tick_timestamp >= to.timestamp { - break; - } - let alpha = (tick_timestamp - from.timestamp) / timespan; - let value = from.value + alpha * (to.value - from.value); - interpolated_values.push(ScheduledAction { - timestamp: tick_timestamp, - action: Action::SetParam(ParamValue { - timestamp: tick_timestamp, - param: from.param.clone(), - value, - }), - }); - } + let seq = load_seq("valid_set_param.toml"); + let abort_seq = load_seq("abort.toml"); + + runner + .run_sequence(seq, abort_seq) + .expect("sequence should start since no other sequence is running"); + + // Wait for sequence to finish + let handle = runner + .last_sequence_handle + .expect("sequence started so handle should exist"); + + let join_result = handle.thread_handle.join(); + assert!(join_result.is_ok()); + let sequence_result = join_result.unwrap(); + assert!(sequence_result.is_ok()) + }); + } + + #[test] + #[timeout(2000)] + fn test_run_sequence_hold_and_resume_completes() { + let dispatcher = events::EventDispatcher::new(); - interpolated_values + thread::scope(|scope| { + let mut runner = SequenceRunner::new(&dispatcher, scope); + + let seq = load_seq("valid_hold.toml"); + let abort_seq = load_seq("abort.toml"); + + runner + .run_sequence(seq, abort_seq) + .expect("sequence should start since no other sequence is running"); + + // Wait for hold and resume + thread::sleep(Duration::from_millis(1200)); + runner.control_sequence(SequenceCmd::Resume); + + // Wait for sequence to finish + let handle = runner + .last_sequence_handle + .expect("sequence started so handle should exist"); + + let join_result = handle.thread_handle.join(); + assert!(join_result.is_ok()); + let sequence_result = join_result.unwrap(); + assert!(sequence_result.is_ok()) + }); + } + + #[test] + #[timeout(2000)] + fn test_run_sequence_abort() { + let dispatcher = events::EventDispatcher::new(); + + thread::scope(|scope| { + let mut runner = SequenceRunner::new(&dispatcher, scope); + + let seq = load_seq("valid_set_param.toml"); + let abort_seq = load_seq("abort.toml"); + + runner + .run_sequence(seq, abort_seq) + .expect("sequence should start since no other sequence is running"); + + // Wait for hold and resume + thread::sleep(Duration::from_millis(500)); + runner.control_sequence(SequenceCmd::Abort); + + // Wait for sequence to finish + let handle = runner + .last_sequence_handle + .expect("sequence started so handle should exist"); + + let join_result = handle.thread_handle.join(); + assert!(join_result.is_ok()); + let sequence_result = join_result.unwrap(); + assert!(sequence_result.is_err()); + assert!(matches!( + sequence_result.unwrap_err(), + SequenceRunError::Aborted + )); + }); } } diff --git a/tests/sequences/abort.toml b/tests/sequences/abort.toml new file mode 100644 index 0000000..722b2b5 --- /dev/null +++ b/tests/sequences/abort.toml @@ -0,0 +1,16 @@ +#:schema ../../sequences/_schema.json + +name = "Test Abort Sequence Valid" + +[globals] +start_time = 0 +end_time = 1 +interpolations = {} +interpolation_interval = 0.1 + +[[steps]] +name = "Step1" +timestamp = 0 +set_params = [ + { timestamp = 0.0, param = "abort", value = 1 }, +] \ No newline at end of file diff --git a/tests/sequences/valid_hold.toml b/tests/sequences/valid_hold.toml new file mode 100644 index 0000000..fd6289b --- /dev/null +++ b/tests/sequences/valid_hold.toml @@ -0,0 +1,14 @@ +#:schema ../../sequences/_schema.json + +name = "Test Sequence Valid" + +[globals] +start_time = 0 +end_time = 2 +interpolations = {} +interpolation_interval = 0.1 + +[[steps]] +name = "Hold1" +timestamp = 1.0 +hold = "always" \ No newline at end of file diff --git a/tests/sequences/valid_set_param.toml b/tests/sequences/valid_set_param.toml new file mode 100644 index 0000000..a7ba6cc --- /dev/null +++ b/tests/sequences/valid_set_param.toml @@ -0,0 +1,18 @@ +#:schema ../../sequences/_schema.json + +name = "Test Sequence Valid" + +[globals] +start_time = 0 +end_time = 2 +interpolations = {} +interpolation_interval = 0.1 + +[[steps]] +name = "Step1" +description = "Description of Step1" +timestamp = 1.0 +set_params = [ + { timestamp = 0.0, param = "servo1", value = 12 }, + { timestamp = 0.5, param = "valve1", value = 12 }, +] From dc2f9c2b0d37a8d2c881215dfc1e22defffdefa9 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Mon, 4 May 2026 16:00:16 +0200 Subject: [PATCH 21/26] change Event to contain the sequence, not just the sequence name --- src/events/mod.rs | 5 ++-- src/sequence/mod.rs | 39 +++-------------------------- src/sequence/sequence_definition.rs | 16 ++++++------ 3 files changed, 15 insertions(+), 45 deletions(-) diff --git a/src/events/mod.rs b/src/events/mod.rs index 1a94193..891a322 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -2,6 +2,7 @@ use std::sync::{RwLock, mpsc::Sender}; +use crate::sequence::Sequence; use liquidcan::{CanMessage, CanMessageId}; use socketcan::CanAnyFrame; @@ -23,8 +24,8 @@ pub enum Event { frame: CanAnyFrame, }, StartSequence { - seq_name: String, - abort_seq_name: String, + seq: Sequence, + abort_seq: Sequence, }, PauseSequence, ResumeSequence, diff --git a/src/sequence/mod.rs b/src/sequence/mod.rs index 807bec2..86f7851 100644 --- a/src/sequence/mod.rs +++ b/src/sequence/mod.rs @@ -5,15 +5,9 @@ mod sequence_definition; mod sequence_runner; mod sequence_validation; -use std::path::Path; - -use crate::{ - events, - sequence::{ - sequence_definition::Sequence, - sequence_runner::{SequenceCmd, SequenceRunner}, - }, -}; +use crate::events; +pub use sequence_definition::Sequence; +use sequence_runner::{SequenceCmd, SequenceRunner}; pub fn spawn_sequence_runner_thread<'scope>( event_dispatcher: &'scope events::EventDispatcher, @@ -30,32 +24,7 @@ pub fn spawn_sequence_runner_thread<'scope>( sequence_runner.control_sequence(SequenceCmd::Shutdown); break; } - events::Event::StartSequence { - seq_name, - abort_seq_name, - } => { - // TODO: replace with loading sequences from the frontend - let seq_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("sequences"); - let seq = Sequence::load_from_path(&seq_dir.join(&seq_name)); - let abort_seq = Sequence::load_from_path(&seq_dir.join(&abort_seq_name)); - - let seq = match seq { - Ok(seq) => seq, - Err(err) => { - eprintln!("Error while loading sequence '{seq_name}': {err:#}"); - continue; - } - }; - let abort_seq = match abort_seq { - Ok(abort_seq) => abort_seq, - Err(err) => { - eprintln!( - "Error while loading abort sequence '{abort_seq_name}': {err:#}" - ); - continue; - } - }; - + events::Event::StartSequence { seq, abort_seq } => { let result = sequence_runner.run_sequence(seq, abort_seq); if let Err(err) = result { eprintln!("Error while running sequence: {err:#}"); diff --git a/src/sequence/sequence_definition.rs b/src/sequence/sequence_definition.rs index 9ec11f2..bab4391 100644 --- a/src/sequence/sequence_definition.rs +++ b/src/sequence/sequence_definition.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Deserializer, de}; pub type TimestampSec = f64; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct Sequence { pub name: String, pub globals: Globals, @@ -29,7 +29,7 @@ impl Sequence { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct Globals { pub start_time: TimestampSec, pub end_time: TimestampSec, @@ -61,7 +61,7 @@ pub enum InterpolationMode { Linear, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Step { pub name: String, pub description: Option, @@ -120,19 +120,19 @@ impl<'de> Deserialize<'de> for Step { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct TimedAction { pub timestamp: TimestampSec, pub action: Action, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum Action { Hold(HoldMode), SetParam(ParamState), } -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum HoldMode { Always, Conditional(Vec), @@ -162,7 +162,7 @@ pub struct ParamState { pub value: f64, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct HoldCondition { field: String, is: FieldComparison, @@ -176,7 +176,7 @@ impl HoldCondition { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub enum FieldComparison { Equal, From bd091aa9b31b8e71be25e33ef6b2a577c572ae2b Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Mon, 4 May 2026 16:15:26 +0200 Subject: [PATCH 22/26] change the `interpolate_linear` function to return an Option instead of an empty vec on error --- src/sequence/sequence_builder.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/sequence/sequence_builder.rs b/src/sequence/sequence_builder.rs index 408f993..39b8650 100644 --- a/src/sequence/sequence_builder.rs +++ b/src/sequence/sequence_builder.rs @@ -39,12 +39,13 @@ pub fn flatten_and_interpolate(seq: Sequence) -> Vec { timestamp: timed_action.timestamp, value: param_state.value, }; - if let Some(last_param_value) = last_param_states.remove(¶m_state.param) { - let mut interpolated = interpolate_linear( + if let Some(last_param_value) = last_param_states.remove(¶m_state.param) + && let Some(mut interpolated) = interpolate_linear( last_param_value, new_param_value, seq.globals.interpolation_interval, - ); + ) + { // Remove the first and last element since they are already contained in the sequence definition interpolated.pop_front(); interpolated.pop_back(); @@ -73,16 +74,16 @@ fn interpolate_linear( from: TimedValue, to: TimedValue, interval: Duration, -) -> VecDeque { +) -> Option> { if from.value == to.value { - return VecDeque::new(); + return None; } let interval = interval.as_secs_f64(); let timespan = to.timestamp - from.timestamp; if interval > timespan / 2. { - return VecDeque::new(); + return None; } let mut interpolated_values = VecDeque::new(); @@ -98,7 +99,7 @@ fn interpolate_linear( }); } - interpolated_values + Some(interpolated_values) } #[cfg(test)] @@ -121,6 +122,8 @@ mod tests { let timestamps = [0., 2., 4., 6., 8., 10.]; let values = [0., 20., 40., 60., 80., 100.]; + assert!(results.is_some()); + let results = results.unwrap(); assert_eq!(results.len(), 6); for (i, result) in results.iter().enumerate() { @@ -142,7 +145,7 @@ mod tests { let interval = Duration::from_secs(2); let results = interpolate_linear(from, to, interval); assert!( - results.is_empty(), + results.is_none(), "Should return empty vec because values are identical" ); } @@ -160,7 +163,7 @@ mod tests { let interval = Duration::from_secs(7); let results = interpolate_linear(from, to, interval); assert!( - results.is_empty(), + results.is_none(), "Should return empty vec because interpolation interval is too large" ); } From db542a4d7e4513ea35df1583b887380ba2fd8b14 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Mon, 4 May 2026 16:16:57 +0200 Subject: [PATCH 23/26] fix typo in `Duration` deserialization error message --- src/sequence/sequence_definition.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sequence/sequence_definition.rs b/src/sequence/sequence_definition.rs index bab4391..ee220ee 100644 --- a/src/sequence/sequence_definition.rs +++ b/src/sequence/sequence_definition.rs @@ -45,7 +45,7 @@ where { let secs = f64::deserialize(deserializer)?; if !secs.is_finite() { - return Err(serde::de::Error::custom("duration cannot infinite or NaN")); + return Err(serde::de::Error::custom("duration cannot be infinite or NaN")); } if secs.is_sign_negative() { return Err(serde::de::Error::custom("duration cannot be negative")); From 5738b3c5372855ad39d032e4838855a2e905bcb7 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Mon, 25 May 2026 15:52:05 +0200 Subject: [PATCH 24/26] use taplo.toml and adjust test sequences --- .../sequence.schema.json | 61 +++++++++++++------ taplo.toml | 5 ++ tests/sequences/abort.toml | 2 - tests/sequences/invalid_action_times.toml | 2 - tests/sequences/invalid_global_times.toml | 2 +- .../invalid_interpolation_interval.toml | 2 +- tests/sequences/invalid_step_times.toml | 2 - tests/sequences/valid.toml | 2 - tests/sequences/valid_hold.toml | 2 - tests/sequences/valid_set_param.toml | 2 - 10 files changed, 48 insertions(+), 34 deletions(-) rename sequences/_schema.json => schemas/sequence.schema.json (63%) create mode 100644 taplo.toml diff --git a/sequences/_schema.json b/schemas/sequence.schema.json similarity index 63% rename from sequences/_schema.json rename to schemas/sequence.schema.json index 2e1c6c4..9b3b622 100644 --- a/sequences/_schema.json +++ b/schemas/sequence.schema.json @@ -1,8 +1,12 @@ { - "$schema": "http://json-schema.org/draft-07/schema", + "$schema": "http://json-schema.org/draft/2020-12/schema", + "$id": "https://spaceteam.at/ferroflow/schemas/sequence.schema.json", + "title": "FerroFlow Sequence", + "description": "TOML schema for FerroFlow sequence files.", "$defs": { "setParam": { - "type": "object", + "type": "object", + "description": "A parameter value to set at a specific.", "additionalProperties": false, "required": [ "timestamp", @@ -11,18 +15,22 @@ ], "properties": { "timestamp": { - "type": "number" + "type": "number", + "description": "Timestamp (seconds) relative to the parent step.", + "minimum": 0 }, "param": { "type": "string" }, "value": { - "type": "number" + "type": "number", + "description": "Logical value of the parameter (see parameter mapping)." } } }, - "holdcondition": { + "holdCondition": { "type": "object", + "description": "A hold condition that gets evaluated at a specific timestamp.", "additionalProperties": false, "required": [ "field", @@ -41,15 +49,16 @@ "less_eq", "greater", "greater_eq" - ] + ], + "description": "Comparison operator for the conditional hold. Will be evaluated like `field value`, so the field value is on the left and the provided value is on the right." }, "value": { - "type": "number" + "type": "number", + "description": "Logical value of the field (see parameter mapping)." } } } }, - "title": "FerroFlow-Sequence", "type": "object", "additionalProperties": false, "required": [ @@ -58,14 +67,12 @@ "steps" ], "properties": { - "$schema": { - "type": "string" - }, "name": { "type": "string" }, "globals": { "type": "object", + "description": "Global settings for the sequence", "additionalProperties": false, "required": [ "start_time", @@ -75,17 +82,22 @@ ], "properties": { "start_time": { - "type": "number" + "type": "number", + "description": "Start time (seconds) of the sequence, <= 0", + "maximum": 0 }, "end_time": { - "type": "number" + "type": "number", + "description": "End time (seconds) of the sequence" }, "interpolation_interval": { "type": "number", + "description": "The frequency (seconds) at which interpolated values are recalculated and sent", "exclusiveMinimum": 0 }, "interpolations": { "type": "object", + "description": "Mappings, how the param values should be interpolated", "patternProperties": { "^.+$": { "type": "string", @@ -115,16 +127,19 @@ "type": "string" }, "description": { - "type": "string" + "type": "string", + "description": "Optional." }, "timestamp": { - "type": "number" + "type": "number", + "description": "A timestamp (seconds), when to execute the step." }, "hold": { "type": "array", + "description": "A list of hold conditions. The sequence holds, when all conditions are satisfied. The sequence can then be manually resumed.", "minItems": 1, "items": { - "$ref": "#/$defs/holdcondition" + "$ref": "#/$defs/holdCondition" } } } @@ -142,13 +157,16 @@ "type": "string" }, "description": { - "type": "string" + "type": "string", + "description": "Optional." }, "timestamp": { - "type": "number" + "type": "number", + "description": "A timestamp (seconds), when to execute the step." }, "hold": { "type": "string", + "description": "A signal, that the sequence unconditionally holds here. The value must be `always`. The sequence can then be manually resumed.", "const": "always" } } @@ -166,13 +184,16 @@ "type": "string" }, "description": { - "type": "string" + "type": "string", + "description": "Optional." }, "timestamp": { - "type": "number" + "type": "number", + "description": "A timestamp (seconds), when to execute the step." }, "set_params": { "type": "array", + "description": "A list of `SetParam` actions.", "minItems": 1, "items": { "$ref": "#/$defs/setParam" diff --git a/taplo.toml b/taplo.toml new file mode 100644 index 0000000..0a37e95 --- /dev/null +++ b/taplo.toml @@ -0,0 +1,5 @@ +[[rule]] +include = ["**/sequence/**/*.toml", "**/sequences/**/*.toml"] + +[rule.schema] +path = "./schemas/sequence.schema.json" \ No newline at end of file diff --git a/tests/sequences/abort.toml b/tests/sequences/abort.toml index 722b2b5..78e2ef3 100644 --- a/tests/sequences/abort.toml +++ b/tests/sequences/abort.toml @@ -1,5 +1,3 @@ -#:schema ../../sequences/_schema.json - name = "Test Abort Sequence Valid" [globals] diff --git a/tests/sequences/invalid_action_times.toml b/tests/sequences/invalid_action_times.toml index bb9c5f7..5d91e8d 100644 --- a/tests/sequences/invalid_action_times.toml +++ b/tests/sequences/invalid_action_times.toml @@ -1,5 +1,3 @@ -#:schema ../../sequences/_schema.json - name = "Test Sequence Invalid: Action times" [globals] diff --git a/tests/sequences/invalid_global_times.toml b/tests/sequences/invalid_global_times.toml index 59d4903..4ef2e53 100644 --- a/tests/sequences/invalid_global_times.toml +++ b/tests/sequences/invalid_global_times.toml @@ -1,4 +1,4 @@ -#:schema ../../sequences/_schema.json +#:schema true name = "Test Sequence Invalid: global times" steps = [] diff --git a/tests/sequences/invalid_interpolation_interval.toml b/tests/sequences/invalid_interpolation_interval.toml index 7046621..9fc0c39 100644 --- a/tests/sequences/invalid_interpolation_interval.toml +++ b/tests/sequences/invalid_interpolation_interval.toml @@ -1,4 +1,4 @@ -##:schema ../../sequences/_schema.json +#:schema true name = "Test Sequence Invalid: interpolations interval" steps = [] diff --git a/tests/sequences/invalid_step_times.toml b/tests/sequences/invalid_step_times.toml index b628522..df8d691 100644 --- a/tests/sequences/invalid_step_times.toml +++ b/tests/sequences/invalid_step_times.toml @@ -1,5 +1,3 @@ -#:schema ../../sequences/_schema.json - name = "Test Sequence Invalid: step times" [globals] diff --git a/tests/sequences/valid.toml b/tests/sequences/valid.toml index acb1e75..557b15d 100644 --- a/tests/sequences/valid.toml +++ b/tests/sequences/valid.toml @@ -1,5 +1,3 @@ -#:schema ../../sequences/_schema.json - name = "Test Sequence Valid" [globals] diff --git a/tests/sequences/valid_hold.toml b/tests/sequences/valid_hold.toml index fd6289b..a15dd73 100644 --- a/tests/sequences/valid_hold.toml +++ b/tests/sequences/valid_hold.toml @@ -1,5 +1,3 @@ -#:schema ../../sequences/_schema.json - name = "Test Sequence Valid" [globals] diff --git a/tests/sequences/valid_set_param.toml b/tests/sequences/valid_set_param.toml index a7ba6cc..450fb02 100644 --- a/tests/sequences/valid_set_param.toml +++ b/tests/sequences/valid_set_param.toml @@ -1,5 +1,3 @@ -#:schema ../../sequences/_schema.json - name = "Test Sequence Valid" [globals] From 79498a8a3aa64d695dc619fb620bfcb75fa882f2 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Mon, 25 May 2026 15:55:39 +0200 Subject: [PATCH 25/26] add README for sequences --- README.md | 4 +++ sequences/README.md | 84 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 sequences/README.md diff --git a/README.md b/README.md index 6dcd5b1..24bdb29 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ Ferroflow is the new control software for all Liquid Rocketry projects at the TU It interfaces with our custom Engine Control Units ECUs, through our custom [LiquidCAN protocol](https://github.com/SpaceTeam/LiquidCAN/). On the other end, it provides a high-level API for our [ECUI](https://github.com/SpaceTeam/web_ecui_houbolt), which is the user interface for our ECUs. +## Sequences +Ferroflow supports automated sequences for parameter changes and holds. +See the [Sequences README](sequences/README.md) for more information. + # Setup TODO diff --git a/sequences/README.md b/sequences/README.md new file mode 100644 index 0000000..c6ad286 --- /dev/null +++ b/sequences/README.md @@ -0,0 +1,84 @@ +# FerroFlow Sequences + +Sequences define a timeline of automated parameter changes and (conditional) holds. They are defined as configuration files, preferably using TOML for readability. + +## Components + +### Globals +The `[globals]` section sets the sequence boundaries and interpolation settings: +- `start_time`: The global timestamp where the sequence begins (seconds). +- `end_time`: The global timestamp where the sequence ends (seconds). +- `interpolation_interval`: The frequency (seconds) at which interpolated values are recalculated and sent. +- `interpolations`: A map of parameter names to their interpolation mode (`linear` or `none`). If a parameter is not specified, the default is `none`. + +### Steps +Sequences are composed of `[[steps]]`. Each step occurs at a global `timestamp` (seconds) and must contain either `set_params` or a `hold` instruction. + +#### Step: _Set Parameter_ +Changes system parameters at specific times. The `timestamp` within each entry in `set_params` is relative to the parent step's timestamp. +- `set_params`: A list of objects with `timestamp` (relative offset in seconds), `param` name, and `value`. + +#### Step: _Hold_ +Pauses sequence execution at the step's timestamp. +- `hold = "always"`: The sequence pauses until manually resumed. +- `hold = [...]`: The sequence pauses if all conditions in the list are satisfied. The sequence stays paused until manually resumed. + - `field`: The telemetry field to monitor. + - `is`: Comparison operator (`equal`, `not_eq`, `less`, `less_eq`, `greater`, `greater_eq`). + - `value`: The numeric value to compare against. + +## Validation Rules + +To ensure sequence integrity, the following rules are enforced during loading: + +### Global Constraints +- `start_time` must be less than or equal to `0.0`. +- `start_time` must be less than or equal to `end_time`. +- The sequence must contain at least one step. + +### Step Constraints +- **Ordering**: Steps must be defined in strictly increasing order of their `timestamp`. +- **Boundaries**: Every step's `timestamp` must fall between the global `start_time` and `end_time`. +- **Content**: Each step must contain at least one action (`hold` or `set_params`). + +### Action Constraints +- **Timing**: Every action must satisfy: + - `step.timestamp <= action.timestamp < next_step.timestamp` + - `action.timestamp <= globals.end_time` +- **Conditional Holds**: Must contain at least one condition. + +## Schema +The structure is defined in `schemas/sequence.schema.json`. Most editors/plugins will provide autocompletion and validation based on this schema. The schema should be automatically applied to any `.toml` file in a `sequence` or `sequences` directory or subdirectory. + +## Sample Sequence + +### TOML +```toml +name = "Ox leak check" + +[globals] +start_time = -5.0 +end_time = 10.0 +interpolation_interval = 0.01 +interpolations = { "ox_main_valve" = "linear" } + +[[steps]] +name = "Initialize" +timestamp = -5.0 +set_params = [ + { timestamp = 0.0, param = "ox_vent", value = 1.0 }, + { timestamp = 0.0, param = "ox_main_valve", value = 0.0 } +] + +[[steps]] +name = "Pressure Check" +description = "Conditional hold" +timestamp = 1.0 +hold = [ + { field = "ox_pressure", is = "greater_eq", value = 50.0 } +] + +[[steps]] +name = "Manual Check" +timestamp = 3.0 +hold = "always" +``` \ No newline at end of file From 5e08a8305183a37e9a64a289d7e41d70345bb103 Mon Sep 17 00:00:00 2001 From: fweichselbaum Date: Mon, 25 May 2026 16:07:24 +0200 Subject: [PATCH 26/26] improve formatting --- src/sequence/sequence_builder.rs | 1 + src/sequence/sequence_definition.rs | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sequence/sequence_builder.rs b/src/sequence/sequence_builder.rs index 39b8650..d3e3324 100644 --- a/src/sequence/sequence_builder.rs +++ b/src/sequence/sequence_builder.rs @@ -59,6 +59,7 @@ pub fn flatten_and_interpolate(seq: Sequence) -> Vec { final_actions.extend(interpolated_actions); } + last_param_states.insert(param_state.param.clone(), new_param_value); final_actions.push(timed_action); } diff --git a/src/sequence/sequence_definition.rs b/src/sequence/sequence_definition.rs index ee220ee..69b563d 100644 --- a/src/sequence/sequence_definition.rs +++ b/src/sequence/sequence_definition.rs @@ -45,7 +45,9 @@ where { let secs = f64::deserialize(deserializer)?; if !secs.is_finite() { - return Err(serde::de::Error::custom("duration cannot be infinite or NaN")); + return Err(serde::de::Error::custom( + "duration cannot be infinite or NaN", + )); } if secs.is_sign_negative() { return Err(serde::de::Error::custom("duration cannot be negative"));