diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0987a5c..7108b28f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,6 +81,23 @@ cargo r -p saver year-circuit.data.txt > I recommend naming the files with the ending > ".data.txt" as this extension is in the gitignore so you won't accidentally commit the telemetry recordings. +You can also create a recording of a past race using the generator. Just start it up and select the year, meeting, and session. + +```bash +cd f1-dash/ + +# Run the generator +cargo r -p generator +``` + +> [!NOTE] +> Not all past races are available using the generator. The following races are unavailable: +> - All seasons before 2018 +> - The 2018 Australian Grand Prix +> - 2020 and 2021 pre-season testing +> - The 2022 season +> - All races before Spain in 2024 + ## Branching Convention For branch names we use git flow style branching. diff --git a/Cargo.lock b/Cargo.lock index 075ff3c7..b6c90207 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -433,6 +433,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot 0.12.4", + "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 = "crypto-common" version = "0.1.6" @@ -555,6 +580,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -784,6 +815,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "fxhash" version = "0.2.1" @@ -793,6 +833,18 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generator" +version = "0.1.0" +dependencies = [ + "chrono", + "data", + "inquire", + "reqwest", + "serde", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1218,6 +1270,23 @@ dependencies = [ "hashbrown 0.15.3", ] +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.9.1", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "instant" version = "0.1.13" @@ -1396,6 +1465,18 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.4" @@ -1424,6 +1505,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1849,7 +1939,9 @@ dependencies = [ "base64", "bytes", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -2073,9 +2165,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" dependencies = [ "itoa", "memchr", @@ -2142,6 +2234,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.5" @@ -2618,7 +2731,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 1.0.4", "parking_lot 0.12.4", "pin-project-lite", "signal-hook-registry", @@ -2864,6 +2977,18 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index a06d98f5..b84fedc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "services/importer", # bin - service "crates/saver", # bin - util "crates/simulator", # bin - util + "crates/generator", # lib, bin - util ] default-members = [ "services/live", diff --git a/crates/generator/Cargo.toml b/crates/generator/Cargo.toml new file mode 100644 index 00000000..a8af1d4a --- /dev/null +++ b/crates/generator/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "generator" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +chrono = { version = "0.4.41", features = ["alloc"] } +reqwest = { workspace = true, features = ["blocking"] } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +data.workspace = true +inquire = "0.7.5" diff --git a/crates/generator/readme.md b/crates/generator/readme.md new file mode 100644 index 00000000..82c1314b --- /dev/null +++ b/crates/generator/readme.md @@ -0,0 +1,17 @@ +# Generator + +Generates a replay file of a chosen race that the simulator is able to read. + +## Usage + +```bash +cargo r -p generator +``` + +> [!NOTE] +> Not all past races are available using the generator. The following races are unavailable: +> - All seasons before 2018 +> - The 2018 Australian Grand Prix +> - 2020 and 2021 pre-season testing +> - The 2022 season +> - All races before Spain in 2024 \ No newline at end of file diff --git a/crates/generator/src/lib.rs b/crates/generator/src/lib.rs new file mode 100644 index 00000000..05285bc1 --- /dev/null +++ b/crates/generator/src/lib.rs @@ -0,0 +1,461 @@ +use std::{ + io::{self, Write}, + sync::mpsc, + thread, +}; + +use chrono::{DateTime, SecondsFormat}; +use data::merge::merge; +use inquire::{CustomType, Select, error::InquireResult}; +use reqwest::blocking::Client; +use serde::Deserialize; +use serde_json::{Map, Value, json}; + +const REQUIRED_FEEDS: [&'static str; 20] = [ + "Heartbeat", + "CarData.z", + "Position.z", + "ExtrapolatedClock", + "TopThree", + "RcmSeries", + "TimingStats", + "TimingAppData", + "WeatherData", + "TrackStatus", + "SessionStatus", + "DriverList", + "RaceControlMessages", + "SessionInfo", + "SessionData", + "LapCount", + "TimingData", + "TeamRadio", + "PitLaneTimeCollection", + "ChampionshipPrediction", +]; + +#[derive(Deserialize, Clone)] +#[serde(rename_all(deserialize = "PascalCase"))] +pub struct Session { + pub key: u16, + pub r#type: String, + #[serde(default)] + pub number: i8, + pub name: String, + pub start_date: String, + pub end_date: String, + pub gmt_offset: String, + pub path: String, +} + +impl std::fmt::Display for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all(deserialize = "PascalCase"))] +pub struct Country { + pub key: u16, + pub code: String, + pub name: String, +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all(deserialize = "PascalCase"))] +pub struct Circuit { + pub key: u16, + pub short_name: String, +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all(deserialize = "PascalCase"))] +pub struct Meeting { + pub sessions: Vec, + pub key: u16, + pub code: String, + pub number: u8, + pub location: String, + pub official_name: String, + pub name: String, + pub country: Country, + pub circuit: Circuit, +} + +impl std::fmt::Display for Meeting { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +#[derive(Deserialize)] +#[serde(rename_all(deserialize = "PascalCase"))] +pub struct YearIndex { + pub year: u16, + pub meetings: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all(deserialize = "PascalCase"))] +pub struct RaceFeeds { + pub feeds: Map, +} + +#[derive(Deserialize)] +#[serde(rename_all(deserialize = "PascalCase"))] +pub struct Heartbeat { + pub utc: String, +} + +#[derive(Clone)] +pub struct Message { + pub name: String, + pub data: Value, + pub time: u64, +} + +#[derive(Clone)] +pub struct RawReplay { + pub messages: Vec, + pub base_time: Option, + pub start_time: Option, +} + +#[derive(Debug)] +pub enum JsonRequestError { + ReqwestErr(reqwest::Error), + JsonErr(serde_json::Error), +} + +impl From for JsonRequestError { + fn from(value: reqwest::Error) -> Self { + Self::ReqwestErr(value) + } +} + +impl From for JsonRequestError { + fn from(value: serde_json::Error) -> Self { + Self::JsonErr(value) + } +} + +impl std::fmt::Display for JsonRequestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + JsonRequestError::ReqwestErr(e) => write!(f, "{e}"), + JsonRequestError::JsonErr(e) => write!(f, "{e}"), + } + } +} + +pub fn get_meetings(year: u16) -> Result { + let res = reqwest::blocking::get(format!( + "https://livetiming.formula1.com/static/{year}/Index.json" + ))? + .text()?; + Ok(serde_json::from_str(res.trim_start_matches("\u{feff}"))?) +} + +pub fn get_race_feeds(session: &Session) -> Result, JsonRequestError> { + let res = reqwest::blocking::get(format!( + "https://livetiming.formula1.com/static/{}Index.json", + session.path + ))? + .text()?; + Ok( + serde_json::from_str::(res.trim_start_matches("\u{feff}"))? + .feeds + .keys() + .map(|feed| feed.clone()) + .collect(), + ) +} + +fn get_feed_data(client: &Client, path: &String, name: &String) -> reqwest::Result { + client + .get(format!( + "https://livetiming.formula1.com/static/{path}{name}.jsonStream" + )) + .send()? + .text() +} + +pub fn select_year() -> InquireResult { + CustomType::::new("Choose a year") + .with_error_message("Invalid year") + .prompt() +} + +pub fn select_meeting(meetings: &Vec) -> InquireResult { + let mut options = meetings.clone(); + options.reverse(); + Select::new("Choose a meeting", options) + .with_page_size(meetings.len()) + .prompt() +} + +pub fn select_session(sessions: &Vec) -> InquireResult { + let mut options = sessions.clone(); + options.reverse(); + Select::new("Choose a session", options) + .with_page_size(sessions.len()) + .prompt() +} + +pub fn select_year_index() -> YearIndex { + loop { + let year = select_year().expect("Failed to read input"); + match get_meetings(year) { + Ok(year_index) => { + if !year_index.meetings.is_empty() { + return year_index; + } + eprintln!("No races available for {year}") + } + Err(error) => match error { + JsonRequestError::ReqwestErr(error) => eprintln!("{error}"), + JsonRequestError::JsonErr(_) => eprintln!("No races available for {year}"), + }, + } + } +} + +fn parse_line(line: &str) -> (u64, Value) { + let line = line.trim_start_matches("\u{feff}"); + let time = time_to_ms(&line[0..12]); + let data: Value = line[12..].parse().expect("Invalid data"); + + (time, data) +} + +fn time_to_ms(time: &str) -> u64 { + const MS_IN_HOUR: u64 = 1000 * 60 * 60; + const MS_IN_MINUTE: u64 = 1000 * 60; + const MS_IN_SECOND: u64 = 1000; + + let [hours, mins, secs_and_ms] = time.split(":").collect::>()[0..3] else { + panic!("Invalid time format") + }; + let [secs, ms] = secs_and_ms.split(".").collect::>()[0..2] else { + panic!("Invalid time format") + }; + let hours: u64 = hours.parse().expect("Invalid hours"); + let mins: u64 = mins.parse().expect("Invalid minutes"); + let secs: u64 = secs.parse().expect("Invalid seconds"); + let ms: u64 = ms.parse().expect("Invalid milliseconds"); + + hours * MS_IN_HOUR + mins * MS_IN_MINUTE + secs * MS_IN_SECOND + ms +} + +fn calculate_base_time(time: u64, heartbeat_data: &Value) -> Option { + let heartbeat = serde_json::from_value::(heartbeat_data.clone()).ok()?; + let date = DateTime::parse_from_rfc3339(&heartbeat.utc).ok()?; + let timestamp = date.timestamp_millis(); + if timestamp < 0 { + return None; + }; + Some(timestamp as u64 - time) +} + +fn calculate_start_time(session: &Session) -> Option { + let offset_str = session.gmt_offset.split(":").collect::>()[0..2].join(":"); + let offset_str = if session.gmt_offset.starts_with("-") { + offset_str + } else { + format!("+{offset_str}") + }; + match DateTime::parse_from_rfc3339(&format!("{}{}", session.start_date, offset_str)) { + Ok(date) => { + let timestamp = date.timestamp_millis(); + if timestamp < 0 { + None + } else { + Some(timestamp as u64) + } + } + Err(_) => None, + } +} + +fn create_inital_message(messages: Vec<&Message>) -> String { + let mut data = Map::new(); + for message in messages { + if data.contains_key(&message.name) { + merge(data.get_mut(&message.name).unwrap(), message.data.clone()); + } else { + data.insert(message.name.clone(), message.data.clone()); + } + } + json!({ + "I": 1, + "R": data + }) + .to_string() +} + +fn merge_messages(messages: Vec<&Message>, base_time: u64) -> String { + let m: Vec = messages + .iter() + .map(|message| { + json!({ + "H": "Streaming", + "M": "feed", + "A": [ + message.name, + message.data, + DateTime::from_timestamp_millis((base_time + message.time) as i64) + .expect("Something went wrong with the dates") + .to_rfc3339_opts(SecondsFormat::Millis, true) + ] + }) + }) + .collect(); + json!({ + "M": m + }) + .to_string() +} + +enum ThreadMessage { + Message(Message), + BaseTime(u64), + Error(String, reqwest::Error), + Finished, +} + +pub fn generate_raw_replay(session: &Session, show_progress_bar: bool) -> RawReplay { + let feeds = get_race_feeds(&session).unwrap_or(REQUIRED_FEEDS.map(String::from).to_vec()); + + let client = reqwest::blocking::Client::new(); + + let (tx, rx) = mpsc::channel(); + let mut total_threads = 0; + + for feed in feeds { + if !REQUIRED_FEEDS.contains(&feed.as_str()) { + continue; + } + + let tx = tx.clone(); + let client = client.clone(); + let feed = feed.clone(); + let path = session.path.clone(); + + thread::spawn(move || { + let data = get_feed_data(&client, &path, &feed).unwrap_or_else(|err| { + tx.send(ThreadMessage::Error(feed.clone(), err)).unwrap(); + String::new() + }); + + let lines = data.lines(); + let mut send_base_time = feed == "Heartbeat"; + + for line in lines { + let (time, data) = parse_line(line); + + if send_base_time { + if let Some(base_time) = calculate_base_time(time, &data) { + tx.send(ThreadMessage::BaseTime(base_time)).unwrap(); + send_base_time = false; + } + } + + let message = Message { + name: String::from(&feed), + data, + time, + }; + tx.send(ThreadMessage::Message(message)).unwrap(); + } + + tx.send(ThreadMessage::Finished).unwrap(); + }); + total_threads += 1; + } + + let mut messages: Vec = Vec::new(); + let mut base_time: Option = None; + let mut threads_finished = 0; + + for message in rx { + match message { + ThreadMessage::Message(message) => { + let i = messages.partition_point(|m| m.time <= message.time); + messages.insert(i, message); + } + ThreadMessage::BaseTime(bt) => { + if base_time == None { + base_time = Some(bt) + } + } + ThreadMessage::Error(feed, err) => { + eprintln!("\rSomething went wrong with \"{feed}\": {err}") + } + ThreadMessage::Finished => { + threads_finished += 1; + if show_progress_bar { + print!( + "\r[{:num$}]", + "#".repeat(threads_finished), + num = total_threads + ); + let _ = io::stdout().flush(); + } + } + } + if threads_finished == total_threads { + break; + } + } + + if show_progress_bar { + print!("\r{:size$}\r", "", size = total_threads + 2) + } + + RawReplay { + messages, + base_time, + start_time: calculate_start_time(&session), + } +} + +pub fn generate_replay(session: &Session, show_progress_bar: bool) -> String { + const FIVE_MINS_IN_TENTHS: u64 = 10 * 60 * 5; + + let raw_replay = generate_raw_replay(session, show_progress_bar); + + let base_time = raw_replay.base_time.unwrap_or(0); + let start_time = raw_replay.start_time.unwrap_or(base_time); + + let messages = raw_replay.messages; + + let end = messages.last().expect("No data").time / 100; + let start = (start_time - base_time) / 100 - FIVE_MINS_IN_TENTHS; + + let mut out = String::new(); + let mut i = 0; + for time in start..=end { + let mut message_group = Vec::new(); + while let Some(message) = messages.get(i) { + if message.time / 100 <= time { + message_group.push(message); + i += 1; + } else { + break; + } + } + if time == start { + out.push_str(&create_inital_message(message_group)); + } else { + if message_group.is_empty() { + out.push_str("{}"); + } else { + out.push_str(&merge_messages(message_group, base_time)); + } + } + out.push('\n'); + } + + out +} diff --git a/crates/generator/src/main.rs b/crates/generator/src/main.rs new file mode 100644 index 00000000..cb89dc05 --- /dev/null +++ b/crates/generator/src/main.rs @@ -0,0 +1,65 @@ +use std::{fs, path::Path}; + +use generator::{generate_replay, select_meeting, select_session, select_year_index}; +use inquire::{Confirm, Text}; + +fn to_kebab_case(string: &String) -> String { + let mut res = String::new(); + for char in string.chars() { + res.push(if char == ' ' { + '-' + } else { + char.to_ascii_lowercase() + }); + } + res +} + +fn try_to_absolute(path: &Path) -> String { + std::path::absolute(path) + .unwrap_or(path.to_path_buf()) + .display() + .to_string() +} + +fn select_output_path(default: String) -> Box { + loop { + let file_name = Text::new("Output path:") + .with_default(&default) + .with_formatter(&|name| try_to_absolute(std::path::Path::new(name))) + .prompt() + .expect("Failed to read input"); + let path = std::path::Path::new(&file_name); + if path.exists() { + let confirm = Confirm::new(&format!( + "File already exists at path {}. Replace it?", + path.display() + )) + .with_default(true) + .prompt() + .expect("Failed to read input"); + if !confirm { + continue; + } + } + return path.into(); + } +} + +fn main() { + let year_index = select_year_index(); + let meeting = select_meeting(&year_index.meetings).expect("Failed to read input"); + let session = select_session(&meeting.sessions).expect("Failed to read input"); + let path = select_output_path(format!( + "{}-{}-{}.data.txt", + &year_index.year, + to_kebab_case(&meeting.country.name), + to_kebab_case(&session.name) + )); + + let replay = generate_replay(&session, true); + + fs::write(&path, replay).expect("Failed to write to file"); + + println!("Replay created at {}", try_to_absolute(&path)); +}