//! `ot`: a test client for the opentracker API. use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use api::{MAX_BATCH, Person, Point, Uploaded}; use serde::{Deserialize, Serialize}; const USAGE: &str = "usage: ot [--http3] [--insecure] use-token use a device token created in the web UI send upload one point simulate [--interval S] [--batch N] [lat lon] random walk, one point every S seconds (default 2), uploaded in batches of N (default 5) position show this device's last position --http3 use HTTP/3 only. Needs an HTTPS reverse proxy that speaks it. --insecure accept any TLS certificate, for a local proxy with its own CA. config file: $OT_CONFIG, default ~/.config/ot/config.json"; #[derive(Serialize, Deserialize)] struct Config { url: String, token: String, } fn config_path() -> PathBuf { if let Ok(p) = std::env::var("OT_CONFIG") { return p.into(); } let base = std::env::var("XDG_CONFIG_HOME") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").expect("HOME")).join(".config")); base.join("ot/config.json") } fn load_config() -> Config { let path = config_path(); let text = std::fs::read_to_string(&path).unwrap_or_else(|_| { fail(&format!( "no config at {}. Run `ot use-token` first.", path.display() )) }); serde_json::from_str(&text).expect("valid config file") } fn save_config(c: &Config) { use std::os::unix::fs::OpenOptionsExt; let path = config_path(); std::fs::create_dir_all(path.parent().unwrap()).expect("create config dir"); let file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(&path) .expect("write config"); serde_json::to_writer_pretty(file, c).expect("write config"); println!("saved {}", path.display()); } fn fail(msg: &str) -> ! { eprintln!("{msg}"); std::process::exit(1); } fn now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() as i64 } /// Removes `--name value` from `args` and returns the value. fn take_opt(args: &mut Vec, name: &str) -> Option { let i = args.iter().position(|a| a == name)?; if i + 1 >= args.len() { fail(&format!("{name} needs a value")); } args.remove(i); Some(args.remove(i)) } fn take_flag(args: &mut Vec, name: &str) -> bool { let found = args.iter().any(|a| a == name); args.retain(|a| a != name); found } fn num(s: &str) -> T { s.parse() .unwrap_or_else(|_| fail(&format!("not a number: {s}"))) } struct Http { client: reqwest::Client, http3: bool, } impl Http { fn get(&self, url: String) -> reqwest::RequestBuilder { self.version(self.client.get(url)) } fn post(&self, url: String) -> reqwest::RequestBuilder { self.version(self.client.post(url)) } /// reqwest picks HTTP/3 per request, not per client. fn version(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { if self.http3 { req.version(reqwest::Version::HTTP_3) } else { req } } } #[tokio::main(flavor = "current_thread")] async fn main() { let mut args: Vec = std::env::args().skip(1).collect(); let http3 = take_flag(&mut args, "--http3"); let insecure = take_flag(&mut args, "--insecure"); let mut builder = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .danger_accept_invalid_certs(insecure); if http3 { builder = builder.http3_prior_knowledge(); } let client = Http { client: builder.build().expect("HTTP client"), http3, }; let interval = take_opt(&mut args, "--interval").map_or(2.0, |s| num::(&s)); let batch = take_opt(&mut args, "--batch").map_or(5, |s| num::(&s)); let args: Vec<&str> = args.iter().map(String::as_str).collect(); match args[..] { ["use-token", url, token] => save_config(&Config { url: url.trim_end_matches('/').into(), token: token.into(), }), ["send", lat, lon] => { let p = Point { ts: now(), ..point(num(lat), num(lon)) }; let up = upload(&client, &load_config(), &[p]) .await .unwrap_or_else(|e| fail(&e)); println!("stored {}, skipped {}", up.stored, up.skipped); } ["simulate"] => simulate(&client, 48.1372, 11.5754, interval, batch).await, ["simulate", lat, lon] => simulate(&client, num(lat), num(lon), interval, batch).await, ["position"] => { let cfg = load_config(); let res = client .get(format!("{}/api/device", cfg.url)) .bearer_auth(&cfg.token) .send() .await .unwrap_or_else(|e| fail(&format!("{e:?}"))); let res = check(res).await.unwrap_or_else(|e| fail(&e)); print_person(&res.json().await.expect("person response")); } _ => fail(USAGE), } } async fn check(res: reqwest::Response) -> Result { if res.status().is_success() { return Ok(res); } let status = res.status(); let body = res.text().await.unwrap_or_default(); Err(format!("{status} {body}")) } async fn upload(client: &Http, cfg: &Config, points: &[Point]) -> Result { let res = client .post(format!("{}/api/points", cfg.url)) .bearer_auth(&cfg.token) .json(points) .send() .await .map_err(|e| format!("{e:?}"))?; let res = check(res).await?; eprintln!("[{:?}] uploaded {} point(s)", res.version(), points.len()); res.json().await.map_err(|e| e.to_string()) } fn point(lat: f64, lon: f64) -> Point { Point { ts: 0, lat, lon, acc: None, alt: None, speed: None, bearing: None, battery: None, } } fn print_person(p: &Person) { { match p.last() { Some(d) => { let l = &d.last; println!( "{:<16} {:<12} {:>10.6} {:>11.6} {:>5}s ago acc {:>4} bat {:>3}", p.username, d.name, l.lat, l.lon, now() - l.ts, l.acc.map_or("-".into(), |a| format!("{a:.0}m")), l.battery.map_or("-".into(), |b| format!("{b}%")), ) } None => println!("{:<16} no position yet", p.username), } } } /// Uniform in [0, 1). fn rand01() -> f64 { getrandom::u32().expect("OS random number generator") as f64 / (u32::MAX as f64 + 1.0) } async fn simulate(client: &Http, mut lat: f64, mut lon: f64, interval: f64, batch: usize) { let cfg = load_config(); let speed = 8.0; // m/s, fast enough to see movement on the map let mut heading = rand01() * 360.0; let mut pending: Vec = Vec::new(); let start = now(); let mut tick = tokio::time::interval(Duration::from_secs_f64(interval)); loop { tick.tick().await; heading = (heading + (rand01() - 0.5) * 40.0).rem_euclid(360.0); let dist = speed * interval; lat += dist * heading.to_radians().cos() / 111_320.0; lon += dist * heading.to_radians().sin() / (111_320.0 * lat.to_radians().cos()); pending.push(Point { ts: now(), acc: Some(5.0 + rand01() as f32 * 10.0), speed: Some(speed as f32), bearing: Some(heading as f32), battery: Some((100 - (now() - start) / 60 % 100) as u8), ..point(lat, lon) }); // Several points can share one second when the interval is short. The server keeps the first. pending.dedup_by_key(|p| p.ts); if pending.len() < batch { continue; } match upload(client, &cfg, &pending).await { Ok(_) => pending.clear(), Err(e) => { eprintln!("upload failed, keeping {} point(s): {e}", pending.len()); if pending.len() > MAX_BATCH { pending.drain(..pending.len() - MAX_BATCH); } } } } }