refactor: TUI multi-feed monitor with binary parsers

- Add ratatui/crossterm TUI replacing stdout data dump
- Add binary parsers for KSUDP RTCarInfo (port 9996) and Telemetry Tool (port 10101)
- Add shared binary_utils module for safe packet reading
- Add ring-buffer frequency calculation and feed health state machine
- Add parse_first_driver() to avoid unnecessary multi-driver allocation
- Add 27 unit tests (14 feed_state + 6 ksudp + 7 telemetry_tool)
- Move parsing outside mutex lock to minimize TUI contention
- Replace expect() on env var with graceful error handling
- Remove unused serde dependency
- Delete obsolete stats.rs and telemetry.rs
This commit is contained in:
Jan-Peter von Hunnius 2026-06-04 08:48:35 +02:00
parent 1731495197
commit 7a1c37fc86
11 changed files with 2308 additions and 307 deletions

464
Cargo.lock generated
View file

@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@ -35,6 +41,21 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.63" version = "1.2.63"
@ -64,12 +85,97 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "compact_str"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@ -77,7 +183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -86,6 +192,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@ -174,6 +286,23 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.65" version = "0.1.65"
@ -198,6 +327,43 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@ -222,6 +388,12 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@ -237,6 +409,15 @@ version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.1" version = "2.8.1"
@ -250,8 +431,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -292,6 +474,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@ -316,6 +504,27 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools",
"lru",
"paste",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@ -325,6 +534,19 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@ -333,70 +555,54 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]] [[package]]
name = "rusty-telemetry" name = "rusty-telemetry"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"crossterm",
"futures", "futures",
"serde", "ratatui",
"serde_json",
"tokio", "tokio",
] ]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.8" version = "1.4.8"
@ -426,7 +632,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
] ]
[[package]] [[package]]
@ -454,7 +694,7 @@ dependencies = [
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -474,6 +714,35 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
@ -525,6 +794,28 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@ -584,6 +875,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@ -594,7 +894,65 @@ dependencies = [
] ]
[[package]] [[package]]
name = "zmij" name = "windows-targets"
version = "1.0.21" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 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"

View file

@ -1,12 +1,12 @@
[package] [package]
name = "rusty-telemetry" name = "rusty-telemetry"
version = "0.2.0" version = "0.3.0"
edition = "2021" edition = "2021"
description = "Sim racing telemetry receiver and analyzer" description = "Sim racing telemetry receiver and analyzer"
[dependencies] [dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4" chrono = "0.4"
futures = "0.3" futures = "0.3"
ratatui = "0.29"
crossterm = "0.28"

97
src/binary_utils.rs Normal file
View file

@ -0,0 +1,97 @@
//! Shared binary reading helpers for UDP packet parsers.
//!
//! All helpers use `get()` for safe bounds checking — they return a zero/empty
//! fallback instead of panicking when the offset is out of bounds.
use crate::telemetry_frame::NUM_WHEELS;
/// Read a `bool` (1 byte) from the data at the given offset.
/// Returns `false` if the offset is out of bounds.
pub fn read_bool(data: &[u8], offset: usize) -> bool {
data.get(offset).map(|&b| b != 0).unwrap_or(false)
}
/// Read a little-endian `u32` from the data at the given offset.
/// Returns 0 if the offset is out of bounds (safe fallback for malformed packets).
pub fn read_u32(data: &[u8], offset: usize) -> u32 {
let buf: [u8; 4] = data
.get(offset..offset + 4)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 4]);
u32::from_le_bytes(buf)
}
/// Read a little-endian `i32` from the data at the given offset.
/// Returns 0 if the offset is out of bounds (safe fallback for malformed packets).
pub fn read_i32(data: &[u8], offset: usize) -> i32 {
let buf: [u8; 4] = data
.get(offset..offset + 4)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 4]);
i32::from_le_bytes(buf)
}
/// Read a little-endian `i16` from the data at the given offset.
/// Returns 0 if the offset is out of bounds (safe fallback for malformed packets).
pub fn read_i16(data: &[u8], offset: usize) -> i16 {
let buf: [u8; 2] = data
.get(offset..offset + 2)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 2]);
i16::from_le_bytes(buf)
}
/// Read a little-endian `f32` from the data at the given offset.
/// Returns 0.0 if the offset is out of bounds (safe fallback for malformed packets).
pub fn read_f32(data: &[u8], offset: usize) -> f32 {
let buf: [u8; 4] = data
.get(offset..offset + 4)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 4]);
f32::from_le_bytes(buf)
}
/// Read a little-endian `f64` from the data at the given offset.
/// Returns 0.0 if the offset is out of bounds (safe fallback for malformed packets).
pub fn read_f64(data: &[u8], offset: usize) -> f64 {
let buf: [u8; 8] = data
.get(offset..offset + 8)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 8]);
f64::from_le_bytes(buf)
}
/// Read an array of 4 little-endian `f32` from the data at the given offset.
pub fn read_f32_4(data: &[u8], offset: usize) -> [f32; NUM_WHEELS] {
let mut arr = [0.0f32; NUM_WHEELS];
for i in 0..NUM_WHEELS {
arr[i] = read_f32(data, offset + i * 4);
}
arr
}
/// Read an array of 2 little-endian `f32` from the data at the given offset.
pub fn read_f32_2(data: &[u8], offset: usize) -> [f32; 2] {
[read_f32(data, offset), read_f32(data, offset + 4)]
}
/// Read an array of 3 little-endian `f32` from the data at the given offset.
pub fn read_f32_3(data: &[u8], offset: usize) -> [f32; 3] {
[read_f32(data, offset), read_f32(data, offset + 4), read_f32(data, offset + 8)]
}
/// Read an array of 3 little-endian `i32` from the data at the given offset.
pub fn read_i32_3(data: &[u8], offset: usize) -> [i32; 3] {
[read_i32(data, offset), read_i32(data, offset + 4), read_i32(data, offset + 8)]
}
/// Read a null-terminated/padded ASCII string.
/// Returns an empty string if the offset is out of bounds.
pub fn read_string(data: &[u8], offset: usize, len: usize) -> String {
let slice = match data.get(offset..offset + len) {
Some(s) => s,
None => return String::new(),
};
let end = slice.iter().position(|&b| b == 0).unwrap_or(len);
String::from_utf8_lossy(&slice[..end]).to_string()
}

389
src/feed_state.rs Normal file
View file

