feat: add use-case session management, analysis algorithms, and REST API (v0.5.0)

- Add session layer with UseCaseSession, SessionRun, SessionManager
  for use-case driven telemetry analysis (shift points, track mapping)
- Add analysis module with shift point detection (gear RPM ranges,
  upshift events, recommended shift RPM) and track map reconstruction
  (spline-aligned interpolation for center line from boundary runs)
- Add 9 REST API endpoints for session CRUD, run management, and
  analysis triggers under /api/sessions/*
- Add RecordingManager with always-on ring buffers (5 min, 18k frames)
  replacing raw binary file dumps
- Wire SessionManager in main.rs with Arc<Mutex> shared state
- Add TelemetryFrame Serialize derives for JSON API output
- Fix stop_recording race: hold active lock across flush I/O
- Fix flush: separate ring buffer lock from JSON serialization
- Fix flush failure propagation: errors prevent Stopped status
- Add data_truncated flag on SessionRun for ring buffer wrap detection
- Deduplicate gear_display into shared free function
- Deduplicate session_display into closure
- Mutex poisoning recovery on all session API handlers
This commit is contained in:
Jan-Peter von Hunnius 2026-06-05 10:40:17 +02:00
parent 56d3169033
commit 47a0896134
10 changed files with 2333 additions and 91 deletions

550
Cargo.lock generated
View file

@ -17,12 +17,76 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.11.1"
@ -81,6 +145,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link", "windows-link",
] ]
@ -198,6 +263,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@ -286,6 +360,19 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@ -297,12 +384,98 @@ dependencies = [
"foldhash", "foldhash",
] ]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.65" version = "0.1.65"
@ -327,12 +500,30 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "ident_case" name = "ident_case"
version = "1.0.1" version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "indoc" name = "indoc"
version = "2.0.7" version = "2.0.7"
@ -382,6 +573,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@ -415,15 +612,27 @@ version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [ dependencies = [
"hashbrown", "hashbrown 0.15.5",
] ]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.1" version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.1" version = "1.2.1"
@ -480,12 +689,28 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@ -504,6 +729,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "ratatui" name = "ratatui"
version = "0.29.0" version = "0.29.0"
@ -555,13 +786,18 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]] [[package]]
name = "rusty-telemetry" name = "rusty-telemetry"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"axum",
"chrono", "chrono",
"crossterm", "crossterm",
"futures", "futures",
"ratatui", "ratatui",
"serde",
"serde_json",
"tokio", "tokio",
"tower-http",
"uuid",
] ]
[[package]] [[package]]
@ -576,6 +812,78 @@ 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 = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[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]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@ -680,6 +988,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.52.3" version = "1.52.3"
@ -708,6 +1022,68 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"http",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@ -743,12 +1119,48 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "uuid"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [
"getrandom",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.122" version = "0.2.122"
@ -794,6 +1206,40 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@ -956,3 +1402,103 @@ name = "windows_x86_64_msvc"
version = "0.52.6" 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 = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View file

@ -1,12 +1,17 @@
[package] [package]
name = "rusty-telemetry" name = "rusty-telemetry"
version = "0.4.0" version = "0.5.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"] }
chrono = "0.4" chrono = { version = "0.4", features = ["serde"] }
futures = "0.3" futures = "0.3"
ratatui = "0.29" ratatui = "0.29"
crossterm = "0.28" crossterm = "0.28"
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
tower-http = { version = "0.6", features = ["cors"] }

View file

@ -32,6 +32,7 @@ Press **q** or **Ctrl+C** to quit.
| `RUSTY_TELEMETRY_GAME_HOST` | IP of the machine running the sim game (used for KSUDP handshake target) | `127.0.0.1` | | `RUSTY_TELEMETRY_GAME_HOST` | IP of the machine running the sim game (used for KSUDP handshake target) | `127.0.0.1` |
| `RUSTY_TELEMETRY_LISTEN` | Local address to bind for incoming UDP feeds (Telemetry Tool, PCARS) | `0.0.0.0` | | `RUSTY_TELEMETRY_LISTEN` | Local address to bind for incoming UDP feeds (Telemetry Tool, PCARS) | `0.0.0.0` |
| `RUSTY_TELEMETRY_BIND` | Fallback for both of the above (backward compatibility) | — | | `RUSTY_TELEMETRY_BIND` | Fallback for both of the above (backward compatibility) | — |
| `RUSTY_TELEMETRY_API_PORT` | REST API server port | `8080` |
### Same Machine ### Same Machine
@ -98,7 +99,7 @@ The terminal UI shows one panel per active feed:
``` ```
┌──────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────┐
│ rusty-telemetry v0.4.0 -- Telemetry Listener │ │ rusty-telemetry v0.5.0 -- Telemetry Listener │
├──────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────┤
│ ╔ * Receiving ╗ │ │ ╔ * Receiving ╗ │
│ ║ 20.0 Hz 1200 packets 15.2 KB/s last: 0.05s ║ │ ║ 20.0 Hz 1200 packets 15.2 KB/s last: 0.05s ║
@ -116,7 +117,7 @@ The terminal UI shows one panel per active feed:
│ ║ 0.0 Hz 0 packets 0.0 KB/s last: never ║ │ ║ 0.0 Hz 0 packets 0.0 KB/s last: never ║
│ ╚ PCARS Port :5606 ╝ │ │ ╚ PCARS Port :5606 ╝ │
├──────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────┤
│ Session: ./data/20260604_103000/ Ctrl+C Quit │ │ Recording: 550e8400-… Ctrl+C Quit │
└──────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────┘
``` ```
@ -128,16 +129,76 @@ Each panel shows:
## Data Capture ## Data Capture
All raw UDP packets are written to per-feed binary dump files: A recording is auto-started on launch. All raw UDP packets are written to per-feed JSONL files under a UUID-based directory:
``` ```
./data/<YYYYMMDD_HHMMSS>/ ./data/<uuid>/
├── feed_9996_ksudp.bin ├── manifest.json
├── feed_10101_telemetry_tool.bin ├── feed_9996_ksudp.jsonl
└── feed_5606_pcars.bin ├── feed_10101_telemetry_tool.jsonl
└── feed_5606_pcars.jsonl
``` ```
Each packet is prefixed with a 4-byte big-endian length header, allowing sequential replay of captured sessions. Each recording contains a `manifest.json` with metadata (game, track, car, use case, status, timestamps) and JSONL files for each feed. Each line in a JSONL file is a serialized `TelemetryFrame` object, enabling sequential replay and analysis of captured sessions.
## REST API
A built-in HTTP API server runs on port 8080 for recording management and live telemetry access.
### Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/live` | Live telemetry snapshot (all feeds) |
| GET | `/api/recordings` | List all recordings |
| POST | `/api/recordings` | Start new recording |
| GET | `/api/recordings/{id}` | Get recording details |
| POST | `/api/recordings/stop` | Stop active recording |
| POST | `/api/recordings/{id}/stop` | Stop specific recording |
| DELETE | `/api/recordings/{id}` | Delete recording |
| GET | `/api/recordings/{id}/data/{feed_name}` | Download raw feed JSONL |
| GET | `/api/use-cases` | List valid use cases |
| GET | `/api/games` | List supported games |
### Start Recording Body
```json
{
"name": "My Session",
"game": "assetto_corsa",
"track": "nurburgring",
"car": "ferrari_488",
"use_case": "general",
"notes": "Testing session"
}
```
All fields are optional.
### Live Telemetry Response
```json
{
"feeds": [
{
"label": "KSUDP",
"port": 9996,
"health": "alive",
"frequency_hz": 20.0,
"total_packets": 1200,
"data_rate_kb_s": 15.2,
"speed_kmh": 245.0,
"gear": "5",
"engine_rpm": 8750.0,
"throttle": 0.85,
"brake": 0.0,
"lap_time": "1:23.456",
"spline_position": 0.453
}
],
"active_recording": "550e8400-e29b-41d4-a716-446655440000"
}
```
## Testing ## Testing
@ -156,7 +217,9 @@ cargo test
``` ```
src/ src/
├── main.rs Multi-port UDP listener, TUI event loop, KSUDP handshake ├── main.rs Multi-port UDP listener, TUI event loop, KSUDP handshake
├── api.rs REST API server (axum) for recording management and live telemetry
├── binary_utils.rs Shared safe binary read helpers (bounds-checked, zero fallback) ├── binary_utils.rs Shared safe binary read helpers (bounds-checked, zero fallback)
├── recording.rs Recording session manager (manifest, CRUD, auto-start)
├── telemetry_frame.rs Unified telemetry data model (TelemetryFrame) ├── telemetry_frame.rs Unified telemetry data model (TelemetryFrame)
├── feed_state.rs Per-feed health/frequency state machine with ring buffer ├── feed_state.rs Per-feed health/frequency state machine with ring buffer
├── parser_ksudp.rs AC KSUDP binary parser (RTCarInfo, 6 tests) ├── parser_ksudp.rs AC KSUDP binary parser (RTCarInfo, 6 tests)

290
src/analysis.rs Normal file
View file

@ -0,0 +1,290 @@
//! Telemetry analysis algorithms for sim racing data.
//!
//! Provides shift point analysis, track map reconstruction, and a dispatch
//! function that routes a [`UseCase`] to the appropriate analysis pipeline.
use serde::Serialize;
use std::collections::HashMap;
use crate::recording::UseCase;
use crate::telemetry_frame::{gear_display, TelemetryFrame};
// ---------------------------------------------------------------------------
// Shift Point Analysis
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
pub struct GearRpmRange {
pub gear: i32,
pub gear_display: String,
pub min_rpm: f32,
pub max_rpm: f32,
pub avg_rpm: f32,
pub sample_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShiftEvent {
pub from_gear: i32,
pub to_gear: i32,
pub rpm_before_shift: f32,
pub rpm_after_shift: f32,
pub rpm_drop: f32,
pub speed_kmh: f32,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShiftPointAnalysis {
pub gear_ranges: Vec<GearRpmRange>,
pub shift_events: Vec<ShiftEvent>,
pub recommended_shift_rpm: Option<f32>,
pub total_frames_analyzed: usize,
}
pub fn analyze_shift_points(frames: &[TelemetryFrame]) -> ShiftPointAnalysis {
let valid: Vec<&TelemetryFrame> = frames
.iter()
.filter(|f| f.gear >= 2 && f.engine_rpm > 0.0)
.collect();
let mut buckets: HashMap<i32, Vec<f32>> = HashMap::new();
for f in &valid {
buckets.entry(f.gear).or_default().push(f.engine_rpm);
}
let mut gear_ranges: Vec<GearRpmRange> = buckets
.into_iter()
.map(|(gear, rpms)| {
let min_rpm = rpms.iter().cloned().fold(f32::INFINITY, f32::min);
let max_rpm = rpms.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let avg_rpm = rpms.iter().sum::<f32>() / rpms.len() as f32;
GearRpmRange {
gear,
gear_display: gear_display(gear),
min_rpm,
max_rpm,
avg_rpm,
sample_count: rpms.len(),
}
})
.collect();
gear_ranges.sort_by_key(|g| g.gear);
let valid_indices: Vec<usize> = frames
.iter()
.enumerate()
.filter(|(_, f)| f.gear >= 2 && f.engine_rpm > 0.0)
.map(|(i, _)| i)
.collect();
let mut shift_events: Vec<ShiftEvent> = Vec::new();
for window in valid_indices.windows(2) {
let prev = &frames[window[0]];
let curr = &frames[window[1]];
if prev.gear + 1 == curr.gear {
shift_events.push(ShiftEvent {
from_gear: prev.gear,
to_gear: curr.gear,
rpm_before_shift: prev.engine_rpm,
rpm_after_shift: curr.engine_rpm,
rpm_drop: prev.engine_rpm - curr.engine_rpm,
speed_kmh: prev.speed_kmh,
});
}
}
let recommended_shift_rpm = {
let upshift_rpms: Vec<f32> = shift_events
.iter()
.filter(|e| e.from_gear >= 2 && e.to_gear >= 2)
.map(|e| e.rpm_before_shift)
.collect();
if upshift_rpms.is_empty() {
None
} else {
Some(upshift_rpms.iter().sum::<f32>() / upshift_rpms.len() as f32)
}
};
ShiftPointAnalysis {
gear_ranges,
shift_events,
recommended_shift_rpm,
total_frames_analyzed: valid.len(),
}
}
// ---------------------------------------------------------------------------
// Track Map Analysis
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
pub struct TrackPoint {
pub x: f32,
pub y: f32,
pub z: f32,
pub spline: f32,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrackBoundary {
pub label: String,
pub point_count: usize,
pub points: Vec<TrackPoint>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrackMapAnalysis {
pub boundaries: Vec<TrackBoundary>,
pub center_line: Option<TrackBoundary>,
pub total_points: usize,
}
pub fn analyze_track_map(
runs: &[Vec<TelemetryFrame>],
labels: &[String],
) -> TrackMapAnalysis {
let mut boundaries: Vec<TrackBoundary> = Vec::new();
for (run, label) in runs.iter().zip(labels.iter()) {
let mut points: Vec<TrackPoint> = run
.iter()
.filter(|f| f.spline_position != 0.0)
.map(|f| TrackPoint {
x: f.world_x,
y: f.world_y,
z: f.world_z,
spline: f.spline_position,
})
.collect();
points.sort_by(|a, b| a.spline.partial_cmp(&b.spline).unwrap_or(std::cmp::Ordering::Equal));
let point_count = points.len();
boundaries.push(TrackBoundary {
label: label.clone(),
point_count,
points,
});
}
let center_line = compute_center_line(&boundaries);
let total_points: usize = boundaries.iter().map(|b| b.point_count).sum::<usize>()
+ center_line.as_ref().map_or(0, |c| c.point_count);
TrackMapAnalysis {
boundaries,
center_line,
total_points,
}
}
fn compute_center_line(boundaries: &[TrackBoundary]) -> Option<TrackBoundary> {
if boundaries.len() != 2 {
return None;
}
let left_idx = boundaries
.iter()
.position(|b| b.label == "left")?;
let right_idx = boundaries
.iter()
.position(|b| b.label == "right")?;
if left_idx == right_idx {
return None;
}
let left = &boundaries[left_idx];
let right = &boundaries[right_idx];
if left.points.is_empty() || right.points.is_empty() {
return None;
}
let num_samples = left.point_count.max(right.point_count).min(2000);
let center_points: Vec<TrackPoint> = (0..num_samples)
.filter_map(|i| {
let spline = i as f32 / (num_samples - 1).max(1) as f32;
let lp = interpolate_boundary(left, spline)?;
let rp = interpolate_boundary(right, spline)?;
Some(TrackPoint {
x: (lp.x + rp.x) / 2.0,
y: (lp.y + rp.y) / 2.0,
z: (lp.z + rp.z) / 2.0,
spline,
})
})
.collect();
if center_points.is_empty() {
return None;
}
Some(TrackBoundary {
label: "center_line".to_owned(),
point_count: center_points.len(),
points: center_points,
})
}
/// Linearly interpolate a TrackPoint at the given spline position from a boundary.
fn interpolate_boundary(boundary: &TrackBoundary, target_spline: f32) -> Option<TrackPoint> {
let points = &boundary.points;
if points.is_empty() {
return None;
}
if target_spline <= points.first()?.spline {
return Some(points.first()?.clone());
}
if target_spline >= points.last()?.spline {
return Some(points.last()?.clone());
}
let idx = points.partition_point(|p| p.spline < target_spline);
if idx == 0 || idx >= points.len() {
return points.first().cloned();
}
let a = &points[idx - 1];
let b = &points[idx];
let t = if (b.spline - a.spline).abs() < f32::EPSILON {
0.0
} else {
(target_spline - a.spline) / (b.spline - a.spline)
};
Some(TrackPoint {
x: a.x + t * (b.x - a.x),
y: a.y + t * (b.y - a.y),
z: a.z + t * (b.z - a.z),
spline: target_spline,
})
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub fn analyze(
use_case: &UseCase,
frames_by_run: &[Vec<TelemetryFrame>],
run_labels: &[String],
) -> serde_json::Value {
match use_case {
UseCase::ShiftPoints => {
if let Some(first_run) = frames_by_run.first() {
serde_json::to_value(analyze_shift_points(first_run))
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}))
} else {
serde_json::json!({"error": "no frames provided"})
}
}
UseCase::TrackMap => serde_json::to_value(analyze_track_map(frames_by_run, run_labels))
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"})),
_ => serde_json::json!({
"status": "not_implemented",
"use_case": format!("{:?}", use_case),
}),
}
}

526
src/api.rs Normal file
View file

@ -0,0 +1,526 @@
//! REST API server for recording management and live telemetry access.
//!
//! Runs as a parallel tokio task alongside the UDP listeners.
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex, MutexGuard};
use tower_http::cors::CorsLayer;
use crate::analysis;
use crate::feed_state::FeedState;
use crate::recording::{Game, RecordingManager, RecordingStatus, StartRecordingRequest, UseCase};
use crate::session::SessionManager;
use crate::ui::FeedDisplayInfo;
pub struct AppState {
pub recording_manager: Arc<RecordingManager>,
pub feed_displays: Vec<(FeedDisplayInfo, Arc<Mutex<FeedState>>)>,
pub session_manager: Arc<std::sync::Mutex<SessionManager>>,
}
#[derive(Serialize)]
struct ApiError {
error: String,
}
fn ok_json<T: Serialize>(data: T) -> Response {
(StatusCode::OK, Json(data)).into_response()
}
fn err(status: StatusCode, msg: &str) -> Response {
(
status,
Json(ApiError {
error: msg.to_string(),
}),
)
.into_response()
}
fn lock_sessions(state: &Arc<AppState>) -> MutexGuard<'_, SessionManager> {
state.session_manager.lock().unwrap_or_else(|e| {
eprintln!("[WARN] SessionManager mutex poisoned, recovering: {}", e);
e.into_inner()
})
}
#[derive(Serialize)]
struct LiveFeedSnapshot {
label: String,
port: u16,
health: String,
frequency_hz: f64,
total_packets: u64,
data_rate_kb_s: f64,
speed_kmh: Option<f32>,
gear: Option<String>,
engine_rpm: Option<f32>,
throttle: Option<f32>,
brake: Option<f32>,
lap_time: Option<String>,
spline_position: Option<f32>,
}
#[derive(Serialize)]
struct LiveTelemetryResponse {
feeds: Vec<LiveFeedSnapshot>,
active_recording: Option<String>,
}
#[derive(Deserialize)]
struct CreateSessionRequest {
use_case: UseCase,
label: Option<String>,
}
#[derive(Deserialize)]
struct StartRunRequest {
feed_name: String,
label: Option<String>,
}
async fn create_session(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateSessionRequest>,
) -> Response {
let mut mgr = lock_sessions(&state);
match mgr.create_session(req.use_case, req.label) {
Ok(session) => (StatusCode::CREATED, Json(session)).into_response(),
Err(msg) => err(StatusCode::BAD_REQUEST, &msg),
}
}
async fn list_sessions(State(state): State<Arc<AppState>>) -> Response {
let mut mgr = lock_sessions(&state);
ok_json(mgr.list_sessions())
}
async fn get_session(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let mgr = lock_sessions(&state);
match mgr.get_session(&id) {
Some(session) => ok_json(session),
None => err(StatusCode::NOT_FOUND, "Session not found"),
}
}
async fn delete_session(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let mut mgr = lock_sessions(&state);
match mgr.delete_session(&id) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(msg) => err(StatusCode::NOT_FOUND, &msg),
}
}
async fn start_run(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<StartRunRequest>,
) -> Response {
let mut mgr = lock_sessions(&state);
match mgr.start_run(&id, &req.feed_name, req.label) {
Ok(run) => (StatusCode::CREATED, Json(run)).into_response(),
Err(msg) => err(StatusCode::BAD_REQUEST, &msg),
}
}
async fn stop_run(
State(state): State<Arc<AppState>>,
Path((id, run_id)): Path<(String, String)>,
) -> Response {
let mut mgr = lock_sessions(&state);
match mgr.stop_run(&id, &run_id) {
Ok(run) => ok_json(run),
Err(msg) => err(StatusCode::BAD_REQUEST, &msg),
}
}
async fn complete_session(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let (use_case, frames_by_run, run_labels) = {
let mut mgr = lock_sessions(&state);
match mgr.complete_session(&id) {
Ok(_session) => {}
Err(msg) => return err(StatusCode::BAD_REQUEST, &msg),
}
match mgr.collect_run_frames(&id) {
Some((uc, frames, labels)) => (uc, frames, labels),
None => return err(StatusCode::NOT_FOUND, "Session not found after completion"),
}
};
let result = analysis::analyze(&use_case, &frames_by_run, &run_labels);
{
let mut mgr = lock_sessions(&state);
let _ = mgr.set_analysis(&id, result);
}
let mgr = lock_sessions(&state);
match mgr.get_session(&id) {
Some(session) => ok_json(session),
None => err(StatusCode::INTERNAL_SERVER_ERROR, "Session lost after analysis"),
}
}
async fn get_analysis(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let mgr = lock_sessions(&state);
match mgr.get_session(&id) {
Some(session) => {
match session.analysis {
Some(ref analysis) => ok_json(analysis),
None => err(StatusCode::NOT_FOUND, "Analysis not available yet. Complete the session first."),
}
}
None => err(StatusCode::NOT_FOUND, "Session not found"),
}
}
async fn run_analysis(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let (use_case, frames_by_run, run_labels) = {
let mgr = lock_sessions(&state);
match mgr.get_session(&id) {
Some(session) => {
if session.status != crate::session::SessionStatus::Completed {
return err(StatusCode::BAD_REQUEST, "Session must be completed before analysis");
}
}
None => return err(StatusCode::NOT_FOUND, "Session not found"),
}
match mgr.collect_run_frames(&id) {
Some(data) => data,
None => return err(StatusCode::NOT_FOUND, "Session not found"),
}
};
let result = analysis::analyze(&use_case, &frames_by_run, &run_labels);
{
let mut mgr = lock_sessions(&state);
let _ = mgr.set_analysis(&id, result.clone());
}
ok_json(result)
}
async fn list_recordings(State(state): State<Arc<AppState>>) -> Response {
match state.recording_manager.list_recordings().await {
Ok(recordings) => ok_json(recordings),
Err(e) => err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to list recordings: {}", e),
),
}
}
async fn get_recording(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
match state.recording_manager.get_recording(&id).await {
Ok(manifest) => ok_json(manifest),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
err(StatusCode::NOT_FOUND, "Recording not found")
}
Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {
err(StatusCode::BAD_REQUEST, &e.to_string())
}
Err(e) => err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to get recording: {}", e),
),
}
}
async fn start_recording(
State(state): State<Arc<AppState>>,
Json(req): Json<StartRecordingRequest>,
) -> Response {
match state.recording_manager.start_recording(req).await {
Ok(manifest) => (StatusCode::CREATED, Json(manifest)).into_response(),
Err(e) => err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to start recording: {}", e),
),
}
}
async fn stop_recording(State(state): State<Arc<AppState>>) -> Response {
match state.recording_manager.stop_recording().await {
Ok(Some(manifest)) => ok_json(manifest),
Ok(None) => err(StatusCode::BAD_REQUEST, "No active recording to stop"),
Err(e) => err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to stop recording: {}", e),
),
}
}
async fn stop_specific_recording(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let manifest = match state.recording_manager.get_recording(&id).await {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return err(StatusCode::NOT_FOUND, "Recording not found")
}
Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {
return err(StatusCode::BAD_REQUEST, &e.to_string())
}
Err(e) => {
return err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to get recording: {}", e),
)
}
};
if manifest.status != RecordingStatus::Recording {
return err(StatusCode::CONFLICT, "Recording is not active");
}
match state
.recording_manager
.stop_recording_internal(&id)
.await
{
Ok(stopped) => {
state
.recording_manager
.clear_active_if_matches(&id)
.await;
ok_json(stopped)
}
Err(e) => err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to stop recording: {}", e),
),
}
}
async fn delete_recording(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
match state.recording_manager.delete_recording(&id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {
err(StatusCode::CONFLICT, &e.to_string())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
err(StatusCode::NOT_FOUND, "Recording not found")
}
Err(e) => err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to delete recording: {}", e),
),
}
}
async fn get_feed_data(
State(state): State<Arc<AppState>>,
Path((id, feed_name)): Path<(String, String)>,
) -> Response {
let active_id = state.recording_manager.active_recording_id().await;
if active_id.as_deref() == Some(id.as_str()) {
match state.recording_manager.read_frames(&feed_name) {
Some(frames) => return ok_json(frames),
None => {
return err(
StatusCode::NOT_FOUND,
&format!("Feed '{}' not found in active recording", feed_name),
)
}
}
}
let manifest = match state.recording_manager.get_recording(&id).await {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return err(StatusCode::NOT_FOUND, "Recording not found")
}
Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {
return err(StatusCode::BAD_REQUEST, &e.to_string())
}
Err(e) => {
return err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to get recording: {}", e),
)
}
};
let feed_stats = match manifest.feeds.iter().find(|f| f.name == feed_name) {
Some(f) => f,
None => {
return err(
StatusCode::NOT_FOUND,
&format!("Feed '{}' not found in recording", feed_name),
)
}
};
let file_path = manifest.dir_path(state.recording_manager.data_dir()).join(&feed_stats.file);
if !file_path.exists() {
return err(StatusCode::NOT_FOUND, "Feed data file not found on disk (recording may still be in progress)");
}
match tokio::fs::read_to_string(&file_path).await {
Ok(content) => {
let frames: Vec<serde_json::Value> = content
.lines()
.filter(|l| !l.is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
ok_json(frames)
}
Err(e) => err(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to read feed data: {}", e),
),
}
}
async fn get_live(State(state): State<Arc<AppState>>) -> Response {
let mut feeds = Vec::new();
for (info, state_arc) in &state.feed_displays {
let snapshot = state_arc.lock().ok().map(|mut s| {
s.tick();
s.clone()
});
let snap = match snapshot {
Some(s) => s,
None => continue,
};
let (speed_kmh, gear, engine_rpm, throttle, brake, lap_time, spline_position) =
if let Some(ref frame) = snap.last_frame {
(
Some(frame.speed_kmh),
Some(frame.gear_display()),
Some(frame.engine_rpm),
Some(frame.throttle),
Some(frame.brake),
Some(
crate::telemetry_frame::TelemetryFrame::format_lap_time(frame.lap_time_ms),
),
Some(frame.spline_position),
)
} else {
(None, None, None, None, None, None, None)
};
feeds.push(LiveFeedSnapshot {
label: info.label.to_string(),
port: info.port,
health: format!("{:?}", snap.health).to_lowercase(),
frequency_hz: snap.frequency_hz,
total_packets: snap.total_packets,
data_rate_kb_s: snap.data_rate_kb_s,
speed_kmh,
gear,
engine_rpm,
throttle,
brake,
lap_time,
spline_position,
});
}
let active_recording = state.recording_manager.active_recording_id().await;
ok_json(LiveTelemetryResponse {
feeds,
active_recording,
})
}
async fn list_use_cases() -> Response {
ok_json(UseCase::all())
}
async fn list_games() -> Response {
ok_json(Game::valid_choices())
}
pub fn build_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/api/sessions", get(list_sessions).post(create_session))
.route("/api/sessions/{id}", get(get_session).delete(delete_session))
.route("/api/sessions/{id}/runs", post(start_run))
.route("/api/sessions/{id}/runs/{run_id}/stop", post(stop_run))
.route("/api/sessions/{id}/complete", post(complete_session))
.route("/api/sessions/{id}/analysis", get(get_analysis))
.route("/api/sessions/{id}/analyze", post(run_analysis))
.route("/api/recordings", get(list_recordings).post(start_recording))
.route("/api/recordings/stop", post(stop_recording))
.route(
"/api/recordings/{id}",
get(get_recording).delete(delete_recording),
)
.route("/api/recordings/{id}/stop", post(stop_specific_recording))
.route(
"/api/recordings/{id}/data/{feed_name}",
get(get_feed_data),
)
.route(
"/api/recordings/{id}/frames/{feed_name}",
get(get_feed_data),
)
.route("/api/live", get(get_live))
.route("/api/use-cases", get(list_use_cases))
.route("/api/games", get(list_games))
.layer(CorsLayer::permissive())
.with_state(state)
}
pub async fn run_server(state: Arc<AppState>, addr: String, mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
let router = build_router(state);
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
eprintln!("[ERROR] Failed to bind API server on {}: {}", addr, e);
return;
}
};
eprintln!("[INFO] REST API server listening on {}", addr);
let shutdown_signal = async move {
while shutdown_rx.changed().await.is_ok() {
if *shutdown_rx.borrow() {
break;
}
}
};
if let Err(e) = axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal)
.await
{
eprintln!("[ERROR] API server error: {}", e);
}
eprintln!("[INFO] REST API server shut down.");
}

View file

@ -1,10 +1,14 @@
mod api;
mod binary_utils; mod binary_utils;
mod feed_state; mod feed_state;
mod ui;
mod telemetry_frame;
mod parser_ksudp; mod parser_ksudp;
mod parser_telemetry_tool;
mod parser_pcars; mod parser_pcars;
mod parser_telemetry_tool;
mod recording;
mod telemetry_frame;
mod ui;
mod analysis;
mod session;
use feed_state::*; use feed_state::*;
use ui::*; use ui::*;
@ -21,10 +25,10 @@ use crossterm::{
}; };
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
use tokio::io::BufWriter;
use tokio::io::AsyncWriteExt;
use crate::feed_state::HandshakePhase; use crate::feed_state::HandshakePhase;
use crate::recording::FEED_DEFS;
use crate::session::SessionManager;
use crate::telemetry_frame::TelemetryFrame; use crate::telemetry_frame::TelemetryFrame;
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
@ -35,6 +39,20 @@ const HANDSHAKE_RESPONSE_SIZE: usize = 408;
const HANDSHAKE_REQUEST: [u8; 12] = [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]; 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]; const SUBSCRIBE_UPDATE: [u8; 12] = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0];
const API_PORT: u16 = 8080;
fn api_port() -> u16 {
std::env::var("RUSTY_TELEMETRY_API_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(API_PORT)
}
fn api_bind_addr() -> String {
let host = std::env::var("RUSTY_TELEMETRY_API_HOST")
.unwrap_or_else(|_| "127.0.0.1".to_string());
format!("{}:{}", host, api_port())
}
fn decode_utf16le(bytes: &[u8]) -> String { fn decode_utf16le(bytes: &[u8]) -> String {
let chars: Vec<u16> = bytes let chars: Vec<u16> = bytes
@ -74,20 +92,20 @@ struct FeedConfig {
static FEEDS: &[FeedConfig] = &[ static FEEDS: &[FeedConfig] = &[
FeedConfig { FeedConfig {
port: 9996, port: FEED_DEFS[0].0,
name: "ksudp", name: FEED_DEFS[0].1,
label: "KSUDP", label: "KSUDP",
needs_handshake: true, needs_handshake: true,
}, },
FeedConfig { FeedConfig {
port: 10101, port: FEED_DEFS[1].0,
name: "telemetry_tool", name: FEED_DEFS[1].1,
label: "Telemetry Tool", label: "Telemetry Tool",
needs_handshake: false, needs_handshake: false,
}, },
FeedConfig { FeedConfig {
port: 5606, port: FEED_DEFS[2].0,
name: "pcars", name: FEED_DEFS[2].1,
label: "PCARS", label: "PCARS",
needs_handshake: false, needs_handshake: false,
}, },
@ -143,7 +161,7 @@ impl CachedStrings {
async fn run_feed( async fn run_feed(
feed: FeedConfig, feed: FeedConfig,
socket: tokio::net::UdpSocket, socket: tokio::net::UdpSocket,
session_dir: String, recording_manager: Arc<recording::RecordingManager>,
state: Arc<std::sync::Mutex<FeedState>>, state: Arc<std::sync::Mutex<FeedState>>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>, mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) { ) {
@ -264,29 +282,6 @@ async fn run_feed(
None None
}; };
let file_path = format!(
"{}/feed_{}_{}.bin",
session_dir, feed.port, feed.name
);
let file = match tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&file_path)
.await
{
Ok(f) => f,
Err(e) => {
eprintln!(
"[ERROR] Cannot open dump file for :{} ({}): {}",
feed.port, feed.name, e
);
if let Ok(mut s) = state.lock() {
s.set_error(&format!("Cannot open dump file: {}", e));
}
return;
}
};
let mut file = BufWriter::new(file);
let mut buf = vec![0u8; BUFFER_SIZE]; let mut buf = vec![0u8; BUFFER_SIZE];
let mut string_cache = CachedStrings::new(); let mut string_cache = CachedStrings::new();
@ -340,23 +335,13 @@ async fn run_feed(
// Update state in a single brief lock acquisition. // Update state in a single brief lock acquisition.
if let Ok(mut s) = state.lock() { if let Ok(mut s) = state.lock() {
s.record_packet(len); s.record_packet(len);
if let Some(frame) = parsed { if let Some(ref frame) = parsed {
s.update_frame(frame); s.update_frame(frame.clone());
} }
} }
let header = (len as u32).to_be_bytes(); if let Some(ref frame) = parsed {
if let Err(e) = file.write_all(&header).await { recording_manager.push_frame(feed.name, frame.clone());
eprintln!(
"[ERROR] Write error on :{} ({}): {}",
feed.port, feed.name, e
);
}
if let Err(e) = file.write_all(&buf[..len]).await {
eprintln!(
"[ERROR] Write error on :{} ({}): {}",
feed.port, feed.name, e
);
} }
} }
Err(e) => { Err(e) => {
@ -388,20 +373,8 @@ async fn run_feed(
} }
} }
if let Err(e) = file.flush().await {
eprintln!(
"[ERROR] Flush error on :{} ({}): {}",
feed.port, feed.name, e
);
}
if let Err(e) = file.get_ref().sync_all().await {
eprintln!(
"[ERROR] Sync error on :{} ({}): {}",
feed.port, feed.name, e
);
}
eprintln!( eprintln!(
"[INFO] Feed :{} ({}) flushed and closed", "[INFO] Feed :{} ({}) stopped",
feed.port, feed.name feed.port, feed.name
); );
} }
@ -411,14 +384,43 @@ async fn main() {
install_panic_hook(); install_panic_hook();
let listen = listen_addr(); let listen = listen_addr();
let session_id = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string(); let data_dir = std::path::PathBuf::from("./data");
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(&data_dir).await {
eprintln!("[ERROR] Cannot create data dir {}: {}", session_dir, e); eprintln!("[ERROR] Cannot create data dir: {}", e);
std::process::exit(1); std::process::exit(1);
} }
let recording_manager = Arc::new(recording::RecordingManager::new(&data_dir));
let session_manager = Arc::new(std::sync::Mutex::new(
SessionManager::new(recording_manager.clone())
));
let auto_start = recording::StartRecordingRequest {
name: Some(
chrono::Local::now()
.format("Session %Y-%m-%d %H:%M")
.to_string(),
),
game: Some(recording::Game::Unknown),
track: None,
car: None,
use_case: Some(recording::UseCase::General),
notes: None,
};
match recording_manager.start_recording(auto_start).await {
Ok(m) => {
eprintln!(
"[INFO] Auto-started recording: {} ({})",
m.name, m.id
);
}
Err(e) => {
eprintln!("[ERROR] Failed to start recording: {}", e);
std::process::exit(1);
}
}
let mut terminal = match setup_terminal() { let mut terminal = match setup_terminal() {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
@ -483,14 +485,23 @@ async fn main() {
if let Some(socket) = socket_opt { if let Some(socket) = socket_opt {
let feed_clone = feed.clone(); let feed_clone = feed.clone();
let dir = session_dir.clone(); let rm = recording_manager.clone();
let sh = shutdown_rx.clone(); let sh = shutdown_rx.clone();
feed_tasks.push(tokio::spawn(async move { feed_tasks.push(tokio::spawn(async move {
run_feed(feed_clone, socket, dir, state, sh).await; run_feed(feed_clone, socket, rm, state, sh).await;
})); }));
} }
} }
let api_state = Arc::new(api::AppState {
recording_manager: recording_manager.clone(),
feed_displays: feed_displays.clone(),
session_manager: session_manager.clone(),
});
let api_addr = api_bind_addr();
eprintln!("[INFO] REST API server will listen on {}", api_addr);
tokio::spawn(api::run_server(api_state, api_addr, shutdown_rx.clone()));
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<Event>(100); let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<Event>(100);
let event_running = Arc::new(AtomicBool::new(true)); let event_running = Arc::new(AtomicBool::new(true));
let event_running_clone = event_running.clone(); let event_running_clone = event_running.clone();
@ -508,6 +519,12 @@ async fn main() {
let mut render_interval = tokio::time::interval(Duration::from_millis(RENDER_INTERVAL_MS)); let mut render_interval = tokio::time::interval(Duration::from_millis(RENDER_INTERVAL_MS));
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
let recording_rx_display = recording_manager.subscribe();
let get_session_display = || {
recording_rx_display.borrow().clone()
.map(|id| format!("./data/{}/", id))
.unwrap_or_else(|| "No active recording".to_string())
};
while running.load(Ordering::Relaxed) { while running.load(Ordering::Relaxed) {
tokio::select! { tokio::select! {
@ -528,8 +545,9 @@ async fn main() {
} }
} }
Event::Resize(_, _) => { Event::Resize(_, _) => {
let session_display = get_session_display();
let _ = terminal.draw(|f| { let _ = terminal.draw(|f| {
render(f, &feed_displays, &session_dir, VERSION); render(f, &feed_displays, &session_display, VERSION);
}); });
} }
_ => {} _ => {}
@ -537,8 +555,9 @@ async fn main() {
} }
} }
_ = render_interval.tick() => { _ = render_interval.tick() => {
let session_display = get_session_display();
let _ = terminal.draw(|f| { let _ = terminal.draw(|f| {
render(f, &feed_displays, &session_dir, VERSION); render(f, &feed_displays, &session_display, VERSION);
}); });
} }
} }
@ -547,6 +566,11 @@ async fn main() {
event_running.store(false, Ordering::Relaxed); event_running.store(false, Ordering::Relaxed);
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
// Flush in-memory ring buffers to disk before exit.
if let Err(e) = recording_manager.stop_recording().await {
eprintln!("[WARN] Failed to flush recording on shutdown: {}", e);
}
match tokio::time::timeout( match tokio::time::timeout(
Duration::from_secs(3), Duration::from_secs(3),
futures::future::join_all(feed_tasks), futures::future::join_all(feed_tasks),
@ -561,7 +585,7 @@ async fn main() {
} }
} }
Err(_) => { Err(_) => {
eprintln!("[WARN] Timeout waiting for feeds to flush; forcing exit"); eprintln!("[WARN] Timeout waiting for feeds to stop; forcing exit");
} }
} }

508
src/recording.rs Normal file
View file

@ -0,0 +1,508 @@
//! Recording session management with always-on ring buffers.
//!
//! Each feed has a rolling ring buffer holding the last 5 minutes of telemetry frames.
//! Explicit recordings can flush the ring buffer to disk as JSONL files.
//! Use-case sessions extract frames from the ring buffer for analysis.
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::sync::{Mutex, watch};
use crate::telemetry_frame::TelemetryFrame;
/// Feed port/name definitions. Must be kept in sync with FEEDS in main.rs.
pub const FEED_DEFS: &[(u16, &str)] = &[
(9996, "ksudp"),
(10101, "telemetry_tool"),
(5606, "pcars"),
];
/// Validate that a recording ID is a well-formed UUID.
/// Prevents path traversal attacks via the API.
fn validate_recording_id(id: &str) -> std::io::Result<()> {
if uuid::Uuid::parse_str(id).is_err() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Invalid recording ID format",
));
}
Ok(())
}
/// Generate the JSONL filename for a feed.
pub fn feed_filename(port: u16, name: &str) -> String {
format!("feed_{}_{}.jsonl", port, name)
}
/// Ring buffer duration in seconds.
const RING_BUFFER_SECS: u64 = 300; // 5 minutes
/// Maximum sample rate across all feeds (Hz).
const MAX_FEED_RATE_HZ: u64 = 60;
/// Ring buffer capacity = duration × max rate.
pub const RING_BUFFER_CAPACITY: usize = (RING_BUFFER_SECS * MAX_FEED_RATE_HZ) as usize;
/// In-memory ring buffer of parsed telemetry frames for a single feed.
/// Always active — captures the last 5 minutes of data regardless of recording state.
pub struct RingBuffer {
frames: VecDeque<TelemetryFrame>,
total_pushed: u64,
dropped_count: u64,
}
impl RingBuffer {
pub fn new() -> Self {
Self {
frames: VecDeque::with_capacity(RING_BUFFER_CAPACITY),
total_pushed: 0,
dropped_count: 0,
}
}
/// Push a frame. Oldest frame is evicted when at capacity (true ring behavior).
pub fn push(&mut self, frame: TelemetryFrame) {
if self.frames.len() >= RING_BUFFER_CAPACITY {
self.frames.pop_front();
self.dropped_count += 1;
}
self.frames.push_back(frame);
self.total_pushed += 1;
}
/// Returns all frames in chronological order.
pub fn frames(&self) -> &VecDeque<TelemetryFrame> {
&self.frames
}
/// Number of frames currently stored.
pub fn len(&self) -> usize {
self.frames.len()
}
/// Whether the buffer is empty.
pub fn is_empty(&self) -> bool {
self.frames.is_empty()
}
/// Total frames ever pushed (monotonically increasing).
pub fn total_pushed(&self) -> u64 {
self.total_pushed
}
/// Number of frames evicted due to ring overflow.
pub fn dropped_count(&self) -> u64 {
self.dropped_count
}
/// Returns a marker representing the current position (for extract_since).
pub fn marker(&self) -> u64 {
self.total_pushed
}
/// Extract frames pushed since the given marker.
/// Returns all frames if the marker predates the buffer contents.
/// Returns empty vec if marker is at or past the current position.
pub fn extract_since(&self, marker: u64) -> Vec<TelemetryFrame> {
if marker >= self.total_pushed {
return Vec::new();
}
let buffer_start = self.total_pushed.saturating_sub(self.frames.len() as u64);
let start_offset = if marker < buffer_start {
0
} else {
(marker - buffer_start) as usize
};
self.frames.iter().skip(start_offset).cloned().collect()
}
/// Snapshot of all current frames.
pub fn snapshot(&self) -> Vec<TelemetryFrame> {
self.frames.iter().cloned().collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum UseCase {
ShiftPoints,
RacingLine,
BrakingAnalysis,
LapTimes,
SectorAnalysis,
TrackMap,
General,
}
impl UseCase {
pub fn all() -> Vec<Self> {
vec![
UseCase::ShiftPoints,
UseCase::RacingLine,
UseCase::BrakingAnalysis,
UseCase::LapTimes,
UseCase::SectorAnalysis,
UseCase::TrackMap,
UseCase::General,
]
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Game {
AssettoCorsa,
AssettoCorsaCompetizione,
ProjectCars1,
ProjectCars2,
Unknown,
}
impl Game {
/// Returns the list of valid game choices for API consumers (excludes Unknown).
pub fn valid_choices() -> Vec<Self> {
vec![
Game::AssettoCorsa,
Game::AssettoCorsaCompetizione,
Game::ProjectCars1,
Game::ProjectCars2,
]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedStats {
pub port: u16,
pub name: String,
pub bytes: u64,
pub file: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum RecordingStatus {
Recording,
Stopped,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordingManifest {
pub id: String,
pub name: String,
pub game: Game,
pub track: String,
pub car: String,
pub use_case: UseCase,
pub status: RecordingStatus,
pub created_at: String,
pub stopped_at: Option<String>,
pub feeds: Vec<FeedStats>,
pub notes: String,
}
impl RecordingManifest {
pub async fn save(&self, data_dir: &Path) -> std::io::Result<()> {
let dir = data_dir.join(&self.id);
fs::create_dir_all(&dir).await?;
let json = serde_json::to_string_pretty(self)?;
fs::write(dir.join("manifest.json"), json).await?;
Ok(())
}
pub async fn load(dir: &Path) -> std::io::Result<Self> {
let json = fs::read_to_string(dir.join("manifest.json")).await?;
let manifest: RecordingManifest = serde_json::from_str(&json)?;
Ok(manifest)
}
pub fn dir_path(&self, data_dir: &Path) -> PathBuf {
data_dir.join(&self.id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StartRecordingRequest {
pub name: Option<String>,
pub game: Option<Game>,
pub track: Option<String>,
pub car: Option<String>,
pub use_case: Option<UseCase>,
pub notes: Option<String>,
}
/// Manages always-on ring buffers and optional disk-based recordings.
pub struct RecordingManager {
data_dir: PathBuf,
active: Mutex<Option<String>>,
recording_change_tx: watch::Sender<Option<String>>,
/// Per-feed ring buffers — always active, created at startup.
ring_buffers: HashMap<String, Arc<std::sync::Mutex<RingBuffer>>>,
}
impl RecordingManager {
pub fn new(data_dir: &Path) -> Self {
let (tx, _) = watch::channel(None);
let mut ring_buffers = HashMap::new();
for &(_port, name) in FEED_DEFS {
ring_buffers.insert(name.to_string(), Arc::new(std::sync::Mutex::new(RingBuffer::new())));
}
Self {
data_dir: data_dir.to_path_buf(),
active: Mutex::new(None),
recording_change_tx: tx,
ring_buffers,
}
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
pub fn subscribe(&self) -> watch::Receiver<Option<String>> {
self.recording_change_tx.subscribe()
}
/// Push a telemetry frame into the feed's ring buffer. Always succeeds.
pub fn push_frame(&self, feed_name: &str, frame: TelemetryFrame) {
if let Some(buf) = self.ring_buffers.get(feed_name) {
if let Ok(mut b) = buf.lock() {
b.push(frame);
}
}
}
/// Get the current ring buffer marker for a feed (for extract_since).
pub fn ring_marker(&self, feed_name: &str) -> u64 {
self.ring_buffers.get(feed_name)
.and_then(|b| b.lock().ok())
.map(|b| b.marker())
.unwrap_or(0)
}
/// Extract frames from a feed's ring buffer since the given marker.
pub fn extract_since(&self, feed_name: &str, marker: u64) -> Vec<TelemetryFrame> {
self.ring_buffers.get(feed_name)
.and_then(|b| b.lock().ok())
.map(|b| b.extract_since(marker))
.unwrap_or_default()
}
/// Read all current frames from a feed's ring buffer.
pub fn read_frames(&self, feed_name: &str) -> Option<Vec<TelemetryFrame>> {
self.ring_buffers.get(feed_name)
.and_then(|b| b.lock().ok())
.map(|b| b.snapshot())
}
/// Get ring buffer stats for a feed.
pub fn ring_stats(&self, feed_name: &str) -> Option<(usize, u64, u64)> {
self.ring_buffers.get(feed_name)
.and_then(|b| b.lock().ok())
.map(|b| (b.len(), b.total_pushed(), b.dropped_count()))
}
pub async fn start_recording(
&self,
req: StartRecordingRequest,
) -> std::io::Result<RecordingManifest> {
// NOTE: Hold the active lock across I/O to prevent concurrent start_recording calls
// from creating orphaned recordings. For a single-user local tool, the contention
// cost is negligible compared to the correctness benefit.
let mut active = self.active.lock().await;
if let Some(ref id) = *active {
self.stop_recording_internal(id).await?;
}
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
let feed_name = |port: u16, name: &str| -> FeedStats {
FeedStats {
port,
name: name.to_string(),
bytes: 0,
file: feed_filename(port, name),
}
};
let manifest = RecordingManifest {
id: id.clone(),
name: req.name.unwrap_or_else(|| {
chrono::Local::now()
.format("Recording %Y-%m-%d %H:%M")
.to_string()
}),
game: req.game.unwrap_or(Game::Unknown),
track: req.track.unwrap_or_default(),
car: req.car.unwrap_or_default(),
use_case: req.use_case.unwrap_or(UseCase::General),
status: RecordingStatus::Recording,
created_at: now,
stopped_at: None,
feeds: FEED_DEFS.iter().map(|&(port, name)| feed_name(port, name)).collect(),
notes: req.notes.unwrap_or_default(),
};
let dir = manifest.dir_path(&self.data_dir);
fs::create_dir_all(&dir).await?;
manifest.save(&self.data_dir).await?;
*active = Some(id.clone());
let _ = self.recording_change_tx.send(Some(id));
Ok(manifest)
}
pub async fn stop_recording(&self) -> std::io::Result<Option<RecordingManifest>> {
let mut active = self.active.lock().await;
let id = match active.clone() {
Some(id) => id,
None => return Ok(None),
};
let result = self.stop_recording_internal(&id).await;
if result.is_ok() {
active.take();
let _ = self.recording_change_tx.send(None);
}
result.map(Some)
}
pub async fn stop_recording_internal(&self, id: &str) -> std::io::Result<RecordingManifest> {
validate_recording_id(id)?;
let dir = self.data_dir.join(id);
if !dir.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Recording not found",
));
}
let mut manifest = RecordingManifest::load(&dir).await?;
// Flush ring buffers to disk as JSONL files.
// Step 1: Clone frame data while locked (fast memcpy).
let frame_snapshots: Vec<(PathBuf, Vec<TelemetryFrame>)> = {
let mut snapshots = Vec::new();
for &(port, name) in FEED_DEFS {
if let Some(buf_arc) = self.ring_buffers.get(name) {
if let Ok(buf) = buf_arc.lock() {
if !buf.is_empty() {
let file_path = dir.join(feed_filename(port, name));
let frames: Vec<TelemetryFrame> = buf.frames().iter().cloned().collect();
snapshots.push((file_path, frames));
}
}
}
}
snapshots
};
// Step 2: Serialize to JSONL outside the lock.
let mut flush_errors = Vec::new();
for (path, frames) in &frame_snapshots {
let mut content = String::with_capacity(frames.len() * 400);
for frame in frames {
if let Ok(line) = serde_json::to_string(frame) {
content.push_str(&line);
content.push('\n');
}
}
if let Err(e) = fs::write(path, content).await {
eprintln!("[WARN] Failed to flush frames to {}: {}", path.display(), e);
flush_errors.push(format!("{}: {}", path.display(), e));
}
}
if !flush_errors.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Flush errors: {}", flush_errors.join(", ")),
));
}
// Update feed stats from actual file sizes AFTER flush.
for feed in &mut manifest.feeds {
let feed_path = dir.join(&feed.file);
if feed_path.exists() {
if let Ok(metadata) = fs::metadata(&feed_path).await {
feed.bytes = metadata.len();
}
}
}
manifest.status = RecordingStatus::Stopped;
manifest.stopped_at = Some(chrono::Utc::now().to_rfc3339());
manifest.save(&self.data_dir).await?;
Ok(manifest)
}
pub async fn list_recordings(&self) -> std::io::Result<Vec<RecordingManifest>> {
let mut recordings = Vec::new();
let mut entries = fs::read_dir(&self.data_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_dir() && path.join("manifest.json").exists() {
match RecordingManifest::load(&path).await {
Ok(m) => recordings.push(m),
Err(e) => {
eprintln!(
"[WARN] Failed to load manifest from {}: {}",
path.display(),
e
);
}
}
}
}
recordings.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(recordings)
}
pub async fn get_recording(&self, id: &str) -> std::io::Result<RecordingManifest> {
validate_recording_id(id)?;
let dir = self.data_dir.join(id);
if !dir.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Recording not found",
));
}
RecordingManifest::load(&dir).await
}
pub async fn delete_recording(&self, id: &str) -> std::io::Result<()> {
validate_recording_id(id)?;
{
let active = self.active.lock().await;
if active.as_deref() == Some(id) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Cannot delete an active recording. Stop it first.",
));
}
}
let dir = self.data_dir.join(id);
if !dir.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Recording not found",
));
}
fs::remove_dir_all(&dir).await
}
pub async fn active_recording_id(&self) -> Option<String> {
self.active.lock().await.clone()
}
pub async fn clear_active_if_matches(&self, id: &str) {
let mut active = self.active.lock().await;
if let Some(ref active_id) = *active {
if active_id == id {
*active = None;
let _ = self.recording_change_tx.send(None);
}
}
}
}

273
src/session.rs Normal file
View file

@ -0,0 +1,273 @@
//! Use-case driven session management for telemetry analysis.
//!
//! Users create sessions tied to specific analysis goals (shift point analysis,
//! track mapping, braking analysis, etc.). Each session contains multiple "runs"
//! that capture telemetry frames from the always-on ring buffer between start/stop
//! markers. Frames are stored in-memory and can be retrieved for analysis.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use crate::recording::{RecordingManager, UseCase};
use crate::telemetry_frame::TelemetryFrame;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum SessionStatus {
Active,
Completed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Recording,
Stopped,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRun {
pub id: String,
pub label: String,
pub feed_name: String,
pub status: RunStatus,
pub start_marker: u64,
pub end_marker: Option<u64>,
pub frame_count: usize,
pub data_truncated: bool,
#[serde(skip)]
pub frames: Vec<TelemetryFrame>,
}
impl SessionRun {
fn summary_clone(&self) -> Self {
Self {
id: self.id.clone(),
label: self.label.clone(),
feed_name: self.feed_name.clone(),
status: self.status.clone(),
start_marker: self.start_marker,
end_marker: self.end_marker,
frame_count: self.frame_count,
data_truncated: self.data_truncated,
frames: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UseCaseSession {
pub id: String,
pub use_case: UseCase,
pub label: String,
pub status: SessionStatus,
pub created_at: String,
pub completed_at: Option<String>,
pub runs: Vec<SessionRun>,
pub analysis: Option<serde_json::Value>,
}
impl UseCaseSession {
fn summary_clone(&self) -> Self {
Self {
id: self.id.clone(),
use_case: self.use_case.clone(),
label: self.label.clone(),
status: self.status.clone(),
created_at: self.created_at.clone(),
completed_at: self.completed_at.clone(),
runs: self.runs.iter().map(|r| r.summary_clone()).collect(),
analysis: self.analysis.clone(),
}
}
}
pub struct SessionManager {
sessions: HashMap<String, UseCaseSession>,
recording_manager: Arc<RecordingManager>,
}
impl SessionManager {
pub fn new(recording_manager: Arc<RecordingManager>) -> Self {
Self {
sessions: HashMap::new(),
recording_manager,
}
}
pub fn create_session(
&mut self,
use_case: UseCase,
label: Option<String>,
) -> Result<UseCaseSession, String> {
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
let session = UseCaseSession {
id: id.clone(),
label: label.unwrap_or_else(|| format!("{:?} Session", use_case)),
use_case,
status: SessionStatus::Active,
created_at: now,
completed_at: None,
runs: Vec::new(),
analysis: None,
};
self.sessions.insert(id, session.clone());
Ok(session)
}
pub fn list_sessions(&mut self) -> Vec<UseCaseSession> {
let mut sessions: Vec<UseCaseSession> = self
.sessions
.values()
.map(|s| s.summary_clone())
.collect();
sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
sessions
}
pub fn get_session(&self, id: &str) -> Option<UseCaseSession> {
self.sessions.get(id).map(|s| s.summary_clone())
}
pub fn start_run(
&mut self,
session_id: &str,
feed_name: &str,
label: Option<String>,
) -> Result<SessionRun, String> {
let session = self
.sessions
.get_mut(session_id)
.ok_or_else(|| format!("Session '{}' not found", session_id))?;
if session.status != SessionStatus::Active {
return Err(format!("Session '{}' is not active", session_id));
}
let marker = self.recording_manager.ring_marker(feed_name);
let run_id = uuid::Uuid::new_v4().to_string();
let run = SessionRun {
id: run_id,
label: label.unwrap_or_else(|| format!("Run {}", session.runs.len() + 1)),
feed_name: feed_name.to_string(),
status: RunStatus::Recording,
start_marker: marker,
end_marker: None,
frame_count: 0,
data_truncated: false,
frames: Vec::new(),
};
let summary = run.summary_clone();
session.runs.push(run);
Ok(summary)
}
pub fn stop_run(
&mut self,
session_id: &str,
run_id: &str,
) -> Result<SessionRun, String> {
let session = self
.sessions
.get_mut(session_id)
.ok_or_else(|| format!("Session '{}' not found", session_id))?;
let run = session
.runs
.iter_mut()
.find(|r| r.id == run_id)
.ok_or_else(|| format!("Run '{}' not found in session '{}'", run_id, session_id))?;
if run.status != RunStatus::Recording {
return Err(format!("Run '{}' is not recording", run_id));
}
let end_marker = self.recording_manager.ring_marker(&run.feed_name);
let frames = self
.recording_manager
.extract_since(&run.feed_name, run.start_marker);
let expected = end_marker.saturating_sub(run.start_marker);
let data_truncated = (frames.len() as u64) < expected && expected > 0;
run.end_marker = Some(end_marker);
run.frame_count = frames.len();
run.frames = frames;
run.status = RunStatus::Stopped;
run.data_truncated = data_truncated;
Ok(run.summary_clone())
}
pub fn complete_session(&mut self, id: &str) -> Result<UseCaseSession, String> {
let session = self
.sessions
.get_mut(id)
.ok_or_else(|| format!("Session '{}' not found", id))?;
if session.status != SessionStatus::Active {
return Err(format!("Session '{}' is not active", id));
}
for run in &mut session.runs {
if run.status == RunStatus::Recording {
let end_marker = self.recording_manager.ring_marker(&run.feed_name);
let frames = self
.recording_manager
.extract_since(&run.feed_name, run.start_marker);
let expected = end_marker.saturating_sub(run.start_marker);
let data_truncated = (frames.len() as u64) < expected && expected > 0;
run.end_marker = Some(end_marker);
run.frame_count = frames.len();
run.frames = frames;
run.status = RunStatus::Stopped;
run.data_truncated = data_truncated;
}
}
session.status = SessionStatus::Completed;
session.completed_at = Some(chrono::Utc::now().to_rfc3339());
Ok(session.summary_clone())
}
pub fn set_analysis(
&mut self,
session_id: &str,
result: serde_json::Value,
) -> Result<(), String> {
let session = self
.sessions
.get_mut(session_id)
.ok_or_else(|| format!("Session '{}' not found", session_id))?;
session.analysis = Some(result);
Ok(())
}
/// Collect frames and labels from all runs for analysis.
///
/// Clones frames to allow the caller to release the SessionManager lock
/// before running CPU-bound analysis. Frames must remain in runs for
/// potential re-analysis via `POST /api/sessions/{id}/analyze`.
pub fn collect_run_frames(&self, session_id: &str) -> Option<(UseCase, Vec<Vec<TelemetryFrame>>, Vec<String>)> {
let session = self.sessions.get(session_id)?;
let mut frames_by_run = Vec::new();
let mut run_labels = Vec::new();
for run in &session.runs {
frames_by_run.push(run.frames.clone());
run_labels.push(run.label.clone());
}
Some((session.use_case.clone(), frames_by_run, run_labels))
}
pub fn delete_session(&mut self, id: &str) -> Result<(), String> {
self.sessions
.remove(id)
.ok_or_else(|| format!("Session '{}' not found", id))?;
Ok(())
}
}

View file

@ -3,8 +3,10 @@
//! Both the KSUDP (port 9996) and Telemetry Tool (port 10101) parsers produce //! Both the KSUDP (port 9996) and Telemetry Tool (port 10101) parsers produce
//! `TelemetryFrame` instances from their raw binary data. //! `TelemetryFrame` instances from their raw binary data.
use serde::Serialize;
/// Identifies which feed source produced this frame. /// Identifies which feed source produced this frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum FeedSource { pub enum FeedSource {
Ksudp, Ksudp,
TelemetryTool, TelemetryTool,
@ -18,7 +20,7 @@ pub const NUM_WHEELS: usize = 4;
/// ///
/// Fields present in both feeds are stored directly; fields unique to one feed /// Fields present in both feeds are stored directly; fields unique to one feed
/// are wrapped in `Option`. /// are wrapped in `Option`.
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)]
pub struct TelemetryFrame { pub struct TelemetryFrame {
// ── Speed ────────────────────────────────────────────────────── // ── Speed ──────────────────────────────────────────────────────
pub speed_kmh: f32, pub speed_kmh: f32,
@ -233,10 +235,15 @@ impl TelemetryFrame {
/// Gear display: 0=Reverse, 1=Neutral, 2+=Gear N-1 /// Gear display: 0=Reverse, 1=Neutral, 2+=Gear N-1
pub fn gear_display(&self) -> String { pub fn gear_display(&self) -> String {
match self.gear { gear_display(self.gear)
0 => "R".to_string(), }
1 => "N".to_string(), }
n => format!("{}", n - 1),
} /// AC gear encoding: 0=Reverse, 1=Neutral, 2+=Gear N-1.
pub fn gear_display(gear: i32) -> String {
match gear {
0 => "R".to_string(),
1 => "N".to_string(),
n => format!("{}", n - 1),
} }
} }

View file

@ -208,7 +208,7 @@ pub fn render(
let footer_text = Line::from(vec![ let footer_text = Line::from(vec![
Span::styled( Span::styled(
format!(" Session: {}/ ", session_dir), format!(" Recording: {}/ ", session_dir),
Style::default().fg(Color::White), Style::default().fg(Color::White),
), ),
Span::raw(" "), Span::raw(" "),