diff --git a/Cargo.lock b/Cargo.lock index 0e7b94e..4168787 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,17 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "config", "dashmap", "diesel", "diesel_migrations", "liquidcan_rust", + "ntest", + "serde", "serde_json", "socketcan", "testcontainers", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -578,6 +713,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 +816,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 +880,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 +1051,7 @@ dependencies = [ "hyper", "libc", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1092,6 +1261,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 +1377,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]] @@ -1293,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" @@ -1384,6 +1597,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 +1645,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" @@ -1513,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" @@ -1539,7 +1820,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 +1960,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 +2000,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1829,6 +2134,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 +2192,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 +2242,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 +2488,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 +2576,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 +2605,42 @@ 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_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.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 +2749,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 +2843,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 +3102,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 +3128,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..d9b4927 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,11 @@ chrono = "0.4" serde_json = "1.0" anyhow = "1.0.102" dashmap = "6.1.0" +toml = "1.1.2" +serde = { version = "1.0.228", features = ["derive"] } +config = "0.15.22" [dev-dependencies] diesel_migrations = "2.3" +ntest = "0.9.5" testcontainers = { version = "0.27", features = ["blocking"] } diff --git a/sequences/_schema.json b/sequences/_schema.json new file mode 100644 index 0000000..2e1c6c4 --- /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", + "minItems": 1, + "items": { + "$ref": "#/$defs/holdcondition" + } + } + } + }, + { + "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", + "minItems": 1, + "items": { + "$ref": "#/$defs/setParam" + } + } + } + } + ] + } + } + } +} \ No newline at end of file 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 { 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(()) }); diff --git a/src/sequence/mod.rs b/src/sequence/mod.rs index fafa68d..807bec2 100644 --- a/src/sequence/mod.rs +++ b/src/sequence/mod.rs @@ -1,88 +1,77 @@ //! Code for managing and running sequences. -#![allow(unused)] - -use std::{ - sync::mpsc, - thread, - time::{Duration, Instant}, +mod sequence_builder; +mod sequence_definition; +mod sequence_runner; +mod sequence_validation; + +use std::path::Path; + +use crate::{ + events, + sequence::{ + sequence_definition::Sequence, + sequence_runner::{SequenceCmd, SequenceRunner}, + }, }; -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. +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 => { + 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; + } + }; + + let result = sequence_runner.run_sequence(seq, abort_seq); + if let Err(err) = result { + eprintln!("Error while running sequence: {err:#}"); } } - } - - println!("Executing step: {}", step.description); + 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, + }; } - - println!("Sequence '{}' completed successfully.", seq.name); }); - - SequenceHandle { - cancel_tx, - thread_handle, - } } 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 new file mode 100644 index 0000000..9ec11f2 --- /dev/null +++ b/src/sequence/sequence_definition.rs @@ -0,0 +1,240 @@ +#![allow(unused)] + +use anyhow::{Context, Result}; +use std::{collections::HashMap, path::Path, time::Duration}; + +use config::{Config, File}; +use serde::{Deserialize, Deserializer, de}; + +pub 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 sequence: Self = config + .try_deserialize() + .with_context(|| format!("Failed to deserialize config from {}", path.display()))?; + + sequence.validate()?; + Ok(sequence) + } +} + +#[derive(Debug, Deserialize)] +pub struct Globals { + pub start_time: TimestampSec, + pub end_time: 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 { + #[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 RawParamState { + #[serde(rename = "timestamp")] + relative_timestamp: TimestampSec, + param: String, + value: f64, + } + #[derive(Deserialize)] + struct RawStep { + name: String, + description: Option, + timestamp: TimestampSec, + set_params: Option>, + hold: Option, + } + + 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(|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_step.name, + description: raw_step.description, + timestamp: raw_step.timestamp, + actions, + }) + } +} + +#[derive(Debug)] +pub struct TimedAction { + pub timestamp: TimestampSec, + pub action: Action, +} + +#[derive(Debug)] +pub enum Action { + Hold(HoldMode), + SetParam(ParamState), +} + +#[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, Deserialize, Clone)] +pub struct ParamState { + 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!("evaluate condition if it's true based on the actual field values") + } +} + +#[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()); + 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("duration cannot be negative")); + } + + #[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")); + } +} diff --git a/src/sequence/sequence_runner.rs b/src/sequence/sequence_runner.rs new file mode 100644 index 0000000..5d1e786 --- /dev/null +++ b/src/sequence/sequence_runner.rs @@ -0,0 +1,309 @@ +use anyhow::{Result, anyhow}; +use std::{ + panic, + sync::mpsc::{self, Receiver, RecvTimeoutError}, + thread, + time::{Duration, Instant}, +}; + +use crate::{ + events::{self, EventDispatcher}, + sequence::{ + sequence_builder::flatten_and_interpolate, + sequence_definition::{Action, HoldMode, Sequence, TimedAction}, + }, +}; + +pub struct SequenceHandle<'scope> { + controller_tx: mpsc::Sender, + thread_handle: thread::ScopedJoinHandle<'scope, Result<(), SequenceRunError>>, +} + +#[derive(Debug)] +pub enum SequenceCmd { + Pause, + Resume, + Abort, + Shutdown, +} + +pub enum SequenceRunError { + Aborted, + Shutdown, + Panicked, +} + +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: Sequence, abort_seq: Sequence) -> Result<()> { + if self.is_sequence_running() { + return Err(anyhow!("another sequence is still running")); + } + let (controller_tx, controller_rx) = mpsc::channel(); + let event_dispatcher = self.event_dispatcher; + + let thread_handle = self.scope.spawn(move || { + 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) + } + } + }); + + 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()) + } + + /// 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_actions( + schedule: Vec, + controller: &Receiver, + #[allow(unused)] event_dispatcher: &EventDispatcher, + ) -> Result<(), SequenceRunError> { + let origin = Instant::now(); + let mut pause_offset = Duration::ZERO; + + for timed_action in schedule { + // loop to wait for next action + loop { + let deadline = + origin + Duration::from_secs_f64(timed_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 + 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 + }; + } + + match timed_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; + } + } + + Action::SetParam(_param_value) => { + // TODO: send can message with correct data + // 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!(), + // }, + // }, + // }); + } + } + } + + 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::Pause) => continue, // already paused, ignore + Ok(SequenceCmd::Resume) => return Ok(paused_at.elapsed()), + Ok(SequenceCmd::Abort) => return Err(SequenceRunError::Aborted), // 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 + } + } + } +} + +#[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") + } + + #[test] + #[timeout(2000)] + fn test_run_sequence_execution_completes() { + 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 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(); + + 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/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(()) + } +} 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/invalid_action_times.toml b/tests/sequences/invalid_action_times.toml new file mode 100644 index 0000000..bb9c5f7 --- /dev/null +++ b/tests/sequences/invalid_action_times.toml @@ -0,0 +1,25 @@ +#: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, param = "servo1", value = 0 } +] + +[[steps]] +name = "Step2" +description = "Description of Step2" +timestamp = 1 +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 new file mode 100644 index 0000000..59d4903 --- /dev/null +++ b/tests/sequences/invalid_global_times.toml @@ -0,0 +1,10 @@ +#:schema ../../sequences/_schema.json + +name = "Test Sequence Invalid: global times" +steps = [] + +[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..7046621 --- /dev/null +++ b/tests/sequences/invalid_interpolation_interval.toml @@ -0,0 +1,10 @@ +##:schema ../../sequences/_schema.json + +name = "Test Sequence Invalid: interpolations interval" +steps = [] + +[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..b628522 --- /dev/null +++ b/tests/sequences/invalid_step_times.toml @@ -0,0 +1,25 @@ +#: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 = [ + { timestamp = 0, param = "servo1", value = 0 } +] + +[[steps]] +name = "Step2" +description = "Description of Step2" +timestamp = 9 +set_params = [ + { timestamp = 0, param = "servo1", value = 0 } +] \ 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 }] 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 }, +]