@ -0,0 +1,389 @@
use crate::telemetry_frame::TelemetryFrame;
use std::time::Instant;
const RING_CAPACITY: usize = 50;
const STALE_THRESHOLD_SECS: f64 = 5.0;
const MIN_TIME_SPAN_SECS: f64 = 0.001;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FeedHealth {
Waiting,
Alive,
Stale,
Error,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum HandshakePhase {
NotStarted,
WaitingForResponse,
Subscribing,
Subscribed,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FeedMode {
BindListen,
Handshake {
target_port: u16,
recv_port: u16,
},
Disabled,
}
#[derive(Clone)]
pub struct FeedState {
pub health: FeedHealth,
pub handshake_phase: HandshakePhase,
pub status_text: String,
pub total_packets: u64,
pub total_bytes: u64,
pub frequency_hz: f64,
pub data_rate_kb_s: f64,
pub last_packet_ago_secs: Option<f64>,
pub start_time: Option<Instant>,
pub last_packet_time: Option<Instant>,
pub packet_timestamps: Vec<Instant>,
pub ring_index: usize,
pub ring_count: usize,
pub last_frame: Option<TelemetryFrame>,
}
impl FeedState {
pub fn new() -> Self {
Self {
health: FeedHealth::Waiting,
handshake_phase: HandshakePhase::NotStarted,
status_text: String::from("Bound, waiting for data"),
total_packets: 0,
total_bytes: 0,
frequency_hz: 0.0,
data_rate_kb_s: 0.0,
last_packet_ago_secs: None,
start_time: None,
last_packet_time: None,
packet_timestamps: vec![Instant::now(); RING_CAPACITY],
ring_index: 0,
ring_count: 0,
last_frame: None,
}
}
pub fn update_frame(&mut self, frame: TelemetryFrame) {
self.last_frame = Some(frame);
}
pub fn record_packet(&mut self, len: usize) {
let now = Instant::now();
if self.start_time.is_none() {
self.start_time = Some(now);
}
self.total_packets += 1;
self.total_bytes += len as u64;
self.last_packet_time = Some(now);
self.last_packet_ago_secs = Some(0.0);
if self.ring_count < RING_CAPACITY {
if self.ring_count < self.packet_timestamps.len() {
self.packet_timestamps[self.ring_count] = now;
}
self.ring_count += 1;
self.ring_index = self.ring_count % RING_CAPACITY;
} else {
self.packet_timestamps[self.ring_index] = now;
self.ring_index = (self.ring_index + 1) % RING_CAPACITY;
}
self.frequency_hz = self.calc_frequency();
self.data_rate_kb_s = self.calc_data_rate();
self.health = FeedHealth::Alive;
if self.status_text != "Receiving" {
self.status_text = String::from("Receiving");
}
}
fn calc_frequency(&self) -> f64 {
if self.ring_count < 2 {
return 0.0;
}
let count = self.ring_count.min(RING_CAPACITY);
let oldest_idx = if self.ring_count >= RING_CAPACITY {
self.ring_index
} else {
0
};
let newest_idx = if self.ring_count >= RING_CAPACITY {
if self.ring_index == 0 {
RING_CAPACITY - 1
} else {
self.ring_index - 1
}
} else {
self.ring_count - 1
};
let oldest = self.packet_timestamps[oldest_idx];
let newest = self.packet_timestamps[newest_idx];
let span = newest.duration_since(oldest).as_secs_f64();
if span < MIN_TIME_SPAN_SECS {
return 0.0;
}
count as f64 / span
}
fn calc_data_rate(&self) -> f64 {
match self.start_time {
Some(start) => {
let elapsed = start.elapsed().as_secs_f64();
if elapsed > 0.0 {
(self.total_bytes as f64 / 1024.0) / elapsed
} else {
0.0
}
}
None => 0.0,
}
}
pub fn update_health(&mut self) {
match self.last_packet_ago_secs {
None => {
if self.health != FeedHealth::Error {
self.health = FeedHealth::Waiting;
self.status_text = String::from("Waiting for data");
}
}
Some(ago) => {
if ago < STALE_THRESHOLD_SECS {
self.health = FeedHealth::Alive;
self.status_text = String::from("Receiving");
} else {
self.health = FeedHealth::Stale;
self.status_text = format!("Stale ({:.1}s since last packet)", ago);
}
}
}
}
pub fn tick(&mut self) {
if let Some(last) = self.last_packet_time {
self.last_packet_ago_secs = Some(last.elapsed().as_secs_f64());
}
self.frequency_hz = self.calc_frequency();
self.data_rate_kb_s = self.calc_data_rate();
self.update_health();
}
pub fn set_handshake_phase(&mut self, phase: HandshakePhase) {
self.handshake_phase = phase;
}
pub fn set_error(&mut self, msg: &str) {
self.health = FeedHealth::Error;
self.status_text = format!("Error: {}", msg);
}
}
impl Default for FeedState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_new_state_is_waiting() {
let state = FeedState::new();
assert_eq!(state.health, FeedHealth::Waiting);
assert_eq!(state.handshake_phase, HandshakePhase::NotStarted);
assert_eq!(state.total_packets, 0);
assert_eq!(state.total_bytes, 0);
assert_eq!(state.frequency_hz, 0.0);
assert!(state.last_packet_ago_secs.is_none());
assert!(state.last_frame.is_none());
}
#[test]
fn test_record_packet_increments_counters() {
let mut state = FeedState::new();
state.record_packet(100);
assert_eq!(state.total_packets, 1);
assert_eq!(state.total_bytes, 100);
assert_eq!(state.health, FeedHealth::Alive);
assert_eq!(state.status_text, "Receiving");
state.record_packet(200);
assert_eq!(state.total_packets, 2);
assert_eq!(state.total_bytes, 300);
}
#[test]
fn test_frequency_zero_with_fewer_than_two_packets() {
let mut state = FeedState::new();
state.record_packet(100);
assert_eq!(state.frequency_hz, 0.0);
// With 2 packets at the same instant, span is < MIN_TIME_SPAN_SECS, so still 0
state.record_packet(100);
// frequency could be 0 if the two packets are within MIN_TIME_SPAN_SECS
// This is expected behavior
}
#[test]
fn test_frequency_nonzero_after_spaced_packets() {
let mut state = FeedState::new();
for _ in 0..10 {
state.record_packet(100);
thread::sleep(std::time::Duration::from_millis(10));
}
// 10 packets over ~90ms → frequency should be > 0
assert!(state.frequency_hz > 0.0);
// Rough sanity: 10 packets / 0.09s ≈ 111 Hz, but with overhead, just check > 0
assert!(state.frequency_hz < 10_000.0); // sanity upper bound
}
#[test]
fn test_ring_buffer_fills_without_wraparound() {
let mut state = FeedState::new();
for _ in 0..RING_CAPACITY {
state.record_packet(50);
}
assert_eq!(state.ring_count, RING_CAPACITY);
assert_eq!(state.ring_index, 0); // wraps to 0 after filling
}
#[test]
fn test_ring_buffer_wraps_around() {
let mut state = FeedState::new();
for _ in 0..RING_CAPACITY {
state.record_packet(50);
}
assert_eq!(state.ring_index, 0);
// Add more packets to trigger wraparound
for _ in 0..10 {
state.record_packet(50);
}
assert_eq!(state.ring_count, RING_CAPACITY);
assert_eq!(state.ring_index, 10);
assert_eq!(state.total_packets, (RING_CAPACITY + 10) as u64);
}
#[test]
fn test_health_waiting_to_alive() {
let mut state = FeedState::new();
assert_eq!(state.health, FeedHealth::Waiting);
state.record_packet(100);
assert_eq!(state.health, FeedHealth::Alive);
}
#[test]
fn test_health_alive_to_stale() {
let mut state = FeedState::new();
state.record_packet(100);
assert_eq!(state.health, FeedHealth::Alive);
// Simulate time passing by manually setting last_packet_ago_secs
state.last_packet_ago_secs = Some(6.0);
state.update_health();
assert_eq!(state.health, FeedHealth::Stale);
assert!(state.status_text.starts_with("Stale"));
}
#[test]
fn test_health_stale_to_alive() {
let mut state = FeedState::new();
state.record_packet(100);
state.last_packet_ago_secs = Some(6.0);
state.update_health();
assert_eq!(state.health, FeedHealth::Stale);
// New packet brings it back to alive
state.record_packet(100);
assert_eq!(state.health, FeedHealth::Alive);
}
#[test]
fn test_health_error_is_sticky_via_update() {
let mut state = FeedState::new();
state.set_error("test error");
assert_eq!(state.health, FeedHealth::Error);
// update_health should not override Error when ago is None
state.last_packet_ago_secs = None;
state.update_health();
assert_eq!(state.health, FeedHealth::Error);
}
#[test]
fn test_tick_updates_ago_secs() {
let mut state = FeedState::new();
// Before any packet, tick should not set ago
state.tick();
assert!(state.last_packet_ago_secs.is_none());
state.record_packet(100);
// Immediately after, ago should be ~0
state.tick();
assert!(state.last_packet_ago_secs.is_some());
assert!(state.last_packet_ago_secs.unwrap() < 1.0);
// After sleeping, ago should be > 0
thread::sleep(std::time::Duration::from_millis(100));
state.tick();
let ago = state.last_packet_ago_secs.unwrap();
assert!(ago >= 0.09, "expected ago >= 0.09, got {}", ago);
}
#[test]
fn test_data_rate_calculation() {
let mut state = FeedState::new();
state.record_packet(1024);
thread::sleep(std::time::Duration::from_millis(50));
state.record_packet(1024);
// 2048 bytes over ~50ms → roughly 40 KB/s
state.data_rate_kb_s = state.calc_data_rate();
assert!(state.data_rate_kb_s > 0.0);
}
#[test]
fn test_update_frame() {
let mut state = FeedState::new();
assert!(state.last_frame.is_none());
let frame = TelemetryFrame::default();
state.update_frame(frame);
assert!(state.last_frame.is_some());
}
#[test]
fn test_set_handshake_phase() {
let mut state = FeedState::new();
assert_eq!(state.handshake_phase, HandshakePhase::NotStarted);
state.set_handshake_phase(HandshakePhase::WaitingForResponse);
assert_eq!(state.handshake_phase, HandshakePhase::WaitingForResponse);
state.set_handshake_phase(HandshakePhase::Subscribed);
assert_eq!(state.handshake_phase, HandshakePhase::Subscribed);
}
#[test]
fn test_set_error() {
let mut state = FeedState::new();
state.set_error("bind failed");
assert_eq!(state.health, FeedHealth::Error);
assert_eq!(state.status_text, "Error: bind failed");
}
}

View file

@ -1,32 +1,59 @@
mod stats; mod binary_utils;
mod telemetry; mod feed_state;
mod ui;
mod telemetry_frame;
mod parser_ksudp;
mod parser_telemetry_tool;
use stats::ReceiveStats; use feed_state::*;
use telemetry::TelemetryFrame; use ui::*;
use std::io::Write; use std::io::{self, Stdout};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tokio::io::{AsyncWriteExt, BufWriter}; use std::time::Duration;
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use tokio::io::BufWriter;
use tokio::io::AsyncWriteExt;
use crate::feed_state::HandshakePhase;
use crate::telemetry_frame::TelemetryFrame;
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
const BUFFER_SIZE: usize = 65535; const BUFFER_SIZE: usize = 65535;
const RENDER_INTERVAL_MS: u64 = 250;
const HANDSHAKE_INTERVAL_SECS: u64 = 10;
const HANDSHAKE_RESPONSE_SIZE: usize = 408;
const HANDSHAKE_REQUEST: [u8; 12] = [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0];
const SUBSCRIBE_UPDATE: [u8; 12] = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0];
fn decode_utf16le(bytes: &[u8]) -> String {
let chars: Vec<u16> = bytes
.chunks(2)
.map(|c| u16::from_le_bytes([c[0], c.get(1).copied().unwrap_or(0)]))
.take_while(|&c| c != 0)
.collect();
String::from_utf16_lossy(&chars)
}
fn bind_addr() -> String { fn bind_addr() -> String {
std::env::var("RUSTY_TELEMETRY_BIND").unwrap_or_else(|_| "127.0.0.1".to_string()) std::env::var("RUSTY_TELEMETRY_BIND").unwrap_or_else(|_| "127.0.0.1".to_string())
} }
#[derive(Clone)]
enum FeedFormat {
Binary,
Json,
}
#[derive(Clone)] #[derive(Clone)]
struct FeedConfig { struct FeedConfig {
port: u16, port: u16,
name: &'static str, name: &'static str,
label: &'static str, label: &'static str,
format: FeedFormat, needs_handshake: bool,
} }
static FEEDS: &[FeedConfig] = &[ static FEEDS: &[FeedConfig] = &[
@ -34,53 +61,170 @@ static FEEDS: &[FeedConfig] = &[
port: 9996, port: 9996,
name: "ksudp", name: "ksudp",
label: "KSUDP", label: "KSUDP",
format: FeedFormat::Binary, needs_handshake: true,
}, },
FeedConfig { FeedConfig {
port: 10101, port: 10101,
name: "telemetry_tool", name: "telemetry_tool",
label: "Telemetry Tool", label: "Telemetry Tool",
format: FeedFormat::Binary, needs_handshake: false,
},
FeedConfig {
port: 5005,
name: "ac_plugin",
label: "AC Lua Plugin",
format: FeedFormat::Json,
}, },
]; ];
fn print_banner(results: &[(&FeedConfig, bool)], session_dir: &str, bind: &str) { type Term = Terminal<CrosstermBackend<Stdout>>;
println!("========================================");
println!(" rusty-telemetry v{VERSION} — Multi-feed telemetry listener"); fn setup_terminal() -> io::Result<Term> {
println!("========================================"); enable_raw_mode()?;
for (feed, ok) in results { let mut stdout = io::stdout();
let status = if *ok { "✅ Listening" } else { "❌ Failed" }; execute!(stdout, EnterAlternateScreen)?;
println!( let backend = CrosstermBackend::new(stdout);
" Feed :{} [{}] {}", let terminal = Terminal::new(backend)?;
feed.port, feed.label, status Ok(terminal)
); }
}
println!("========================================"); fn restore_terminal(terminal: &mut Term) -> io::Result<()> {
println!(" Bind: {bind}"); disable_raw_mode()?;
println!(" Data dir: {session_dir}/"); execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
println!("========================================"); terminal.show_cursor()?;
println!(); Ok(())
}
fn install_panic_hook() {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original_hook(info);
}));
} }
async fn run_feed( async fn run_feed(
feed: FeedConfig, feed: FeedConfig,
socket: tokio::net::UdpSocket, socket: tokio::net::UdpSocket,
session_dir: String, session_dir: String,
state: Arc<std::sync::Mutex<FeedState>>,
shutdown: Arc<tokio::sync::Notify>, shutdown: Arc<tokio::sync::Notify>,
) { ) {
let ext = match feed.format { let handshake_target: Option<std::net::SocketAddr> = if feed.needs_handshake {
FeedFormat::Binary => "bin", let target: std::net::SocketAddr = match format!("{}:{}", bind_addr(), feed.port).parse() {
FeedFormat::Json => "json", Ok(addr) => addr,
Err(e) => {
eprintln!(
"[ERROR] {} :{} Invalid target address '{}': {}",
feed.label, feed.port, bind_addr(), e
);
if let Ok(mut s) = state.lock() {
s.set_error(&format!("Invalid target address: {}", e));
}
return;
}
};
// Phase 1: Send HANDSHAKE
eprintln!(
"[INFO] {} :{} Phase 1: Sending HANDSHAKE to {}",
feed.label, feed.port, target
);
if let Err(e) = socket.send_to(&HANDSHAKE_REQUEST, target).await {
eprintln!(
"[WARN] HANDSHAKE send failed for :{}: {}",
feed.port, e
);
}
if let Ok(mut s) = state.lock() {
s.set_handshake_phase(HandshakePhase::WaitingForResponse);
}
// Phase 2: Wait for 408-byte HandshakerResponse with timeout
let mut hs_buf = [0u8; HANDSHAKE_RESPONSE_SIZE];
match tokio::time::timeout(
Duration::from_secs(5),
socket.recv_from(&mut hs_buf),
)
.await
{
Ok(Ok((len, _src))) if len == HANDSHAKE_RESPONSE_SIZE => {
let car_name = decode_utf16le(&hs_buf[0..100]);
let driver_name = decode_utf16le(&hs_buf[100..200]);
let identifier = i32::from_le_bytes([
hs_buf[200], hs_buf[201], hs_buf[202], hs_buf[203],
]);
let version = i32::from_le_bytes([
hs_buf[204], hs_buf[205], hs_buf[206], hs_buf[207],
]);
let track_name = decode_utf16le(&hs_buf[208..308]);
let track_config = decode_utf16le(&hs_buf[308..408]);
eprintln!(
"[INFO] {} :{} Phase 2: HandshakerResponse received",
feed.label, feed.port
);
eprintln!(
"[INFO] car_name=\"{}\" driver_name=\"{}\" id={} ver={}",
car_name, driver_name, identifier, version
);
eprintln!(
"[INFO] track_name=\"{}\" track_config=\"{}\"",
track_name, track_config
);
if identifier != 4242 {
eprintln!(
"[WARN] {} :{} Unexpected handshake identifier: {} (expected 4242)",
feed.label, feed.port, identifier
);
}
}
Ok(Ok((len, _))) => {
eprintln!(
"[WARN] {} :{} Phase 2: Unexpected response size {} (expected {})",
feed.label, feed.port, len, HANDSHAKE_RESPONSE_SIZE
);
}
Ok(Err(e)) => {
eprintln!(
"[WARN] {} :{} Phase 2: Recv error: {}",
feed.label, feed.port, e
);
}
Err(_) => {
eprintln!(
"[WARN] {} :{} Phase 2: Handshake response timed out (5s). AC may not be running.",
feed.label, feed.port
);
}
}
// Phase 3: Send SUBSCRIBE_UPDATE
if let Ok(mut s) = state.lock() {
s.set_handshake_phase(HandshakePhase::Subscribing);
}
eprintln!(
"[INFO] {} :{} Phase 3: Sending SUBSCRIBE_UPDATE",
feed.label, feed.port
);
if let Err(e) = socket.send_to(&SUBSCRIBE_UPDATE, target).await {
eprintln!(
"[WARN] SUBSCRIBE_UPDATE send failed for :{}: {}",
feed.port, e
);
}
if let Ok(mut s) = state.lock() {
s.set_handshake_phase(HandshakePhase::Subscribed);
}
eprintln!(
"[INFO] {} :{} Subscribed, entering streaming loop",
feed.label, feed.port
);
Some(target)
} else {
None
}; };
let file_path = format!( let file_path = format!(
"{session_dir}/feed_{}_{}.{ext}", "{}/feed_{}_{}.bin",
feed.port, feed.name session_dir, feed.port, feed.name
); );
let file = match tokio::fs::OpenOptions::new() let file = match tokio::fs::OpenOptions::new()
.create(true) .create(true)
@ -94,70 +238,59 @@ async fn run_feed(
"[ERROR] Cannot open dump file for :{} ({}): {}", "[ERROR] Cannot open dump file for :{} ({}): {}",
feed.port, feed.name, e feed.port, feed.name, e
); );
if let Ok(mut s) = state.lock() {
s.set_error(&format!("Cannot open dump file: {}", e));
}
return; return;
} }
}; };
let mut file = BufWriter::new(file); let mut file = BufWriter::new(file);
let mut buf = vec![0u8; BUFFER_SIZE]; let mut buf = vec![0u8; BUFFER_SIZE];
let mut stats = ReceiveStats::new();
let stdout = std::io::stdout(); let mut hs_interval: Option<tokio::time::Interval> = handshake_target.map(|_| {
tokio::time::interval(Duration::from_secs(HANDSHAKE_INTERVAL_SECS))
});
loop { loop {
tokio::select! { tokio::select! {
result = socket.recv_from(&mut buf) => { result = socket.recv_from(&mut buf) => {
match result { match result {
Ok((len, _src)) => { Ok((len, _src)) => {
stats.record(len); // Parse telemetry data outside the lock to minimize
// contention with the TUI render thread.
let parsed: Option<TelemetryFrame> = if feed.needs_handshake {
// KSUDP: parse RTCarInfo (skip 408-byte handshake responses)
if len != HANDSHAKE_RESPONSE_SIZE {
parser_ksudp::parse_rt_car_info(&buf[..len]).ok()
} else {
None
}
} else {
// Telemetry Tool: parse only the first driver
parser_telemetry_tool::parse_first_driver(&buf[..len]).ok()
};
if let FeedFormat::Binary = feed.format { // Update state in a single brief lock acquisition.
let header = (len as u32).to_be_bytes(); if let Ok(mut s) = state.lock() {
if let Err(e) = file.write_all(&header).await { s.record_packet(len);
eprintln!( if let Some(frame) = parsed {
"[ERROR] Write error on :{} ({}): {}", s.update_frame(frame);
feed.port, feed.name, e
);
} }
} }
let header = (len as u32).to_be_bytes();
if let Err(e) = file.write_all(&header).await {
eprintln!(
"[ERROR] Write error on :{} ({}): {}",
feed.port, feed.name, e
);
}
if let Err(e) = file.write_all(&buf[..len]).await { if let Err(e) = file.write_all(&buf[..len]).await {
eprintln!( eprintln!(
"[ERROR] Write error on :{} ({}): {}", "[ERROR] Write error on :{} ({}): {}",
feed.port, feed.name, e feed.port, feed.name, e
); );
} }
if let FeedFormat::Json = feed.format {
let _ = file.write_all(b"\n").await;
}
{
let mut handle = stdout.lock();
let fmt_tag = match feed.format {
FeedFormat::Binary => "Binary",
FeedFormat::Json => "JSON",
};
let _ = writeln!(
handle,
"[ :{} | {} ] {fmt_tag} packet: {} bytes (#{})",
feed.port,
feed.label,
len,
stats.total_packets,
);
if let FeedFormat::Json = feed.format {
if let Ok(text) = std::str::from_utf8(&buf[..len]) {
if let Ok(frame) =
serde_json::from_str::<TelemetryFrame>(text)
{
let _ = writeln!(handle, "{frame}");
} else {
let _ = writeln!(handle, " [WARN] JSON parse failed");
}
}
}
let _ = writeln!(handle, " {}", stats);
let _ = writeln!(handle);
}
} }
Err(e) => { Err(e) => {
eprintln!( eprintln!(
@ -167,6 +300,21 @@ async fn run_feed(
} }
} }
} }
_ = async {
match hs_interval.as_mut() {
Some(interval) => interval.tick().await,
None => std::future::pending().await,
}
} => {
if let Some(target) = handshake_target {
if let Err(e) = socket.send_to(&SUBSCRIBE_UPDATE, target).await {
eprintln!(
"[WARN] Periodic SUBSCRIBE_UPDATE send failed for :{}: {}",
feed.port, e
);
}
}
}
_ = shutdown.notified() => { _ = shutdown.notified() => {
break; break;
} }
@ -193,70 +341,155 @@ async fn run_feed(
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let bind = bind_addr(); install_panic_hook();
let bind = bind_addr();
let session_id = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string(); let session_id = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
let session_dir = format!("./data/{session_id}"); let session_dir = format!("./data/{}", session_id);
if let Err(e) = tokio::fs::create_dir_all(&session_dir).await { if let Err(e) = tokio::fs::create_dir_all(&session_dir).await {
eprintln!("[ERROR] Cannot create data dir {}: {}", session_dir, e); eprintln!("[ERROR] Cannot create data dir {}: {}", session_dir, e);
std::process::exit(1); std::process::exit(1);
} }
let mut banner_data: Vec<(&FeedConfig, bool)> = Vec::new(); let mut terminal = match setup_terminal() {
let mut bound_feeds: Vec<(FeedConfig, tokio::net::UdpSocket)> = Vec::new(); Ok(t) => t,
Err(e) => {
eprintln!("[ERROR] Failed to setup terminal: {}", e);
std::process::exit(1);
}
};
let mut feed_displays: Vec<(FeedDisplayInfo, Arc<std::sync::Mutex<FeedState>>)> = Vec::new();
let mut feed_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();
let shutdown = Arc::new(tokio::sync::Notify::new());
for feed in FEEDS { for feed in FEEDS {
let addr = format!("{bind}:{}", feed.port); let state = Arc::new(std::sync::Mutex::new(FeedState::new()));
match tokio::net::UdpSocket::bind(&addr).await {
Ok(socket) => { let (socket_opt, mode) = if feed.needs_handshake {
banner_data.push((feed, true)); let recv_addr = format!("{}:0", bind);
bound_feeds.push((feed.clone(), socket)); match tokio::net::UdpSocket::bind(&recv_addr).await {
Ok(s) => {
let local_port = s.local_addr().map(|a| a.port()).unwrap_or(0);
(Some(s), FeedMode::Handshake {
target_port: feed.port,
recv_port: local_port,
})
}
Err(e) => {
eprintln!(
"[WARN] Failed to bind handshake recv socket for :{} ({}): {}",
feed.port, feed.label, e
);
if let Ok(mut s) = state.lock() {
s.set_error(&format!("Bind failed: {}", e));
}
(None, FeedMode::Handshake {
target_port: feed.port,
recv_port: 0,
})
}
} }
Err(e) => { } else {
eprintln!( let addr = format!("{}:{}", bind, feed.port);
"[WARN] Failed to bind :{} ({}): {} — skipping", match tokio::net::UdpSocket::bind(&addr).await {
feed.port, feed.label, e Ok(s) => (Some(s), FeedMode::BindListen),
); Err(e) => {
banner_data.push((feed, false)); eprintln!(
"[WARN] Failed to bind :{} ({}): {} -- feed is optional, continuing without it",
feed.port, feed.label, e
);
(None, FeedMode::Disabled)
}
}
};
feed_displays.push((
FeedDisplayInfo {
label: feed.label,
port: feed.port,
mode,
},
state.clone(),
));
if let Some(socket) = socket_opt {
let feed_clone = feed.clone();
let dir = session_dir.clone();
let sh = shutdown.clone();
feed_tasks.push(tokio::spawn(async move {
run_feed(feed_clone, socket, dir, state, sh).await;
}));
}
}
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<Event>(100);
let event_running = Arc::new(AtomicBool::new(true));
let event_running_clone = event_running.clone();
tokio::spawn(async move {
while event_running_clone.load(Ordering::Relaxed) {
if event::poll(Duration::from_millis(100)).unwrap_or(false) {
if let Ok(ev) = event::read() {
if event_tx.send(ev).await.is_err() {
break;
}
}
}
}
});
let mut render_interval = tokio::time::interval(Duration::from_millis(RENDER_INTERVAL_MS));
let running = Arc::new(AtomicBool::new(true));
while running.load(Ordering::Relaxed) {
tokio::select! {
maybe_event = event_rx.recv() => {
if let Some(ev) = maybe_event {
match ev {
Event::Key(key) => {
match key.code {
KeyCode::Char('q') => {
running.store(false, Ordering::Relaxed);
}
KeyCode::Char('c')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
running.store(false, Ordering::Relaxed);
}
_ => {}
}
}
Event::Resize(_, _) => {
let _ = terminal.draw(|f| {
render(f, &feed_displays, &session_dir, VERSION);
});
}
_ => {}
}
}
}
_ = render_interval.tick() => {
let _ = terminal.draw(|f| {
render(f, &feed_displays, &session_dir, VERSION);
});
} }
} }
} }
print_banner(&banner_data, &session_dir, &bind); event_running.store(false, Ordering::Relaxed);
if bound_feeds.is_empty() {
eprintln!("[ERROR] No feeds could bind. Exiting.");
std::process::exit(1);
}
let shutdown = Arc::new(tokio::sync::Notify::new());
let mut tasks = Vec::new();
for (feed, socket) in bound_feeds {
let dir = session_dir.clone();
let shutdown = shutdown.clone();
tasks.push(tokio::spawn(async move {
run_feed(feed, socket, dir, shutdown).await;
}));
}
tokio::signal::ctrl_c().await.ok();
println!();
println!("[INFO] Ctrl+C received, shutting down...");
shutdown.notify_waiters(); shutdown.notify_waiters();
match tokio::time::timeout( match tokio::time::timeout(
std::time::Duration::from_secs(3), Duration::from_secs(3),
futures::future::join_all(tasks), futures::future::join_all(feed_tasks),
) )
.await .await
{ {
Ok(results) => { Ok(results) => {
for r in results { for r in results {
if let Err(e) = r { if let Err(e) = r {
eprintln!("[WARN] Task join error: {e}"); eprintln!("[WARN] Task join error: {}", e);
} }
} }
} }
@ -264,4 +497,10 @@ async fn main() {
eprintln!("[WARN] Timeout waiting for feeds to flush; forcing exit"); eprintln!("[WARN] Timeout waiting for feeds to flush; forcing exit");
} }
} }
if let Err(e) = restore_terminal(&mut terminal) {
eprintln!("[WARN] Failed to restore terminal: {}", e);
}
eprintln!("[INFO] rusty-telemetry shut down cleanly.");
} }

241
src/parser_ksudp.rs Normal file
View file

@ -0,0 +1,241 @@
//! Binary parser for KSUDP RTCarInfo packets (Assetto Corsa port 9996).
//!
//! After the 3-phase handshake, AC sends continuous RTCarInfo structs at ~20 Hz.
//! This parser converts the raw binary data into a `TelemetryFrame`.
use crate::binary_utils::{read_bool, read_f32, read_f32_4, read_i32};
use crate::telemetry_frame::{FeedSource, TelemetryFrame};
/// Minimum expected packet size (identifier + size field + all known fields).
/// The `size` field in the packet itself reports the true struct size.
const MIN_PACKET_SIZE: usize = 323;
/// Parse error type for KSUDP packets.
#[derive(Debug)]
pub enum KsudpParseError {
/// Packet too short to contain a valid RTCarInfo.
TooShort { actual: usize, expected: usize },
/// Invalid identifier byte (expected 0x61 = 'a').
InvalidIdentifier { got: u8 },
}
impl std::fmt::Display for KsudpParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KsudpParseError::TooShort { actual, expected } => {
write!(f, "packet too short: {} bytes (expected >= {})", actual, expected)
}
KsudpParseError::InvalidIdentifier { got } => {
write!(f, "invalid identifier: 0x{:02x} (expected 0x61 = 'a')", got)
}
}
}
}
impl std::error::Error for KsudpParseError {}
/// Parse a raw KSUDP RTCarInfo packet into a `TelemetryFrame`.
///
/// # Errors
/// Returns `KsudpParseError` if the packet is too short or has an invalid identifier.
pub fn parse_rt_car_info(data: &[u8]) -> Result<TelemetryFrame, KsudpParseError> {
if data.len() < MIN_PACKET_SIZE {
return Err(KsudpParseError::TooShort {
actual: data.len(),
expected: MIN_PACKET_SIZE,
});
}
// Validate identifier
let identifier = data[0];
if identifier != b'a' {
return Err(KsudpParseError::InvalidIdentifier { got: identifier });
}
let reported_size = read_i32(data, 1) as usize;
if data.len() < reported_size && reported_size > 0 {
// Log warning but don't fail — the struct might have fields we don't read yet
eprintln!(
"[WARN] KSUDP: reported struct size {} but packet is {} bytes",
reported_size,
data.len()
);
}
let frame = TelemetryFrame {
source: FeedSource::Ksudp,
raw_size: data.len(),
// Speed
speed_kmh: read_f32(data, 5),
speed_mph: read_f32(data, 9),
speed_ms: read_f32(data, 13),
// Flags
is_abs_enabled: read_bool(data, 17),
is_abs_in_action: read_bool(data, 18),
is_tc_in_action: read_bool(data, 19),
is_tc_enabled: read_bool(data, 20),
is_in_pit: read_bool(data, 21),
is_engine_limiter_on: read_bool(data, 22),
// G-forces
acc_g_vertical: read_f32(data, 23),
acc_g_horizontal: read_f32(data, 27),
acc_g_frontal: read_f32(data, 31),
// Lap timing
lap_time_ms: read_i32(data, 35),
last_lap_ms: read_i32(data, 39),
best_lap_ms: read_i32(data, 43),
lap_count: read_i32(data, 47),
// Driver inputs
throttle: read_f32(data, 51),
brake: read_f32(data, 55),
clutch: read_f32(data, 59),
engine_rpm: read_f32(data, 63),
steer: read_f32(data, 67),
// Gear & chassis
gear: read_i32(data, 71),
cg_height: read_f32(data, 75),
// Per-wheel arrays
wheel_angular_speed: read_f32_4(data, 79),
slip_angle: read_f32_4(data, 95),
slip_angle_contact_patch: read_f32_4(data, 111),
slip_ratio: read_f32_4(data, 127),
tyre_slip: read_f32_4(data, 143),
nd_slip: read_f32_4(data, 159),
wheel_load: read_f32_4(data, 175),
wheel_dy: read_f32_4(data, 191),
wheel_mz: read_f32_4(data, 207),
tyre_dirty_level: read_f32_4(data, 223),
camber_rad: read_f32_4(data, 239),
tyre_radius: read_f32_4(data, 255),
tyre_loaded_radius: read_f32_4(data, 271),
suspension_height: read_f32_4(data, 287),
// Position
// NOTE: carPosition (offset 303) is the normalized spline position (0.0–1.0).
// Both car_position_normalized and spline_position map to the same AC field.
car_position_normalized: read_f32(data, 303),
car_slope: read_f32(data, 307),
world_x: read_f32(data, 311),
world_y: read_f32(data, 315),
world_z: read_f32(data, 319),
spline_position: read_f32(data, 303), // same as car_position_normalized (AC SDK: carPosition)
..Default::default()
};
// KSUDP-specific: mark Telemetry Tool fields as None (already default)
// Keep KSUDP-specific fields populated above.
Ok(frame)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_valid_packet() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'a'; // identifier
// size = MIN_PACKET_SIZE
packet[1..5].copy_from_slice(&(MIN_PACKET_SIZE as i32).to_le_bytes());
// speed_Kmh = 123.4
packet[5..9].copy_from_slice(&123.4f32.to_le_bytes());
// gear = 3 (meaning gear 2)
packet[71..75].copy_from_slice(&3i32.to_le_bytes());
// engineRPM = 7500.0
packet[63..67].copy_from_slice(&7500.0f32.to_le_bytes());
// lapTime = 78123 ms (1:18.123)
packet[35..39].copy_from_slice(&78123i32.to_le_bytes());
let frame = parse_rt_car_info(&packet).unwrap();
assert_eq!(frame.source, FeedSource::Ksudp);
assert!((frame.speed_kmh - 123.4).abs() < 0.01);
assert_eq!(frame.gear, 3);
assert!((frame.engine_rpm - 7500.0).abs() < 0.01);
assert_eq!(frame.lap_time_ms, 78123);
}
#[test]
fn test_too_short() {
let packet = vec![b'a', 0, 0, 0, 0];
let result = parse_rt_car_info(&packet);
assert!(result.is_err());
if let Err(KsudpParseError::TooShort { actual, .. }) = result {
assert_eq!(actual, 5);
} else {
panic!("Expected TooShort error");
}
}
#[test]
fn test_invalid_identifier() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'x';
let result = parse_rt_car_info(&packet);
assert!(result.is_err());
if let Err(KsudpParseError::InvalidIdentifier { got }) = result {
assert_eq!(got, b'x');
} else {
panic!("Expected InvalidIdentifier error");
}
}
#[test]
fn test_boolean_flags() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'a';
packet[17] = 1; // isAbsEnabled = true
packet[18] = 0; // isAbsInAction = false
packet[19] = 1; // isTcInAction = true
packet[20] = 1; // isTcEnabled = true
packet[21] = 0; // isInPit = false
packet[22] = 1; // isEngineLimiterOn = true
let frame = parse_rt_car_info(&packet).unwrap();
assert!(frame.is_abs_enabled);
assert!(!frame.is_abs_in_action);
assert!(frame.is_tc_in_action);
assert!(frame.is_tc_enabled);
assert!(!frame.is_in_pit);
assert!(frame.is_engine_limiter_on);
}
#[test]
fn test_per_wheel_arrays() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'a';
// slipRatio at offset 127: [0.1, 0.2, 0.3, 0.4]
for i in 0..4 {
let val = (i as f32 + 1.0) * 0.1;
packet[127 + i * 4..127 + i * 4 + 4].copy_from_slice(&val.to_le_bytes());
}
let frame = parse_rt_car_info(&packet).unwrap();
assert!((frame.slip_ratio[0] - 0.1).abs() < 0.001);
assert!((frame.slip_ratio[1] - 0.2).abs() < 0.001);
assert!((frame.slip_ratio[2] - 0.3).abs() < 0.001);
assert!((frame.slip_ratio[3] - 0.4).abs() < 0.001);
}
#[test]
fn test_world_coordinates() {
let mut packet = vec![0u8; MIN_PACKET_SIZE];
packet[0] = b'a';
packet[311..315].copy_from_slice(&100.5f32.to_le_bytes()); // x
packet[315..319].copy_from_slice(&2.3f32.to_le_bytes()); // y
packet[319..323].copy_from_slice(&(-50.7f32).to_le_bytes()); // z
let frame = parse_rt_car_info(&packet).unwrap();
assert!((frame.world_x - 100.5).abs() < 0.01);
assert!((frame.world_y - 2.3).abs() < 0.01);
assert!((frame.world_z - (-50.7)).abs() < 0.01);
}
}

