diff --git a/Cargo.lock b/Cargo.lock index 58e6694..e49d688 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -431,6 +431,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1970,6 +1979,7 @@ dependencies = [ "tokio", "tokio-stream", "tracing", + "tracing-appender", "tracing-subscriber", "uuid", ] @@ -2121,12 +2131,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2135,6 +2147,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.49.0" @@ -2199,6 +2221,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/Cargo.toml b/Cargo.toml index d897db2..262ae6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ derivative = "2.2.0" clap = { version = "4.5.60", features = ["derive"] } async-stream = "0.3.6" rand = "0.10.0" +tracing-appender = "0.2.4" [dev-dependencies] mockall = "0.14.0" diff --git a/Justfile b/Justfile index 03f9fc7..6b584d8 100644 --- a/Justfile +++ b/Justfile @@ -4,6 +4,8 @@ runner := if x'${env:-local}' == "docker" { "docker exec -it suffice-dev-1" } +all: build test lint + docker-build: docker compose build dev diff --git a/src/bin/suffice/app.rs b/src/bin/suffice/app.rs index 7dd5d0e..50eb3ab 100644 --- a/src/bin/suffice/app.rs +++ b/src/bin/suffice/app.rs @@ -20,6 +20,22 @@ use suffice::trainer::Command; use crate::stats::Stat; +#[derive(Debug, Derivative)] +#[derivative(Default)] +struct RideState { + mode: Mode, + #[derivative(Default(value = "true"))] + mode_dirty: bool, + #[derivative(Default(value = "true"))] + level_dirty: bool, + + #[derivative(Default(value = "false"))] + is_recording: bool, + + resistance: i16, + power: i16, +} + #[derive(Debug, Default)] enum Mode { Power, @@ -40,17 +56,12 @@ struct Stats { #[derivative(Default)] /// The main application data bundle pub struct App { - resistance: i16, - power: i16, exit: bool, - mode: Mode, - #[derivative(Default(value = "true"))] - mode_dirty: bool, - #[derivative(Default(value = "true"))] - level_dirty: bool, to_trainer: Option>, from_trainer: Option>, + stats: Stats, + ride: RideState, } impl App { @@ -105,18 +116,18 @@ impl App { _ => {} }; } else { - if self.mode_dirty { + if self.ride.mode_dirty { let _ = self .to_trainer .as_ref() .expect("send should always work") .send(Command::Reset); ev!(Level::INFO, "sent reset command"); - self.mode_dirty = false; + self.ride.mode_dirty = false; } - if self.level_dirty { - match self.mode { + if self.ride.level_dirty { + match self.ride.mode { Mode::Power => { let _ = self .to_trainer @@ -127,7 +138,7 @@ impl App { .to_trainer .as_ref() .expect("") - .send(Command::Power(self.power)); + .send(Command::Power(self.ride.power)); } Mode::Resistance => { let _ = self.to_trainer.as_ref().expect("").send(Command::Reset); @@ -135,15 +146,15 @@ impl App { .to_trainer .as_ref() .expect("") - .send(Command::Resist((self.resistance as u8).into())); + .send(Command::Resist((self.ride.resistance as u8).into())); } } ev!( Level::INFO, "sent level change command for {:?} mode", - self.mode + self.ride.mode ); - self.level_dirty = false; + self.ride.level_dirty = false; } } @@ -163,6 +174,7 @@ impl App { .as_ref() .expect("") .send(Command::ToggleRecording); + self.ride.is_recording = !self.ride.is_recording; } _ => {} } @@ -173,27 +185,27 @@ impl App { } fn more(&mut self) { - match self.mode { - Mode::Power => self.power = (self.power + 10).clamp(0, 1000), - Mode::Resistance => self.resistance = (self.resistance + 1).clamp(0, 100), + match self.ride.mode { + Mode::Power => self.ride.power = (self.ride.power + 10).clamp(0, 1000), + Mode::Resistance => self.ride.resistance = (self.ride.resistance + 1).clamp(0, 100), } - self.level_dirty = true; + self.ride.level_dirty = true; } fn less(&mut self) { - match self.mode { - Mode::Power => self.power = (self.power - 10).clamp(0, 1000), - Mode::Resistance => self.resistance = (self.resistance - 1).clamp(0, 100), + match self.ride.mode { + Mode::Power => self.ride.power = (self.ride.power - 10).clamp(0, 1000), + Mode::Resistance => self.ride.resistance = (self.ride.resistance - 1).clamp(0, 100), } - self.level_dirty = true; + self.ride.level_dirty = true; } fn change_mode(&mut self, _change: i8) { - match self.mode { - Mode::Power => self.mode = Mode::Resistance, - Mode::Resistance => self.mode = Mode::Power, + match self.ride.mode { + Mode::Power => self.ride.mode = Mode::Resistance, + Mode::Resistance => self.ride.mode = Mode::Power, } - self.mode_dirty = true; + self.ride.mode_dirty = true; } } @@ -206,7 +218,11 @@ impl Widget for &App { " Less ".into(), "".blue().bold(), " Record ".into(), - "".blue().bold(), + if self.ride.is_recording { + "".red().slow_blink().bold() + } else { + "".blue().bold() + }, " Quit ".into(), " ".blue().bold(), ]); @@ -222,10 +238,13 @@ impl Widget for &App { let dist_total = self.stats.distance.total(); let counter = Text::from(vec![ - Line::from(match self.mode { - Mode::Power => vec!["Power: ".into(), self.power.to_string().yellow()], + Line::from(match self.ride.mode { + Mode::Power => vec!["Power: ".into(), self.ride.power.to_string().yellow()], Mode::Resistance => { - vec!["Resistance: ".into(), self.resistance.to_string().yellow()] + vec![ + "Resistance: ".into(), + self.ride.resistance.to_string().yellow(), + ] } }), Line::from(vec![]), @@ -266,8 +285,11 @@ mod tests { #[test] fn render_no_data() { let mut app = App { - level_dirty: false, - mode_dirty: false, + ride: RideState { + level_dirty: false, + mode_dirty: false, + ..Default::default() + }, ..Default::default() }; let mut buf = Buffer::empty(Rect::new(0, 0, 50, 10)); @@ -312,8 +334,11 @@ mod tests { #[test] fn render_with_data() { let mut app = App { - level_dirty: false, - mode_dirty: false, + ride: RideState { + level_dirty: false, + mode_dirty: false, + ..Default::default() + }, ..Default::default() }; let mut buf = Buffer::empty(Rect::new(0, 0, 50, 10)); diff --git a/src/bin/suffice/main.rs b/src/bin/suffice/main.rs index a41e3e6..f583f9b 100644 --- a/src/bin/suffice/main.rs +++ b/src/bin/suffice/main.rs @@ -3,6 +3,7 @@ use std::error::Error; use tokio::sync::{broadcast, mpsc}; use suffice::bluetooth::BluetoothDevice; +use suffice::config::Config; use suffice::ftms::{BikeData, FitnessDevice, SampleDevice}; use suffice::trainer::{Command, Trainer}; @@ -62,10 +63,14 @@ async fn main() -> Result<(), Box> { Level::INFO }; + let config = Config::default().get_dir(); + println!("logs and FIT files will be written to {:?}", config.clone()); + let file_appender = tracing_appender::rolling::daily(config, "suffice.log"); + let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender); let subscriber = tracing_subscriber::fmt() .compact() .with_max_level(level) - .with_writer(std::io::stderr) + .with_writer(non_blocking) .finish(); tracing::subscriber::set_global_default(subscriber)?; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..f6661b7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,35 @@ +#[cfg(not(test))] +use chrono::Local; +use std::path::PathBuf; + +#[derive(Default, Debug)] +pub struct Config {} + +#[cfg(test)] +impl Config { + pub fn get_dir(&self) -> PathBuf { + PathBuf::new() + } + + pub fn get_recording_name(&self) -> PathBuf { + let mut fout = self.get_dir(); + fout.push("./output.fit"); + fout + } +} + +#[cfg(not(test))] +impl Config { + pub fn get_dir(&self) -> std::path::PathBuf { + let mut dir = std::env::home_dir().unwrap_or_default(); + dir.push(".config/suffice/"); + dir + } + + pub fn get_recording_name(&self) -> PathBuf { + let mut fout = self.get_dir(); + let now = Local::now(); + fout.push(format!("recording-{}.fit", now.to_rfc3339())); + fout + } +} diff --git a/src/lib.rs b/src/lib.rs index eb3d071..e521345 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ /// Suffice is a TUI app for controlling a bike trainer. pub mod bluetooth; +pub mod config; pub mod ftms; mod record; pub mod trainer; diff --git a/src/record.rs b/src/record.rs index b491d27..57c3d3f 100644 --- a/src/record.rs +++ b/src/record.rs @@ -1,4 +1,6 @@ +use crate::config::Config; use crate::ftms::BikeData; + #[allow(unused_imports)] use chrono::TimeZone as _; use chrono::{DateTime, Local}; @@ -20,12 +22,7 @@ use tracing::{Level, event, instrument}; #[instrument(skip(data))] /// Saves a collection of BikeData to a FIT file on disk. pub(crate) fn save_file(data: &mut [BikeData]) -> Result<(), Error> { - // NOTE: adapted from the example in the documentation - // https://crates.io/crates/rustyfit#encode-using-mesgdef-module - - // FIXME: make this a random/timestamped name and/or with a name specified by user - let fout_name = "output.fit"; - let fout = File::create(fout_name)?; + let fout = File::create(Config::default().get_recording_name())?; let mut bw = BufWriter::new(fout); let mut enc = Encoder::new(&mut bw); diff --git a/src/trainer.rs b/src/trainer.rs index 097fdfe..7c19b7f 100644 --- a/src/trainer.rs +++ b/src/trainer.rs @@ -80,6 +80,7 @@ impl Trainer { let dist = (dt * (speed / 360) as f32) as u32; data.distance = Some(dist); } + self.data.push_back(data); let _ = data_tx.send(data); } @@ -146,7 +147,7 @@ mod tests { use std::future::ready; use super::*; - use crate::ftms::MockFitnessDevice; + use crate::{config::Config, ftms::MockFitnessDevice}; use mockall::predicate; use std::pin::Pin; use tokio::sync::{broadcast, mpsc}; @@ -234,8 +235,8 @@ mod tests { io::BufReader, }; - let name = "output.fit"; - let f = File::open(name).unwrap(); + let recording_name = Config::default().get_recording_name(); + let f = File::open(recording_name.clone()).unwrap(); let br = BufReader::new(f); let mut dec = Decoder::new(br); @@ -269,8 +270,37 @@ mod tests { } } + let msg = &fit.messages[2]; + for field in msg.fields.clone() { + if field.num == mesgdef::Record::CADENCE { + assert_eq!(field.value.as_u8(), 88) + } + + if field.num == mesgdef::Record::POWER { + assert_eq!(field.value.as_u16(), 100) + } + + if field.num == mesgdef::Record::SPEED { + assert_eq!(field.value.as_u16(), 20) + } + + if field.num == mesgdef::Record::HEART_RATE { + assert_eq!(field.value.as_u8(), 80) + } + + if field.num == mesgdef::Record::RESISTANCE { + assert_eq!(field.value.as_u8(), 4) + } + + if field.num == mesgdef::Record::DISTANCE { + // FIXME: this seems wrong and likely indicates an error + // in the units we send to the FIT file for distance + assert_eq!(field.value.as_u8(), 255) + } + } + println!("{:?}", msg.fields); - assert_eq!(fit.messages.len(), 4); - let _ = remove_file("output.fit"); + assert_eq!(fit.messages.len(), 5); + let _ = remove_file(recording_name); } }