commit 05accd36900e808ac6f27c9eeede9507f128d8da Author: lenn Date: Wed Apr 22 23:51:15 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cdc9701 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1053 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "JE-Skin-Cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "clap", + "crc", + "csv", + "fern", + "humantime", + "log", + "serde", + "serde_json", + "tokio", + "tokio-serial", + "tokio-util", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.52.0", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "chrono", + "colored", + "log", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mio-serial" +version = "5.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029e1f407e261176a983a6599c084efd322d9301028055c87174beac71397ba3" +dependencies = [ + "log", + "mio", + "nix 0.29.0", + "serialport", + "winapi", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serialport" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-serial" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa1d5427f11ba7c5e6384521cfd76f2d64572ff29f3f4f7aa0f496282923fdc8" +dependencies = [ + "cfg-if", + "futures", + "log", + "mio-serial", + "serialport", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f005ee3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "JE-Skin-Cli" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.102" +clap = "4.6.1" +fern = {version = "0.7.1", features=["colored", "date-based"]} +humantime = "2.3.0" +log = "0.4.29" +tokio = {version = "1.52.1", features=["full"]} +tokio-serial = "5.4.5" +tokio-util = "0.7.18" +csv = "1.4.0" +chrono = "0.4.44" +crc = "3.4.0" +async-trait = "0.1.89" +serde = { version = "1.0.228", features=["derive"]} +serde_json = "1.0.149" diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..1d37329 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,35 @@ +use std::{sync::{Arc, Mutex}, thread::JoinHandle}; + +use tokio_util::sync::CancellationToken; + +use crate::serial_core::{TactileARecording, error::SerialError}; + +struct SerialSession { + port: String, + cancel: CancellationToken, + task: JoinHandle<()>, + current_record: Arc> +} + +pub struct SerialConnectionState { + session: Mutex>, + last_record: Mutex>>> +} + +pub async fn serial_connect( + port: String, + state: Arc +) -> Result<(), SerialError> { + let port_name = port.trim().to_string(); + if port_name.is_empty() { + return Err(SerialError::InvalidConfig); + } + + { + let session = state.session.lock().map_err(|_| SerialError::StateError)?; + if session.is_some() { + return Err(SerialError::AlreadyConnected); + } + } + +} \ No newline at end of file diff --git a/src/flog.rs b/src/flog.rs new file mode 100644 index 0000000..d9bf659 --- /dev/null +++ b/src/flog.rs @@ -0,0 +1,58 @@ +use fern::{Dispatch, colors::{ColoredLevelConfig, Color}, DateBased}; +use log::{debug}; +use std::time::SystemTime; +pub fn setup_logger() { + let colors_line = ColoredLevelConfig::new() + .error(Color::Red) + .warn(Color::Yellow) + .info(Color::Green) + .debug(Color::White) + .trace(Color::BrightBlack); + + let colors_level = colors_line.info(Color::Green); + let level = if cfg!(debug_assertions) { + log::LevelFilter::Debug + } else { + log::LevelFilter::Info + }; + + let console_config = fern::Dispatch::new() + .format(move |out, message, record| { + out.finish( + format_args!( + "{colors_line}[{data} {level} {target} {colors_line}] {message}\x1B[0m", + colors_line = format_args!( + "\x1B[{}m", + colors_line.get_color(&record.level()).to_fg_str() + ), + data = humantime::format_rfc3339_seconds(SystemTime::now()), + target = record.target(), + level = colors_level.color(record.level()), + message = message, + ) + ); + }) + .level(level) + .chain(std::io::stdout()); + + let data_based_config = fern::Dispatch::new() + .format(move |out, message, record| { + out.finish( + format_args!( + "[{data} {level} {target}] {message}", + data = humantime::format_rfc3339_seconds(SystemTime::now()), + target = record.target(), + level = colors_level.color(record.level()), + message = message, + ) + ); + }) + .level(level) + .chain(fern::DateBased::new("program.log", "%Y-%m-%d")); + Dispatch::new() + .level(log::LevelFilter::Debug) + .chain(console_config) + .chain(data_based_config) + .apply() + .unwrap(); +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..39ed96a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,6 @@ +pub mod serial_core; +pub mod flog; +pub mod app; +fn main() { + println!("Hello, world!"); +} diff --git a/src/serial_core/codec.rs b/src/serial_core/codec.rs new file mode 100644 index 0000000..bf37bee --- /dev/null +++ b/src/serial_core/codec.rs @@ -0,0 +1,6 @@ +use crate::serial_core::error::CodecError; +use std::time::Instant; +pub trait Codec { + fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result, CodecError>; + fn encode(&self, frame: &F) -> Result, CodecError>; +} diff --git a/src/serial_core/codecs/mod.rs b/src/serial_core/codecs/mod.rs new file mode 100644 index 0000000..d4b0944 --- /dev/null +++ b/src/serial_core/codecs/mod.rs @@ -0,0 +1,5 @@ +use crate::serial_core::{frame::TestFrame, record::Recording}; + +pub mod test; +pub mod tactile_a; +pub type TestRecording = Recording; \ No newline at end of file diff --git a/src/serial_core/codecs/tactile_a.rs b/src/serial_core/codecs/tactile_a.rs new file mode 100644 index 0000000..a8bcee0 --- /dev/null +++ b/src/serial_core/codecs/tactile_a.rs @@ -0,0 +1,382 @@ +use crate::serial_core::error::CodecError; +use crate::serial_core::frame::{ + FrameHandler, TactileAFrameMetaData, TactileARepFrame, TactileAReqFrame, +}; +use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording}; +use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis}; +use crate::serial_core::{ + codec::Codec, + frame::{TactileAFrame, TactileAFrameStatusCode}, +}; +use async_trait::async_trait; +use csv::StringRecord; +use anyhow::anyhow; +use std::io::Read; +use log::debug; + +const FRAME_BUFFER_MIN_LENGTH: usize = 15; + +pub struct TactileACodec { + buffer: Vec, + expected_data_len: usize, +} + +pub struct TactileACsvExporter { + channels: usize, +} + +pub struct TactileACsvImporter { + channels: usize, + data_row: usize, + packets: Vec, +} + +pub struct TactileAHandler; + +#[derive(Clone)] +pub struct TactileADataPacket { + pub data: Vec, + pub dts_ms: u64, +} + +impl From for TactileAFrameStatusCode { + fn from(value: u8) -> Self { + match value { + 0 => TactileAFrameStatusCode::Success, + _ => TactileAFrameStatusCode::Failure, + } + } +} + +impl TryFrom<&TactileARepFrame> for TactileADataPacket { + type Error = CodecError; + fn try_from(value: &TactileARepFrame) -> Result { + let data = TactileACodec::parse_data_frame(&value.payload)?; + let dts_ms = value.dts_ms; + Ok(TactileADataPacket { + data: data, + dts_ms: dts_ms, + }) + } +} + +impl TactileACodec { + pub fn new(cols: usize, rows: usize) -> TactileACodec { + Self { + buffer: Vec::new(), + expected_data_len: cols * rows * 2, + } + } + + pub fn parse_data_frame(data: &[u8]) -> Result, CodecError> { + if data.len() % 2 != 0 { + return Err(CodecError::InvalidLength); + } + + let vals: Vec = data + .chunks_exact(2) + .map(|chunk| { + let raw = u16::from_le_bytes([chunk[0], chunk[1]]) as i32; + if raw < 15 { + 0 + } else { + raw + } + }) + .collect::>(); + + Ok(vals) + } + + pub fn build_req_frame(cols: usize, rows: usize) -> anyhow::Result { + let header = [0x55, 0xAA]; + let payload_len: usize = 9; + let device_addr: u8 = 0x34; + let extend_code: u8 = 0x00; + let func_code: u8 = 0xFB; + let start_addr: u32 = 7168; + let except_data_len: usize = cols * rows * 2; + let checksum: u8 = 0; + Ok(TactileAFrame::Req(TactileAReqFrame { + meta: TactileAFrameMetaData { + header, + payload_len, + device_addr, + extend_code, + func_code, + start_addr, + except_data_len, + checksum, + }, + })) + } +} + +impl Codec for TactileACodec { + fn decode( + &mut self, + input: &[u8], + session_started_at: std::time::Instant, + ) -> Result, CodecError> { + self.buffer.extend_from_slice(input); + let mut frames: Vec = Vec::new(); + + loop { + if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH { + break; + } + + let header_pos = self.buffer.windows(2).position(|w| w == [0xAA, 0x55]); + + let Some(pos) = header_pos else { + self.buffer.clear(); + break; + }; + if pos > 0 { + self.buffer.drain(0..pos); + } + + if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH { + break; + } + + let header = [self.buffer[0], self.buffer[1]]; + let payload_len = u16::from_le_bytes([self.buffer[2], self.buffer[3]]) as usize; + let device_addr = self.buffer[4]; + let extend_code = self.buffer[5]; + let func_code = self.buffer[6]; + let start_addr = u32::from_le_bytes([ + self.buffer[7], + self.buffer[8], + self.buffer[9], + self.buffer[10], + ]); + let except_data_len = u16::from_le_bytes([self.buffer[11], self.buffer[12]]) as usize; + let status = TactileAFrameStatusCode::from(self.buffer[13]); + if except_data_len != self.expected_data_len { + debug!( + "unexpected payload length: expected {}, got {}, buffer_len={}", + self.expected_data_len, + except_data_len, + self.buffer.len() + ); + self.buffer.drain(0..1); + continue; + } + + let frame_length = except_data_len + FRAME_BUFFER_MIN_LENGTH; + if self.buffer.len() < frame_length { + break; + } + + let need_check_data = self.buffer[0..14 + except_data_len].to_vec(); + let payload = self.buffer[14..14 + except_data_len].to_vec(); + let crc8_itu_alg = crc::Crc::::new(&crc::CRC_8_I_432_1); + let checksum = crc8_itu_alg.checksum(&need_check_data.as_slice()); + if self.buffer[frame_length - 1] != checksum { + debug!( + "checksum mismatch: expected {:02X}, got {:02X}, frame_len={}", + checksum, + self.buffer[frame_length - 1], + frame_length + ); + self.buffer.drain(0..1); + continue; + } + let dts_ms = elapsed_millis(session_started_at); + let meta: TactileAFrameMetaData = TactileAFrameMetaData { + header, + payload_len, + device_addr, + extend_code, + func_code, + start_addr, + except_data_len, + checksum, + }; + frames.push(TactileAFrame::Rep({ + TactileARepFrame { + meta, + status, + payload, + dts_ms, + } + })); + + self.buffer.drain(0..frame_length); + } + + Ok(frames) + } + + fn encode( + &self, + frame: &TactileAFrame, + ) -> Result, crate::serial_core::error::CodecError> { + match frame { + TactileAFrame::Req(f) => { + let mut req_bytes: Vec = Vec::new(); + req_bytes.extend_from_slice(f.meta.header.as_slice()); + req_bytes.extend_from_slice((f.meta.payload_len as u16).to_le_bytes().as_slice()); + req_bytes.push(f.meta.device_addr); + req_bytes.push(f.meta.extend_code); + req_bytes.push(f.meta.func_code); + + req_bytes.extend_from_slice(f.meta.start_addr.to_le_bytes().as_slice()); + req_bytes.extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice()); + let checksum = calc_crc8_itu(req_bytes.as_slice()); + req_bytes.push(checksum); + Ok(req_bytes) + } + _ => { + Err(CodecError::InvalidFrameType) + } + } + } +} + +#[async_trait] +impl FrameHandler for TactileAHandler { + async fn on_frame(&mut self, frame: &TactileAFrame) -> anyhow::Result>> { + match frame { + TactileAFrame::Rep(rep) => { + let vals = TactileACodec::parse_data_frame(&rep.payload)?; + Ok(Some(vals)) + } + _ => Ok(None), + } + } +} + +impl TactileACsvExporter { + fn new(channels: usize) -> Self { + TactileACsvExporter { channels } + } +} + +impl CsvExporter for TactileACsvExporter { + type Error = CodecError; + fn csv_header(&self, _recording: &Recording) -> Vec { + let mut header: Vec = Vec::new(); + for i in 0..self.channels { + header.push(format!("channel{}", i + 1)); + } + + header.push("dts".to_string()); + header.push("summary".to_string()); + header + } + + fn csv_row( + &self, + item: &RecordedFrame, + ) -> anyhow::Result> { + let packet = TactileADataPacket::try_from(&item.frame)?; + let summary: i32 = packet.data.iter().sum(); + let mut row: Vec = packet.data.iter().map(|x| x.to_string()).collect(); + row.push(packet.dts_ms.to_string()); + row.push(summary.to_string()); + Ok(row) + } +} + +impl CsvExporter for TactileACsvExporter { + type Error = CodecError; + + fn csv_header(&self, _recording: &Recording) -> Vec { + let mut header: Vec = Vec::new(); + for i in 0..self.channels { + header.push(format!("channel{}", i + 1)); + } + + header.push("dts".to_string()); + header + } + + fn csv_row( + &self, + item: &RecordedFrame, + ) -> anyhow::Result> { + let rep = match &item.frame { + TactileAFrame::Rep(rep) => rep, + TactileAFrame::Req(_) => return Err(anyhow!("request frame cannot be exported to csv row")), + }; + + let packet = TactileADataPacket::try_from(rep)?; + let mut row: Vec = packet.data.iter().map(|x| x.to_string()).collect(); + row.push(packet.dts_ms.to_string()); + Ok(row) + } +} + +impl TactileACsvImporter { + pub fn new(_path: &str) -> TactileACsvImporter { + Self { + channels: 0, + data_row: 0, + packets: Vec::new(), + } + } + + fn parse_record(&mut self, record: StringRecord) -> anyhow::Result { + if self.channels == 0 { + return Err(anyhow!("csv header is missing channel columns")); + } + + if record.len() < self.channels + 1 { + return Err(anyhow!("csv row has insufficient columns")); + } + + let mut data = Vec::with_capacity(self.channels); + for index in 0..self.channels { + let cell = record.get(index).ok_or_else(|| anyhow!("missing channel cell"))?; + data.push(cell.parse::()?); + } + + let dts_cell = record + .get(self.channels) + .ok_or_else(|| anyhow!("missing dts cell"))?; + let dts_ms = dts_cell.parse::()?; + + Ok(TactileADataPacket { + data: data, + dts_ms: dts_ms, + }) + } +} + +impl CsvImporter for TactileACsvImporter { + fn load(&mut self, reader: R) -> anyhow::Result> { + let mut rdr = csv::Reader::from_reader(reader); + let headers = rdr.headers()?.clone(); + self.channels = headers.len().saturating_sub(1); + self.data_row = 0; + self.packets.clear(); + + for record in rdr.records() { + let record = record?; + let packet = self.parse_record(record)?; + self.packets.push(packet); + self.data_row += 1; + } + + Ok(self.packets.clone()) + } +} + +pub fn export_recording_csv(recording: &Recording, writer: W) -> anyhow::Result<()> +where + W: std::io::Write, +{ + let channel_nb = recording + .frames + .iter() + .find_map(|frame| match &frame.frame { + TactileAFrame::Rep(rep) => Some(rep.payload.len() / 2), + TactileAFrame::Req(_) => None, + }) + .unwrap_or(0); + + let exporter = TactileACsvExporter::new(channel_nb); + write_csv(recording, &exporter, writer) +} diff --git a/src/serial_core/codecs/test.rs b/src/serial_core/codecs/test.rs new file mode 100644 index 0000000..ad4fc60 --- /dev/null +++ b/src/serial_core/codecs/test.rs @@ -0,0 +1,256 @@ +use std::io::Read; +use std::time::Instant; +use crate::serial_core::frame::{FrameHandler}; +use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame}; +use anyhow::anyhow; +use async_trait::async_trait; +use csv::StringRecord; +use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording}; +use crate::serial_core::utils::{ + elapsed_millis, + usize_to_u16_be_bytes +}; +pub struct TestCodec { + buffer: Vec, +} + +pub struct TestHandler; + +impl TestCodec { + pub fn new() -> TestCodec { + Self { buffer: Vec::new() } + } +} + +impl Codec for TestCodec { + fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result, CodecError> { + self.buffer.extend_from_slice(input); + let mut frames = Vec::new(); + + loop { + if self.buffer.len() < 6 { + break; + } + + let header_pos = self.buffer.windows(2).position(|w| w == [0xAA, 0x55]); + + let Some(pos) = header_pos else { + self.buffer.clear(); + break; + }; + if pos > 0 { + self.buffer.drain(0..pos); + } + + if self.buffer.len() < 6 { + break; + } + + let cmd = self.buffer[2]; + let length_bytes = [self.buffer[3], self.buffer[4]]; + let length = u16::from_be_bytes(length_bytes) as usize; + let frame_length = (length + 6) as usize; + if self.buffer.len() < frame_length { + break; + } + let payload = self.buffer[5..5 + length].to_vec(); + // let checksum = crc8(payload.as_slice()); + let crc8_alg = crc::Crc::::new(&crc::CRC_8_SMBUS); + let checksum = crc8_alg.checksum(payload.as_slice()); + if self.buffer[frame_length - 1] != checksum { + self.buffer.drain(0..1); + continue; + } + let dts = elapsed_millis(session_started_at); + println!("dts_ms: {dts}"); + frames.push(TestFrame { + header: [0xAA, 0x55], + cmd: cmd, + length: length, + payload: payload, + checksum: checksum, + dts_ms: dts, + }); + + self.buffer.drain(0..frame_length); + } + + Ok(frames) + } + fn encode(&self, frame: &TestFrame) -> Result, CodecError> { + let _ = u16::try_from(frame.payload.len()).map_err(|_| CodecError::PayloadTooLarge)?; + let mut out = Vec::with_capacity(6 + frame.length); + out.extend_from_slice(&frame.header); + out.push(frame.cmd); + out.extend_from_slice(&usize_to_u16_be_bytes(frame.length)); + out.extend_from_slice(&frame.payload); + out.push(frame.checksum); + + Ok(out) + } +} + +#[async_trait] +impl FrameHandler for TestHandler { + async fn on_frame(&mut self, frame: &TestFrame) -> anyhow::Result>> { + match frame.cmd { + 0x01 => { + let vals = parse_data_frame(&frame.payload)?; + Ok(Some(vals)) + } + _ => Ok(None), + } + } +} + +fn parse_data_frame(data: &[u8]) -> Result, CodecError> { + if data.len() % 2 != 0 { + return Err(CodecError::InvalidLength); + } + + let vals: Vec = data + .chunks_exact(2) + .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]) as i32) + .collect::>(); + + Ok(vals) +} + +pub struct TestCsvExporter; +pub struct TestCsvImporter { + channels: usize, + data_row: usize, + packets: Vec, +} + +#[derive(Clone)] +pub struct TestDataPacket { + pub data: Vec, + pub dts_ms: u64 +} + +impl TryFrom<&TestFrame> for TestDataPacket { + type Error = CodecError; + fn try_from(frame: &TestFrame) -> Result { + let data = parse_data_frame(&frame.payload)?; + let dts = frame.dts_ms; + Ok(TestDataPacket { data: data, dts_ms: dts }) + } +} +// impl From for TestDataPacket { +// fn from(frame: TestFrame) -> Self { +// let data = parse_data_frame(&frame.payload)?; +// let dts = frame.dts_ms; +// TestDataPacket { data: data, dts_ms: dts } +// } +// } + + +impl CsvExporter for TestCsvExporter { + type Error = CodecError; + fn csv_header(&self, recording: &Recording) -> Vec { + let channel_nb = recording + .frames + .iter() + .find_map(|frame| parse_data_frame(&frame.frame.payload).ok().map(|vals| vals.len())) + .unwrap_or(0); + let mut header: Vec = Vec::new(); + for i in 0..channel_nb { + header.push(format!("channel{}", i + 1)); + } + header.push("dts".to_string()); + + header + } + + fn csv_row(&self, item: &RecordedFrame) -> anyhow::Result> { + let packet: TestDataPacket = TestDataPacket::try_from(&item.frame)?; + let mut row: Vec = packet.data.iter().map(|&x| x.to_string()).collect(); + row.push(packet.dts_ms.to_string()); + Ok(row) + } +} + +impl TestCsvImporter { + pub fn new(_path: &str) -> TestCsvImporter { + Self { + channels: 0, + data_row: 0, + packets: Vec::new(), + } + } + + fn parse_record(&mut self, record: StringRecord) -> anyhow::Result{ + if self.channels == 0 { + return Err(anyhow!("csv header is missing channel columns")); + } + + if record.len() < self.channels + 1 { + return Err(anyhow!("csv row has insufficient columns")); + } + + let mut data = Vec::with_capacity(self.channels); + for index in 0..self.channels { + let cell = record.get(index).ok_or_else(|| anyhow!("missing channel cell"))?; + data.push(cell.parse::()?); + } + + let dts_cell = record + .get(self.channels) + .ok_or_else(|| anyhow!("missing dts cell"))?; + let dts_ms = dts_cell.parse::()?; + + Ok(TestDataPacket { + data: data, + dts_ms: dts_ms, + }) + } +} + +impl CsvImporter for TestCsvImporter { + fn load(&mut self, reader: R) -> anyhow::Result> { + let mut rdr = csv::Reader::from_reader(reader); + let headers = rdr.headers()?.clone(); + self.channels = headers.len().saturating_sub(1); + self.data_row = 0; + self.packets.clear(); + + for record in rdr.records() { + let record = record?; + let packet = self.parse_record(record)?; + self.packets.push(packet); + self.data_row += 1; + } + + Ok(self.packets.clone()) + } +} + + +pub fn export_recording_csv(recording: &Recording, writer: W) -> anyhow::Result<()> +where + W: std::io::Write, +{ + write_csv(recording, &TestCsvExporter, writer) +} + +#[cfg(test)] +mod tests { + use super::*; + use csv::Reader; + use std::io::Cursor; + + #[test] + fn test_read_csv_basic() -> anyhow::Result<()> { + let mut rdr = Reader::from_path("recording_20260329_125238.csv")?; + let headers = rdr.headers()?; + println!("headers: {:?}", headers); + + for result in rdr.records() { + let record = result?; + println!("record: {:?}", record); + } + + Ok(()) + } +} diff --git a/src/serial_core/error.rs b/src/serial_core/error.rs new file mode 100644 index 0000000..fae6738 --- /dev/null +++ b/src/serial_core/error.rs @@ -0,0 +1,54 @@ +use serde::Serialize; +use std::fmt; + +#[derive(Debug, Serialize)] +pub enum SerialError { + OpenError, + CloseError, + ScanError, + InvalidConfig, + AlreadyConnected, + StateError, + NoRecordedData, + ExportError, + ImportError, +} + +impl fmt::Display for SerialError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + SerialError::OpenError => write!(f, "Opening Error"), + SerialError::CloseError => write!(f, "Closing Error"), + SerialError::ScanError => write!(f, "Scan Error"), + SerialError::InvalidConfig => write!(f, "Invalid Config"), + SerialError::AlreadyConnected => write!(f, "Already Connected"), + SerialError::StateError => write!(f, "State Error"), + SerialError::NoRecordedData => write!(f, "No Recorded Data"), + SerialError::ExportError => write!(f, "Export Error"), + SerialError::ImportError => write!(f, "Import Error"), + } + } +} + +#[derive(Debug)] +pub enum CodecError { + InvalidHeader, + InvalidTail, + InvalidLength, + InvalidFrameType, + PayloadTooLarge, +} + +impl fmt::Display for CodecError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + CodecError::InvalidHeader => write!(f, "Invalid Header"), + CodecError::InvalidTail => write!(f, "Invalid Tail"), + CodecError::InvalidLength => write!(f, "Invalid Length"), + CodecError::InvalidFrameType => write!(f, "Invalid Frame Type"), + CodecError::PayloadTooLarge => write!(f, "Payload too large"), + } + } +} + +impl std::error::Error for CodecError {} diff --git a/src/serial_core/frame.rs b/src/serial_core/frame.rs new file mode 100644 index 0000000..42d23a6 --- /dev/null +++ b/src/serial_core/frame.rs @@ -0,0 +1,57 @@ +use anyhow::Result; +use async_trait::async_trait; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TestFrame { + pub header: [u8; 2], + pub cmd: u8, + pub length: usize, + pub payload: Vec, + pub checksum: u8, + pub dts_ms: u64 +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TactileAFrameMetaData { + pub header: [u8; 2], + pub payload_len: usize, + pub device_addr: u8, + pub extend_code: u8, + pub func_code: u8, + pub start_addr: u32, + pub except_data_len: usize, + // pub status: u8, + // pub payload_data: Vec, + pub checksum: u8, + // pub dts_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TactileAReqFrame { + pub meta: TactileAFrameMetaData, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TactileARepFrame { + pub meta: TactileAFrameMetaData, + pub status: TactileAFrameStatusCode, + pub payload: Vec, + pub dts_ms: u64 +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TactileAFrameStatusCode { + Success, + Failure +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TactileAFrame { + Req(TactileAReqFrame), + Rep(TactileARepFrame) +} + +#[async_trait] +pub trait FrameHandler: Send { + async fn on_frame(&mut self, frame: &F) -> Result>>; +} + diff --git a/src/serial_core/mod.rs b/src/serial_core/mod.rs new file mode 100644 index 0000000..83b8fbc --- /dev/null +++ b/src/serial_core/mod.rs @@ -0,0 +1,43 @@ +use tokio_serial::available_ports; + +use crate::serial_core::{ + error::SerialError, frame::{TactileAFrame, TestFrame}, record::Recording +}; + +pub mod codec; +pub mod codecs; +pub mod error; +pub mod frame; +pub mod model; +pub mod serial; +pub mod record; +pub mod utils; + +pub type TestRecording = Recording; +pub type TactileARecording = Recording; + +pub struct SerialConnection { + pub port: String, +} + +pub fn connect(port: &str) -> Result { + let port = port.trim(); + + if port.is_empty() { + return Err("Serial port is required".to_string()); + } + + Ok(SerialConnection { + port: port.to_string(), + }) +} + +pub fn serial_enum() -> Result, SerialError> { + let ports = available_ports() + .map_err(|_| SerialError::ScanError)? + .into_iter() + .map(|info| info.port_name) + .collect(); + + Ok(ports) +} \ No newline at end of file diff --git a/src/serial_core/model.rs b/src/serial_core/model.rs new file mode 100644 index 0000000..ce5b9e9 --- /dev/null +++ b/src/serial_core/model.rs @@ -0,0 +1,500 @@ +use crate::serial_core::frame::TestFrame; +use std::collections::HashMap; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const MAX_POINTS: usize = 28; +const MAX_SUMMARY_POINTS: usize = 42; +const PANEL_STALE_AFTER: Duration = Duration::from_millis(2400); + +#[derive(serde::Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HudPacket { + pub ts: u64, + pub panels: Vec, + pub summary: HudSummary, + pub pressure_matrix: Option>, +} + +#[derive(serde::Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HudSummary { + pub label: String, + pub points: Vec, + pub latest: Option, + pub min: Option, + pub max: Option, +} + +#[derive(serde::Serialize, Clone, Copy)] +#[serde(rename_all = "lowercase")] +pub enum HudPanelSide { + Left, + Right, +} + +#[derive(serde::Serialize, Clone, Copy)] +#[serde(rename_all = "lowercase")] +pub enum HudTone { + Cyan, + Lime, + Orange, + Violet, + Gold, + Rose, +} + +#[derive(serde::Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HudSignalPanel { + pub id: String, + pub code: String, + pub title: String, + pub side: HudPanelSide, + pub active: bool, + pub series: Vec, + pub icons: Vec, + pub latest: Option, + pub min: Option, + pub max: Option, +} + +#[derive(serde::Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HudSignalSeries { + pub id: String, + pub tone: HudTone, + pub points: Vec, +} + +#[derive(serde::Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HudSignalIcon { + pub id: String, + pub label: String, + pub tone: HudTone, +} + +struct HudPanelUpdate { + source_id: String, + values: Vec, +} + +struct PanelEntry { + panel: HudSignalPanel, + last_seen: Instant, +} + +pub struct HudChartState { + panels: HashMap, + order: Vec, + summary_points: Vec, + pressure_matrix: Option>, + last_frame_seen: Option, +} + +impl HudChartState { + pub fn new() -> Self { + Self { + panels: HashMap::new(), + order: Vec::new(), + summary_points: Vec::new(), + pressure_matrix: None, + last_frame_seen: None, + } + } + + pub fn record_summary(&mut self, value: f32) { + push_summary_point(&mut self.summary_points, value); + } + + pub fn record_pressure_matrix(&mut self, values: &[i32]) { + if values.is_empty() { + return; + } + + self.pressure_matrix = Some(values.iter().map(|value| *value as f32).collect()); + } + + pub fn apply_frame(&mut self, frame: &TestFrame, decoded_values: Option<&[i32]>) -> HudPacket { + let now = Instant::now(); + self.last_frame_seen = Some(now); + + for update in expand_frame_updates(frame, decoded_values) { + self.apply_update(update, now); + } + + self.prune_stale_at(now); + self.snapshot() + } + + pub fn prune_stale(&mut self) -> Option { + let before = self.panels.len(); + let summary_points_before = self.summary_points.len(); + self.prune_stale_at(Instant::now()); + + if before == self.panels.len() && summary_points_before == self.summary_points.len() { + return None; + } + + Some(self.snapshot()) + } + + fn apply_update(&mut self, update: HudPanelUpdate, now: Instant) { + if update.values.is_empty() { + return; + } + + if !self.panels.contains_key(&update.source_id) { + let next_side = side_for_index(self.order.len()); + self.order.push(update.source_id.clone()); + self.panels.insert( + update.source_id.clone(), + PanelEntry { + panel: build_panel(&update.source_id, next_side, update.values.len()), + last_seen: now, + }, + ); + } + + let entry = self + .panels + .get_mut(&update.source_id) + .expect("panel entry should exist after insertion"); + + entry.last_seen = now; + entry.panel.active = true; + ensure_panel_channels(&mut entry.panel, update.values.len()); + + for (index, value) in update.values.into_iter().enumerate() { + if let Some(series) = entry.panel.series.get_mut(index) { + push_point(&mut series.points, value); + } + } + + refresh_panel_stats(&mut entry.panel); + } + + fn prune_stale_at(&mut self, now: Instant) { + self.panels + .retain(|_, entry| now.duration_since(entry.last_seen) <= PANEL_STALE_AFTER); + self.order.retain(|id| self.panels.contains_key(id)); + + let summary_stale = self + .last_frame_seen + .map(|last_seen| now.duration_since(last_seen) > PANEL_STALE_AFTER) + .unwrap_or(false); + + if summary_stale { + self.summary_points.clear(); + self.pressure_matrix = None; + self.last_frame_seen = None; + } + } + + fn snapshot(&mut self) -> HudPacket { + self.rebalance_sides(); + + let panels = self + .order + .iter() + .filter_map(|id| self.panels.get(id).map(|entry| entry.panel.clone())) + .collect(); + + HudPacket { + ts: now_millis(), + panels, + summary: build_summary(&self.summary_points), + pressure_matrix: self.pressure_matrix.clone(), + } + } + + fn rebalance_sides(&mut self) { + for (index, id) in self.order.iter().enumerate() { + if let Some(entry) = self.panels.get_mut(id) { + entry.panel.side = side_for_index(index); + } + } + } +} + +impl Default for HudChartState { + fn default() -> Self { + Self::new() + } +} + +fn build_panel(source_id: &str, side: HudPanelSide, channel_count: usize) -> HudSignalPanel { + HudSignalPanel { + id: format!("panel-{source_id}"), + code: source_id.to_string(), + title: format!("Source {source_id}"), + side, + active: true, + series: build_panel_series(source_id, channel_count, &[]), + icons: build_panel_icons(source_id, channel_count), + latest: None, + min: None, + max: None, + } +} + +fn expand_frame_updates(frame: &TestFrame, decoded_values: Option<&[i32]>) -> Vec { + if let Some(values) = decoded_values { + if values.is_empty() { + return Vec::new(); + } + + return vec![HudPanelUpdate { + source_id: format_source_id(frame.cmd), + values: values.iter().map(|value| *value as f32).collect(), + }]; + } + + let chunks = frame.payload.chunks_exact(4); + + if !frame.payload.is_empty() && chunks.remainder().is_empty() { + return chunks.map(build_update_from_chunk).collect(); + } + + vec![HudPanelUpdate { + source_id: format_source_id(frame.cmd), + values: fallback_values(frame), + }] +} + +fn build_update_from_chunk(chunk: &[u8]) -> HudPanelUpdate { + HudPanelUpdate { + source_id: format_source_id(chunk[0]), + values: chunk[1..] + .iter() + .enumerate() + .map(|(index, byte)| normalize_value(*byte, tone_for_index(index))) + .collect(), + } +} + +fn fallback_values(frame: &TestFrame) -> Vec { + let mut bytes = frame.payload.clone(); + + if bytes.is_empty() { + bytes.extend([ + frame.cmd, + frame.length as u8, + frame.checksum, + frame.cmd.wrapping_add(frame.checksum), + ]); + } + + while bytes.len() < 3 { + let previous = *bytes.last().unwrap_or(&frame.cmd); + bytes.push( + previous + .wrapping_add(frame.cmd) + .wrapping_add(bytes.len() as u8), + ); + } + + bytes + .into_iter() + .enumerate() + .map(|(index, byte)| normalize_value(byte, tone_for_index(index))) + .collect() +} + +fn normalize_value(byte: u8, tone: HudTone) -> f32 { + let base = (byte as f32 / 255.0) * 100.0; + let offset = match tone { + HudTone::Cyan => 6.0, + HudTone::Lime => 0.0, + HudTone::Orange => -6.0, + HudTone::Violet => 10.0, + HudTone::Gold => -10.0, + HudTone::Rose => 3.0, + }; + + (base + offset).clamp(0.0, 100.0) +} + +fn format_source_id(byte: u8) -> String { + if byte.is_ascii_alphanumeric() { + (byte as char).to_ascii_uppercase().to_string() + } else { + format!("CH{:02X}", byte) + } +} + +fn side_for_index(index: usize) -> HudPanelSide { + if index % 2 == 0 { + HudPanelSide::Left + } else { + HudPanelSide::Right + } +} + +fn push_point(points: &mut Vec, value: f32) { + if points.len() >= MAX_POINTS { + points.remove(0); + } + + points.push((value * 10.0).round() / 10.0); +} + +fn build_panel_series( + source_id: &str, + channel_count: usize, + previous: &[HudSignalSeries], +) -> Vec { + (0..channel_count) + .map(|index| HudSignalSeries { + id: format!("{source_id}-series-{}", index + 1), + tone: tone_for_index(index), + points: previous + .get(index) + .map(|series| series.points.clone()) + .unwrap_or_default(), + }) + .collect() +} + +fn build_panel_icons(source_id: &str, channel_count: usize) -> Vec { + (0..channel_count) + .map(|index| HudSignalIcon { + id: format!("{source_id}-icon-{}", index + 1), + label: if channel_count == 1 { + "TOTAL".to_string() + } else { + format!("{source_id}-{}", index + 1) + }, + tone: tone_for_index(index), + }) + .collect() +} + +fn ensure_panel_channels(panel: &mut HudSignalPanel, channel_count: usize) { + if panel.series.len() == channel_count && panel.icons.len() == channel_count { + return; + } + + panel.series = build_panel_series(&panel.code, channel_count, &panel.series); + panel.icons = build_panel_icons(&panel.code, channel_count); +} + +fn refresh_panel_stats(panel: &mut HudSignalPanel) { + let latest_values: Vec = panel + .series + .iter() + .filter_map(|series| series.points.last().copied()) + .collect(); + + panel.latest = if latest_values.is_empty() { + None + } else { + Some(latest_values.iter().sum::() / latest_values.len() as f32) + }; + + panel.min = panel + .series + .iter() + .flat_map(|series| series.points.iter().copied()) + .reduce(f32::min); + + panel.max = panel + .series + .iter() + .flat_map(|series| series.points.iter().copied()) + .reduce(f32::max); +} + +fn tone_for_index(index: usize) -> HudTone { + match index % 6 { + 0 => HudTone::Cyan, + 1 => HudTone::Lime, + 2 => HudTone::Orange, + 3 => HudTone::Violet, + 4 => HudTone::Gold, + _ => HudTone::Rose, + } +} + +fn push_summary_point(points: &mut Vec, value: f32) { + if points.len() >= MAX_SUMMARY_POINTS { + points.remove(0); + } + + points.push((value * 10.0).round() / 10.0); +} + +fn build_summary(points: &[f32]) -> HudSummary { + HudSummary { + label: "TOTAL".to_string(), + points: points.to_vec(), + latest: points.last().copied(), + min: points.iter().copied().reduce(f32::min), + max: points.iter().copied().reduce(f32::max), + } +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default() +} + +// #[cfg(test)] +// mod tests { +// use super::*; +// +// fn sample_frame() -> TestFrame { +// TestFrame { +// header: [0xAA, 0x55], +// cmd: 0x01, +// length: 4, +// payload: vec![0x00, 0x0A, 0x00, 0x14], +// checksum: 0, +// +// } +// } +// +// #[test] +// fn prune_stale_clears_panels_and_summary_after_timeout() { +// let mut state = HudChartState::new(); +// let frame = sample_frame(); +// +// state.record_summary(30.0); +// let _ = state.apply_frame(&frame, Some(&[10, 20])); +// +// let stale_now = Instant::now(); +// let stale_seen = stale_now - PANEL_STALE_AFTER - Duration::from_millis(1); +// +// state.last_frame_seen = Some(stale_seen); +// +// for entry in state.panels.values_mut() { +// entry.last_seen = stale_seen; +// } +// +// let packet = state +// .prune_stale() +// .expect("stale data should emit an update"); +// +// assert!(packet.panels.is_empty()); +// assert!(packet.summary.points.is_empty()); +// assert!(state.panels.is_empty()); +// assert!(state.summary_points.is_empty()); +// } +// +// #[test] +// fn prune_stale_keeps_recent_summary_points() { +// let mut state = HudChartState::new(); +// let frame = sample_frame(); +// +// state.record_summary(30.0); +// let _ = state.apply_frame(&frame, Some(&[10, 20])); +// +// state.last_frame_seen = Some(Instant::now()); +// +// assert!(state.prune_stale().is_none()); +// assert_eq!(state.summary_points, vec![30.0]); +// assert_eq!(state.panels.len(), 1); +// } +// } diff --git a/src/serial_core/record.rs b/src/serial_core/record.rs new file mode 100644 index 0000000..7a20d35 --- /dev/null +++ b/src/serial_core/record.rs @@ -0,0 +1,56 @@ +#[derive(Clone)] +pub struct FrameTiming { + pub pts_ms: Option, + pub dts_ms: u64, +} + +#[derive(Clone)] +pub struct RecordedFrame { + pub timing: FrameTiming, + pub frame: F +} + +#[derive(Clone, Default)] +pub struct Recording { + pub frames: Vec> +} + +impl Recording { + pub fn new() -> Recording { Self { frames: Vec::new() } } + pub fn push(&mut self, ite: RecordedFrame) { + self.frames.push(ite); + } +} + +pub trait CsvExporter { + type Error: std::error::Error + Send + Sync + 'static; + fn csv_header(&self, recording: &Recording) -> Vec; + fn csv_row(&self, item: &RecordedFrame) -> anyhow::Result>; +} + +// TODO: CsvImporter +pub trait CsvImporter

{ + fn load(&mut self, reader: R) -> anyhow::Result>; +} + +pub fn write_csv( + recording: &Recording, + exporter: &E, + writer: W, +) -> anyhow::Result<()> +where + E: CsvExporter, + W: std::io::Write, +{ + let header = exporter.csv_header(&recording); + let mut wrt = csv::Writer::from_writer(writer); + wrt.write_record(header)?; + for f in &recording.frames { + let row = exporter.csv_row(f)?; + wrt.write_record(&row)?; + } + + wrt.flush()?; + + Ok(()) +} diff --git a/src/serial_core/serial.rs b/src/serial_core/serial.rs new file mode 100644 index 0000000..89c33c7 --- /dev/null +++ b/src/serial_core/serial.rs @@ -0,0 +1,251 @@ +use crate::serial_core::codec::Codec; +use crate::serial_core::codecs::tactile_a::TactileACodec; +use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame}; +use crate::serial_core::model::{HudChartState, HudPacket}; +use crate::serial_core::record::Recording; +use anyhow::Result; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::{self, Duration, MissedTickBehavior}; +use tokio_serial::SerialStream; +use tokio_util::sync::CancellationToken; +use std::future::pending; +use std::sync::{Arc, Mutex}; +use std::time::Instant; +use crate::serial_core::record::{FrameTiming, RecordedFrame}; + +pub enum PollMode { + Disable, + Enabled(Box>) +} + +pub trait SerialFrame: Clone + Send + 'static { + fn dts_ms(&self) -> u64; + + fn to_hud_packet( + &self, + chart_state: &mut HudChartState, + display_values: Option<&[i32]>, + ) -> Option; +} + + +pub trait PollRequester: Send { + fn poll_interval(&self) -> Option { + None + } + + fn should_request(&mut self) -> bool { + true + } + + fn next_request(&mut self) -> Result> { + Ok(None) + } + + fn on_rx_frame(&mut self, _frame: &F) {} +} + +#[derive(Default)] +pub struct NoopPollRequester; + +impl PollRequester for NoopPollRequester {} + +pub struct TactileAPollRequester { + period: Duration, + cols: usize, + rows: usize, + awaiting_reply: bool, + last_request_at: Option, + reply_timeout: Duration, +} + +impl TactileAPollRequester { + pub fn new(period: Duration, cols: usize, rows: usize, reply_timeout: Duration) -> Self { + Self { + period, + cols, + rows, + awaiting_reply: false, + last_request_at: None, + reply_timeout, + } + } +} + +impl PollRequester for TactileAPollRequester { + fn poll_interval(&self) -> Option { + Some(self.period) + } + + fn should_request(&mut self) -> bool { + if !self.awaiting_reply { + return true; + } + let timed_out = self + .last_request_at + .map(|t| t.elapsed() >= self.reply_timeout) + .unwrap_or(false); + + if timed_out { + self.awaiting_reply = false; + self.last_request_at = None; + return true; + } + + false + } + + fn next_request(&mut self) -> Result> { + let req = TactileACodec::build_req_frame(self.cols, self.rows)?; + self.awaiting_reply = true; + self.last_request_at = Some(Instant::now()); + Ok(Some(req)) + } + + fn on_rx_frame(&mut self, frame: &TactileAFrame) { + if matches!(frame, TactileAFrame::Rep(_)) { + self.awaiting_reply = false; + self.last_request_at = None + } + } +} + +pub async fn run_serial( + port: SerialStream, + codec: C, + handler: H, + session_started_at: Instant, + recording: Arc>>, + cancel: CancellationToken, +) -> Result<()> +where + F: SerialFrame, + C: Codec + Send + 'static, + H: FrameHandler + Send + 'static, + T: Into +{ + run_serial_with_poll( + port, codec, handler, session_started_at, recording, cancel, PollMode::Disable + ).await +} + +pub async fn run_serial_with_poll( + mut port: SerialStream, + mut codec: C, + mut handler: H, + session_started_at: Instant, + recording: Arc>>, + cancel: CancellationToken, + poll_mode: PollMode +) -> Result<()> +where + F: SerialFrame, + C: Codec + Send + 'static, + H: FrameHandler + Send + 'static, + T: Into, +{ + let mut requester = match poll_mode { + PollMode::Disable => None, + PollMode::Enabled(r) => Some(r), + }; + + let mut poll_interval = requester + .as_ref() + .and_then(|r| r.poll_interval()) + .map(|d| { + let mut it = time::interval(d); + it.set_missed_tick_behavior(MissedTickBehavior::Skip); + it + }); + + let mut chart_state = HudChartState::new(); + let mut buffer = [0u8; 1024]; + let mut prune_interval = time::interval(Duration::from_millis(450)); + prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + + + loop { + tokio::select! { + _ = cancel.cancelled() => break, + _ = async { + match poll_interval.as_mut() { + Some(it) => { + it.tick().await; + } + None => pending::<()>().await, + } + } => { + if let Some(r) = requester.as_mut() { + if r.should_request() { + if let Some(req) = r.next_request()? { + let bytes = codec.encode(&req)?; + port.write_all(&bytes).await?; + } + } + } + } + read_result = port.read(&mut buffer) => { + let n = read_result?; + if n == 0 { + // Some serial drivers can resolve reads with 0 bytes repeatedly. + // Yield here so timer-driven poll requests are not starved by a busy loop. + tokio::task::yield_now().await; + continue; + } + + let frames = codec.decode(&buffer[..n], session_started_at)?; + for frame in frames { + if let Some(r) = requester.as_mut() { + r.on_rx_frame(&frame); + } + + let decode_res = handler + .on_frame(&frame) + .await? + .map(|vals| vals.into_iter().map(Into::into).collect::>()); + + let mut record = recording.lock().map_err(|_| anyhow::anyhow!("recording state poisoned"))?; + record.push(RecordedFrame{ + timing: FrameTiming { pts_ms: None, dts_ms: frame.dts_ms() }, + frame: frame.clone(), + }); + + } + } + } + } + Ok(()) +} + +fn raw_to_g1(raw: u32) -> f64 { + const X: [u32; 11] = [ + 0, 74602, 105503, 131459, 153512, 172041, 193794, 218947, 240580, 295118, 332346, + ]; + + const Y: [f64; 11] = [ + 0.0, 160.0, 260.0, 360.0, 460.0, 560.0, 660.0, 860.0, 1060.0, 1560.0, 2060.0, + ]; + + let n = X.len(); + if raw <= X[0] { + return Y[0] / 100.0; + } + if raw >= X[n - 1] { + return Y[n - 1] / 100.0; + } + + let mut left = 0; + let mut right = n - 1; + + while left + 1 < right { + let mid = (left + right) / 2; + if raw < X[mid] { + right = mid; + } else { + left = mid; + } + } + + let ratio = (raw - X[left]) as f64 / (X[right] - X[left]) as f64; + Y[left] / 100.0 + ratio * (Y[right] - Y[left]) / 100.0 +} diff --git a/src/serial_core/utils.rs b/src/serial_core/utils.rs new file mode 100644 index 0000000..f5b2542 --- /dev/null +++ b/src/serial_core/utils.rs @@ -0,0 +1,59 @@ + +use std::time::Instant; + +pub fn usize_to_u16_be_bytes(n: usize) -> [u8; 2] { + (n as u16).to_be_bytes() +} + +pub fn usize_to_u16_le_bytes(n: usize) -> [u8; 2] { + (n as u16).to_be_bytes() +} + +pub fn u16_to_hex_be_bytes(n: u16) -> [u8; 2] { + (n as u16).to_be_bytes() +} + +pub fn u16_to_hex_le_bytes(n: u16) -> [u8; 2] { + (n as u16).to_le_bytes() +} + +pub fn calc_crc8_smbus(c: &[u8]) -> u8 { + let crc8_smbus = crc::Crc::::new(&crc::CRC_8_SMBUS); + let checksum = crc8_smbus.checksum(c); + return checksum; +} + +pub fn calc_crc8_itu(c: &[u8]) -> u8 { + let crc8_itu_alg = crc::Crc::::new(&crc::CRC_8_I_432_1); + let checksum = crc8_itu_alg.checksum(c); + return checksum; +} + +pub fn elapsed_millis(start_at: Instant) -> u64 { + start_at.elapsed().as_millis() as u64 +} + +#[cfg(test)] +mod test { + use anyhow::Ok; + + use crate::serial_core::utils::{calc_crc8_itu, calc_crc8_smbus}; + + #[test] + fn test_crc8_itu() -> anyhow::Result<()> { + let req_vec = vec![0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00]; + let checksum = calc_crc8_itu(req_vec.as_slice()); + assert_eq!(checksum, 0x7A); + + Ok(()) + } + + #[test] + fn test_crc8_smbus() -> anyhow::Result<()> { + let req_vec = vec![0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00]; + let checksum = calc_crc8_smbus(req_vec.as_slice()); + assert_eq!(checksum, 0x2F); + + Ok(()) + } +} \ No newline at end of file