View file

@ -0,0 +1,363 @@
//! Binary parser for Telemetry Tool plugin packets (port 10101).
//!
//! The Telemetry Tool is a third-party AC Python plugin by IkoRein v1.3.
//! It sends binary telemetry for all cars in the session.
//!
//! Reference: `<AC>/apps/python/Telemetry_Tool_plugin/Telemetry_Tool_plugin.py`
use crate::binary_utils::{
read_bool, read_f32, read_f32_2, read_f32_3, read_f32_4, read_f64, read_i16, read_i32_3,
read_string, read_u32,
};
use crate::telemetry_frame::{FeedSource, TelemetryFrame};
/// Packet type byte indicating telemetry data.
const PACKET_TYPE_TELEMETRY: u8 = 22;
/// Packet type byte indicating version info (sent every ~100 frames).
const PACKET_TYPE_VERSION: u8 = 23;
/// Size of a single per-driver telemetry structure.
const DRIVER_STRUCT_SIZE: usize = 357;
/// Validation constants embedded by the plugin.
const TEST_FLOAT: f32 = 666.666;
const TEST_INT: u32 = 666;
/// Parse error type for Telemetry Tool packets.
#[derive(Debug)]
pub enum TelemetryToolParseError {
/// Packet too short for the declared content.
TooShort { actual: usize, needed: usize },
/// Unknown packet type.
UnknownPacketType { got: u8 },
}
impl std::fmt::Display for TelemetryToolParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TelemetryToolParseError::TooShort { actual, needed } => {
write!(f, "packet too short: {} bytes (needed {})", actual, needed)
}
TelemetryToolParseError::UnknownPacketType { got } => {
write!(f, "unknown packet type: {} (expected 22 or 23)", got)
}
}
}
}
impl std::error::Error for TelemetryToolParseError {}
/// Parsed result of a Telemetry Tool packet.
///
/// Reserved for future multi-driver UI support.
#[allow(dead_code)]
pub enum TelemetryToolPacket {
/// Telemetry data for one or more drivers.
Telemetry(Vec<TelemetryFrame>),
/// Version info packet (typically ignored).
Version,
}
/// Parse a complete Telemetry Tool UDP packet.
///
/// Returns either a list of parsed driver frames or a version marker.
///
/// Reserved for future multi-driver UI support.
#[allow(dead_code)]
pub fn parse_packet(data: &[u8]) -> Result<TelemetryToolPacket, TelemetryToolParseError> {
if data.is_empty() {
return Err(TelemetryToolParseError::TooShort { actual: 0, needed: 1 });
}
let packet_type = data[0];
match packet_type {
PACKET_TYPE_TELEMETRY => {
let payload = &data[1..];
if payload.len() < DRIVER_STRUCT_SIZE {
return Err(TelemetryToolParseError::TooShort {
actual: data.len(),
needed: 1 + DRIVER_STRUCT_SIZE,
});
}
let num_drivers = payload.len() / DRIVER_STRUCT_SIZE;
let mut drivers = Vec::with_capacity(num_drivers);
for i in 0..num_drivers {
let start = i * DRIVER_STRUCT_SIZE;
let end = start + DRIVER_STRUCT_SIZE;
if end > payload.len() {
break;
}
let driver_data = &payload[start..end];
drivers.push(parse_driver(driver_data)?);
}
Ok(TelemetryToolPacket::Telemetry(drivers))
}
PACKET_TYPE_VERSION => Ok(TelemetryToolPacket::Version),
_ => Err(TelemetryToolParseError::UnknownPacketType { got: packet_type }),
}
}
/// Parse only the first driver from a Telemetry Tool UDP packet.
///
/// Avoids allocating a `Vec` for all drivers when only the local player's
/// telemetry is needed.
pub fn parse_first_driver(data: &[u8]) -> Result<TelemetryFrame, TelemetryToolParseError> {
if data.is_empty() {
return Err(TelemetryToolParseError::TooShort { actual: 0, needed: 1 });
}
let packet_type = data[0];
if packet_type != PACKET_TYPE_TELEMETRY {
return Err(TelemetryToolParseError::UnknownPacketType { got: packet_type });
}
let payload = &data[1..];
if payload.len() < DRIVER_STRUCT_SIZE {
return Err(TelemetryToolParseError::TooShort {
actual: data.len(),
needed: 1 + DRIVER_STRUCT_SIZE,
});
}
parse_driver(&payload[..DRIVER_STRUCT_SIZE])
}
/// Parse a single 357-byte driver structure.
fn parse_driver(data: &[u8]) -> Result<TelemetryFrame, TelemetryToolParseError> {
// Validation: check test_float at offset 93
let test_float_val = read_f32(data, 93);
if (test_float_val - TEST_FLOAT).abs() > 1.0 {
eprintln!(
"[WARN] Telemetry Tool: test_float validation: expected {}, got {}",
TEST_FLOAT, test_float_val
);
}
// Validation: check test_int at offset 177
let test_int_val = read_u32(data, 177);
if test_int_val != TEST_INT {
eprintln!(
"[WARN] Telemetry Tool: test_int validation: expected {}, got {}",
TEST_INT, test_int_val
);
}
let speed_kmh = read_f32(data, 63);
let frame = TelemetryFrame {
source: FeedSource::TelemetryTool,
raw_size: data.len(),
// Speed (convert km/h back)
speed_kmh,
speed_mph: speed_kmh * 0.621371,
speed_ms: speed_kmh / 3.6,
// Engine & drivetrain
engine_rpm: read_f32(data, 59),
gear: read_i16(data, 57) as i32,
// Driver inputs
steer: read_f32(data, 41),
throttle: read_f32(data, 45),
brake: read_f32(data, 49),
clutch: read_f32(data, 53),
// Lap timing
lap_time_ms: read_u32(data, 4) as i32,
last_lap_ms: read_u32(data, 8) as i32,
best_lap_ms: read_u32(data, 12) as i32,
lap_count: read_i16(data, 71) as i32,
// World position
world_x: read_f32(data, 73),
world_y: read_f32(data, 77),
world_z: read_f32(data, 81),
spline_position: read_f64(data, 85) as f32,
// G-forces
acc_g_vertical: read_f32(data, 185),
acc_g_horizontal: read_f32(data, 189),
acc_g_frontal: read_f32(data, 193),
// Boolean flags
is_in_pit: read_bool(data, 182),
is_engine_limiter_on: read_bool(data, 196),
// Per-wheel data
tyre_dirty_level: read_f32_4(data, 213),
slip_ratio: read_f32_4(data, 257),
slip_angle: read_f32_4(data, 273),
nd_slip: read_f32_4(data, 289),
tyre_slip: read_f32_4(data, 305),
// Telemetry Tool-specific fields
driver_id: Some(read_u32(data, 0)),
driver_name: Some(read_string(data, 97, 33)),
nationality: Some(read_string(data, 130, 4)),
car_name: Some(read_string(data, 134, 33)),
tyre_compound: Some(read_string(data, 167, 10)),
track_position: Some(read_i16(data, 67)),
leaderboard_position: Some(read_i16(data, 69)),
is_connected: Some(read_bool(data, 181)),
is_car_in_pitlane: Some(read_bool(data, 183)),
lap_invalidated: Some(read_bool(data, 184)),
is_drs_available: Some(read_bool(data, 197)),
is_drs_enabled: Some(read_bool(data, 198)),
is_race_finished: Some(read_bool(data, 199)),
turbo_boost: Some(read_f32(data, 229)),
ers_max_kj: Some(read_f32(data, 205)),
ers_current_kj: Some(read_f32(data, 209)),
ride_height_front_rear: Some(read_f32_2(data, 233)),
tyre_core_temp: Some(read_f32_4(data, 241)),
world_velocity: Some(read_f32_3(data, 321)),
local_velocity: Some(read_f32_3(data, 333)),
sector_times: Some(read_i32_3(data, 17)),
last_sector_times: Some(read_i32_3(data, 29)),
// Remaining KSUDP-only fields stay at default (0.0 / [0.0; 4])
..Default::default()
};
Ok(frame)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_driver_struct() -> Vec<u8> {
let mut data = vec![0u8; DRIVER_STRUCT_SIZE];
// driver_id = 42
data[0..4].copy_from_slice(&42u32.to_le_bytes());
// lap_time = 65000
data[4..8].copy_from_slice(&65000u32.to_le_bytes());
// test_float at offset 93
data[93..97].copy_from_slice(&TEST_FLOAT.to_le_bytes());
// driver_name at offset 97
let name = b"TestDriver";
data[97..97 + name.len()].copy_from_slice(name);
// test_int at offset 177
data[177..181].copy_from_slice(&TEST_INT.to_le_bytes());
// speed at offset 63 = 180.5
data[63..67].copy_from_slice(&180.5f32.to_le_bytes());
// engineRPM at offset 59 = 7200.0
data[59..63].copy_from_slice(&7200.0f32.to_le_bytes());
// gear at offset 57 = 5
data[57..59].copy_from_slice(&5i16.to_le_bytes());
// throttle at offset 45 = 0.85
data[45..49].copy_from_slice(&0.85f32.to_le_bytes());
// spline_position at offset 85 = 0.5
data[85..93].copy_from_slice(&0.5f64.to_le_bytes());
// x at offset 73
data[73..77].copy_from_slice(&100.0f32.to_le_bytes());
// is_in_pit at offset 182 = false
data[182] = 0;
data
}
#[test]
fn test_parse_telemetry_packet_single_driver() {
let driver = make_driver_struct();
let mut packet = vec![PACKET_TYPE_TELEMETRY];
packet.extend_from_slice(&driver);
let result = parse_packet(&packet).unwrap();
match result {
TelemetryToolPacket::Telemetry(drivers) => {
assert_eq!(drivers.len(), 1);
let f = &drivers[0];
assert_eq!(f.source, FeedSource::TelemetryTool);
assert_eq!(f.driver_id, Some(42));
assert_eq!(f.lap_time_ms, 65000);
assert!((f.speed_kmh - 180.5).abs() < 0.01);
assert!((f.engine_rpm - 7200.0).abs() < 0.01);
assert_eq!(f.gear, 5);
assert!((f.throttle - 0.85).abs() < 0.01);
assert!((f.world_x - 100.0).abs() < 0.01);
assert_eq!(f.driver_name.as_deref(), Some("TestDriver"));
}
TelemetryToolPacket::Version => panic!("Expected Telemetry, got Version"),
}
}
#[test]
fn test_parse_version_packet() {
let packet = vec![PACKET_TYPE_VERSION, 1, 2, 3];
let result = parse_packet(&packet).unwrap();
match result {
TelemetryToolPacket::Version => {}
_ => panic!("Expected Version"),
}
}
#[test]
fn test_unknown_packet_type() {
let packet = vec![99u8];
let result = parse_packet(&packet);
assert!(result.is_err());
if let Err(TelemetryToolParseError::UnknownPacketType { got }) = result {
assert_eq!(got, 99);
} else {
panic!("Expected UnknownPacketType");
}
}
#[test]
fn test_empty_packet() {
let result = parse_packet(&[]);
assert!(result.is_err());
}
#[test]
fn test_multiple_drivers() {
let driver1 = make_driver_struct();
let mut driver2 = make_driver_struct();
// Change driver_id for second driver
driver2[0..4].copy_from_slice(&99u32.to_le_bytes());
let mut packet = vec![PACKET_TYPE_TELEMETRY];
packet.extend_from_slice(&driver1);
packet.extend_from_slice(&driver2);
let result = parse_packet(&packet).unwrap();
match result {
TelemetryToolPacket::Telemetry(drivers) => {
assert_eq!(drivers.len(), 2);
assert_eq!(drivers[0].driver_id, Some(42));
assert_eq!(drivers[1].driver_id, Some(99));
}
TelemetryToolPacket::Version => panic!("Expected Telemetry"),
}
}
#[test]
fn test_per_wheel_slip_ratio() {
let mut driver = make_driver_struct();
// slip_ratio at offset 257: [0.1, 0.2, 0.3, 0.4]
for i in 0..4 {
let val = (i as f32 + 1.0) * 0.1;
driver[257 + i * 4..257 + i * 4 + 4].copy_from_slice(&val.to_le_bytes());
}
let mut packet = vec![PACKET_TYPE_TELEMETRY];
packet.extend_from_slice(&driver);
let result = parse_packet(&packet).unwrap();
match result {
TelemetryToolPacket::Telemetry(drivers) => {
let f = &drivers[0];
assert!((f.slip_ratio[0] - 0.1).abs() < 0.001);
assert!((f.slip_ratio[1] - 0.2).abs() < 0.001);
assert!((f.slip_ratio[2] - 0.3).abs() < 0.001);
assert!((f.slip_ratio[3] - 0.4).abs() < 0.001);
}
TelemetryToolPacket::Version => panic!("Expected Telemetry"),
}
}
}

View file

@ -1,70 +0,0 @@
use std::fmt;
use std::time::Instant;
pub struct ReceiveStats {
pub total_packets: u64,
pub total_bytes: u64,
pub start_time: Option<Instant>,
pub last_packet_time: Option<Instant>,
}
impl ReceiveStats {
pub fn new() -> Self {
Self {
total_packets: 0,
total_bytes: 0,
start_time: None,
last_packet_time: None,
}
}
pub fn record(&mut self, bytes_received: usize) {
let now = Instant::now();
if self.start_time.is_none() {
self.start_time = Some(now);
}
self.last_packet_time = Some(now);
self.total_packets += 1;
self.total_bytes += bytes_received as u64;
}
pub fn packets_per_second(&self) -> f64 {
match (self.start_time, self.last_packet_time) {
(Some(start), Some(last)) => {
let elapsed = last.duration_since(start).as_secs_f64();
if elapsed > 0.0 {
self.total_packets as f64 / elapsed
} else {
0.0
}
}
_ => 0.0,
}
}
pub fn data_rate_kbps(&self) -> f64 {
match self.start_time {
Some(start) => {
let elapsed = start.elapsed().as_secs_f64();
if elapsed > 0.0 {
(self.total_bytes as f64 / 1024.0) / elapsed
} else {
0.0
}
}
_ => 0.0,
}
}
}
impl fmt::Display for ReceiveStats {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Packets: {} | Rate: {:.1} pkt/s | Data: {:.1} KB/s",
self.total_packets,
self.packets_per_second(),
self.data_rate_kbps(),
)
}
}

View file

@ -1,56 +0,0 @@
use serde::Deserialize;
use std::fmt;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TelemetryFrame {
pub timestamp: f64,
pub speed_kmh: f64,
pub rpm: f64,
pub gear: i32,
pub throttle: f64,
pub brake: f64,
pub steering: f64,
pub g_forces: GForces,
pub wheel_slip: [f64; 4],
pub suspension_travel: [f64; 4],
pub lap_time: f64,
pub lap_count: i32,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GForces {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl fmt::Display for TelemetryFrame {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[Frame @ {:.3}]\n Speed: {:.1} km/h | RPM: {:.0} | Gear: {}\n Throttle: {:.1}% | Brake: {:.1}% | Steering: {:.1}%\n G-Forces: X={:.2} Y={:.2} Z={:.2}\n Wheel Slip: [{:.2}, {:.2}, {:.2}, {:.2}]\n Suspension: [{:.2}, {:.2}, {:.2}, {:.2}]\n Lap: #{} | Lap Time: {:.3}s",
self.timestamp,
self.speed_kmh,
self.rpm,
self.gear,
self.throttle * 100.0,
self.brake * 100.0,
self.steering * 100.0,
self.g_forces.x,
self.g_forces.y,
self.g_forces.z,
self.wheel_slip[0],
self.wheel_slip[1],
self.wheel_slip[2],
self.wheel_slip[3],
self.suspension_travel[0],
self.suspension_travel[1],
self.suspension_travel[2],
self.suspension_travel[3],
self.lap_count,
self.lap_time,
)
}
}

218
src/telemetry_frame.rs Normal file
View file

@ -0,0 +1,218 @@
//! Generic telemetry frame model — unified representation of parsed sim racing data.
//!
//! Both the KSUDP (port 9996) and Telemetry Tool (port 10101) parsers produce
//! `TelemetryFrame` instances from their raw binary data.
use std::fmt;
/// Identifies which feed source produced this frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeedSource {
Ksudp,
TelemetryTool,
}
/// Per-wheel index convention: FL=0, FR=1, RL=2, RR=3.
pub const NUM_WHEELS: usize = 4;
/// A single parsed telemetry snapshot from any supported feed.
///
/// Fields present in both feeds are stored directly; fields unique to one feed
/// are wrapped in `Option`.
#[derive(Debug, Clone)]
pub struct TelemetryFrame {
// ── Speed ──────────────────────────────────────────────────────
pub speed_kmh: f32,
pub speed_mph: f32,
pub speed_ms: f32,
// ── Engine & Drivetrain ────────────────────────────────────────
pub engine_rpm: f32,
pub gear: i32,
// ── Driver Inputs ──────────────────────────────────────────────
pub throttle: f32,
pub brake: f32,
pub clutch: f32,
pub steer: f32,
// ── Lap Timing ─────────────────────────────────────────────────
pub lap_time_ms: i32,
pub last_lap_ms: i32,
pub best_lap_ms: i32,
pub lap_count: i32,
// ── World Position ─────────────────────────────────────────────
pub world_x: f32,
pub world_y: f32,
pub world_z: f32,
pub spline_position: f32,
// ── Acceleration (G-forces) ────────────────────────────────────
pub acc_g_vertical: f32,
pub acc_g_horizontal: f32,
pub acc_g_frontal: f32,
// ── Boolean Flags ──────────────────────────────────────────────
pub is_abs_enabled: bool,
pub is_abs_in_action: bool,
pub is_tc_enabled: bool,
pub is_tc_in_action: bool,
pub is_in_pit: bool,
pub is_engine_limiter_on: bool,
// ── Per-Wheel Data (FL, FR, RL, RR) ────────────────────────────
pub wheel_angular_speed: [f32; NUM_WHEELS],
pub slip_angle: [f32; NUM_WHEELS],
pub slip_ratio: [f32; NUM_WHEELS],
pub tyre_slip: [f32; NUM_WHEELS],
pub nd_slip: [f32; NUM_WHEELS],
pub suspension_height: [f32; NUM_WHEELS],
pub tyre_dirty_level: [f32; NUM_WHEELS],
// ── KSUDP-specific fields ──────────────────────────────────────
pub slip_angle_contact_patch: [f32; NUM_WHEELS],
pub wheel_load: [f32; NUM_WHEELS],
pub wheel_dy: [f32; NUM_WHEELS],
pub wheel_mz: [f32; NUM_WHEELS],
pub camber_rad: [f32; NUM_WHEELS],
pub tyre_radius: [f32; NUM_WHEELS],
pub tyre_loaded_radius: [f32; NUM_WHEELS],
pub cg_height: f32,
pub car_slope: f32,
pub car_position_normalized: f32,
// ── Telemetry Tool-specific fields ─────────────────────────────
pub driver_name: Option<String>,
pub car_name: Option<String>,
pub nationality: Option<String>,
pub tyre_compound: Option<String>,
pub track_position: Option<i16>,
pub leaderboard_position: Option<i16>,
pub is_connected: Option<bool>,
pub is_car_in_pitlane: Option<bool>,
pub lap_invalidated: Option<bool>,
pub is_drs_available: Option<bool>,
pub is_drs_enabled: Option<bool>,
pub is_race_finished: Option<bool>,
pub turbo_boost: Option<f32>,
pub ers_max_kj: Option<f32>,
pub ers_current_kj: Option<f32>,
pub ride_height_front_rear: Option<[f32; 2]>,
pub tyre_core_temp: Option<[f32; NUM_WHEELS]>,
pub world_velocity: Option<[f32; 3]>,
pub local_velocity: Option<[f32; 3]>,
pub sector_times: Option<[i32; 3]>,
pub last_sector_times: Option<[i32; 3]>,
pub driver_id: Option<u32>,
// ── Meta ───────────────────────────────────────────────────────
pub source: FeedSource,
pub raw_size: usize,
}
impl Default for TelemetryFrame {
fn default() -> Self {
Self {
speed_kmh: 0.0,
speed_mph: 0.0,
speed_ms: 0.0,
engine_rpm: 0.0,
gear: 0,
throttle: 0.0,
brake: 0.0,
clutch: 0.0,
steer: 0.0,
lap_time_ms: 0,
last_lap_ms: 0,
best_lap_ms: 0,
lap_count: 0,
world_x: 0.0,
world_y: 0.0,
world_z: 0.0,
spline_position: 0.0,
acc_g_vertical: 0.0,
acc_g_horizontal: 0.0,
acc_g_frontal: 0.0,
is_abs_enabled: false,
is_abs_in_action: false,
is_tc_enabled: false,
is_tc_in_action: false,
is_in_pit: false,
is_engine_limiter_on: false,
wheel_angular_speed: [0.0; NUM_WHEELS],
slip_angle: [0.0; NUM_WHEELS],
slip_ratio: [0.0; NUM_WHEELS],
tyre_slip: [0.0; NUM_WHEELS],
nd_slip: [0.0; NUM_WHEELS],
suspension_height: [0.0; NUM_WHEELS],
tyre_dirty_level: [0.0; NUM_WHEELS],
slip_angle_contact_patch: [0.0; NUM_WHEELS],
wheel_load: [0.0; NUM_WHEELS],
wheel_dy: [0.0; NUM_WHEELS],
wheel_mz: [0.0; NUM_WHEELS],
camber_rad: [0.0; NUM_WHEELS],
tyre_radius: [0.0; NUM_WHEELS],
tyre_loaded_radius: [0.0; NUM_WHEELS],
cg_height: 0.0,
car_slope: 0.0,
car_position_normalized: 0.0,
driver_name: None,
car_name: None,
nationality: None,
tyre_compound: None,
track_position: None,
leaderboard_position: None,
is_connected: None,
is_car_in_pitlane: None,
lap_invalidated: None,
is_drs_available: None,
is_drs_enabled: None,
is_race_finished: None,
turbo_boost: None,
ers_max_kj: None,
ers_current_kj: None,
ride_height_front_rear: None,
tyre_core_temp: None,
world_velocity: None,
local_velocity: None,
sector_times: None,
last_sector_times: None,
driver_id: None,
source: FeedSource::Ksudp,
raw_size: 0,
}
}
}
impl TelemetryFrame {
/// Format lap time from milliseconds as M:SS.mmm
pub fn format_lap_time(ms: i32) -> String {
if ms <= 0 {
return "-:--.---".to_string();
}
let total_ms = ms as u32;
let minutes = total_ms / 60000;
let seconds = (total_ms % 60000) / 1000;
let millis = total_ms % 1000;
format!("{}:{:02}.{:03}", minutes, seconds, millis)
}
/// Gear display: 0=Reverse, 1=Neutral, 2+=Gear N-1
pub fn gear_display(&self) -> String {
match self.gear {
0 => "R".to_string(),
1 => "N".to_string(),
n => format!("{}", n - 1),
}
}
}
impl fmt::Display for FeedSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FeedSource::Ksudp => write!(f, "KSUDP"),
FeedSource::TelemetryTool => write!(f, "Telemetry Tool"),
}
}
}

222
src/ui.rs Normal file
View file

@ -0,0 +1,222 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
use crate::feed_state::{FeedHealth, FeedMode, FeedState, HandshakePhase};
use crate::telemetry_frame::TelemetryFrame;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct FeedDisplayInfo {
pub label: &'static str,
pub port: u16,
pub mode: FeedMode,
}
fn health_indicator(health: FeedHealth) -> Span<'static> {
match health {
FeedHealth::Alive => Span::styled(
"*".to_string(),
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
),
FeedHealth::Stale => Span::styled(
"~".to_string(),
Style::default().fg(Color::Yellow),
),
FeedHealth::Waiting => Span::styled(
".".to_string(),
Style::default().fg(Color::DarkGray),
),
FeedHealth::Error => Span::styled(
"!".to_string(),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
}
}
fn freq_style(health: FeedHealth) -> Style {
match health {
FeedHealth::Alive => Style::default().fg(Color::Green),
FeedHealth::Stale => Style::default().fg(Color::Yellow),
_ => Style::default().fg(Color::DarkGray),
}
}
fn format_ago(ago: Option<f64>) -> String {
match ago {
None => "never".to_string(),
Some(t) => format!("{:.2}s", t),
}
}
fn feed_block_title(info: &FeedDisplayInfo, state: &FeedState) -> String {
match info.mode {
FeedMode::Handshake {
target_port,
recv_port,
} => {
let phase_str = match state.handshake_phase {
HandshakePhase::NotStarted => "not started",
HandshakePhase::WaitingForResponse => "waiting",
HandshakePhase::Subscribing => "subscribing",
HandshakePhase::Subscribed => "subscribed",
};
format!(
" {} -> :{} recv :{} [{}] ",
info.label, target_port, recv_port, phase_str
)
}
FeedMode::BindListen => {
format!(" {} Port :{} ", info.label, info.port)
}
FeedMode::Disabled => {
format!(" {} Port :{} [optional, not available] ", info.label, info.port)
}
}
}
fn render_feed_panel(frame: &mut Frame, area: Rect, info: &FeedDisplayInfo, state: &FeedState) {
let title = feed_block_title(info, state);
let border_style = match state.health {
FeedHealth::Alive => Style::default().fg(Color::Green),
FeedHealth::Stale => Style::default().fg(Color::Yellow),
FeedHealth::Error => Style::default().fg(Color::Red),
FeedHealth::Waiting => {
if info.mode == FeedMode::Disabled {
Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM)
} else {
Style::default().fg(Color::DarkGray)
}
}
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(area);
frame.render_widget(block, area);
let indicator = health_indicator(state.health);
let status_text = Span::raw(format!(" {:<12}", state.status_text));
let line1 = Line::from(vec![indicator, status_text]);
let freq_span = Span::styled(
format!("{:>7.1} Hz", state.frequency_hz),
freq_style(state.health),
);
let packets_span = Span::raw(format!(" {:>8} packets", state.total_packets));
let rate_span = Span::raw(format!(" {:>7.1} KB/s", state.data_rate_kb_s));
let ago_span = Span::raw(format!(" last: {:>8}", format_ago(state.last_packet_ago_secs)));
let line2 = Line::from(vec![freq_span, packets_span, rate_span, ago_span]);
let mut lines = vec![line1, line2];
if let Some(ref frame) = state.last_frame {
let speed_span = Span::styled(
format!(" {:>6.0} km/h", frame.speed_kmh),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
);
let gear_span = Span::styled(
format!(" Gear:{}", frame.gear_display()),
Style::default().fg(Color::Yellow),
);
let rpm_span = Span::raw(format!(" {:.0} RPM", frame.engine_rpm));
let lap_span = Span::raw(format!(" Lap:{}", TelemetryFrame::format_lap_time(frame.lap_time_ms)));
let line3 = Line::from(vec![speed_span, gear_span, rpm_span, lap_span]);
lines.push(line3);
let throttle_pct = (frame.throttle.clamp(0.0, 1.0) * 100.0) as u8;
let brake_pct = (frame.brake.clamp(0.0, 1.0) * 100.0) as u8;
let inputs_span = Span::raw(format!(" THR:{:>3}% BRK:{:>3}%", throttle_pct, brake_pct));
let pos_span = Span::raw(format!(" Pos:{:.3}", frame.spline_position));
let line4 = Line::from(vec![inputs_span, pos_span]);
lines.push(line4);
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}
pub fn render(
frame: &mut Frame,
feeds: &[(FeedDisplayInfo, Arc<Mutex<FeedState>>)],
session_dir: &str,
version: &str,
) {
let total_feeds = feeds.len() as u16;
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(total_feeds * 4 + 2),
Constraint::Length(3),
])
.split(frame.area());
let title_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let title_inner = title_block.inner(outer[0]);
frame.render_widget(title_block, outer[0]);
let title_text = Line::from(Span::styled(
format!(
" rusty-telemetry v{} -- Telemetry Listener ",
version
),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
let title_para = Paragraph::new(title_text);
frame.render_widget(title_para, title_inner);
let feed_constraints: Vec<Constraint> = feeds
.iter()
.map(|_| Constraint::Min(6))
.collect();
let feed_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(feed_constraints)
.split(outer[1]);
for (i, (info, state)) in feeds.iter().enumerate() {
// Clone inside the lock, then drop the guard before rendering
// to avoid blocking the UDP receive task during the entire draw.
let snapshot = state.lock().ok().map(|mut s| {
s.tick();
s.clone()
});
if let Some(snap) = snapshot {
render_feed_panel(frame, feed_layout[i], info, &snap);
}
}
let footer_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let footer_inner = footer_block.inner(outer[2]);
frame.render_widget(footer_block, outer[2]);
let footer_text = Line::from(vec![
Span::styled(
format!(" Session: {}/ ", session_dir),
Style::default().fg(Color::White),
),
Span::raw(" "),
Span::styled(
"Ctrl+C Quit".to_string(),
Style::default().fg(Color::DarkGray),
),
]);
let footer_para = Paragraph::new(footer_text).wrap(Wrap { trim: true });
frame.render_widget(footer_para, footer_inner);
}