main.rs
⎇
Raw
1//! `ot`: a test client for the opentracker API.
2
3use std::path::PathBuf;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use api::{MAX_BATCH, Person, Point, Uploaded};
7use serde::{Deserialize, Serialize};
8
9const USAGE: &str = "usage: ot [--http3] [--insecure] <command>
10 use-token <url> <token> use a device token created in the web UI
11 send <lat> <lon> upload one point
12 simulate [--interval S] [--batch N] [lat lon]
13 random walk, one point every S seconds (default 2),
14 uploaded in batches of N (default 5)
15 position show this device's last position
16
17--http3 use HTTP/3 only. Needs an HTTPS reverse proxy that speaks it.
18--insecure accept any TLS certificate, for a local proxy with its own CA.
19config file: $OT_CONFIG, default ~/.config/ot/config.json";
20
21#[derive(Serialize, Deserialize)]
22struct Config {
23 url: String,
24 token: String,
25}
26
27fn config_path() -> PathBuf {
28 if let Ok(p) = std::env::var("OT_CONFIG") {
29 return p.into();
30 }
31 let base = std::env::var("XDG_CONFIG_HOME")
32 .map(PathBuf::from)
33 .unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").expect("HOME")).join(".config"));
34 base.join("ot/config.json")
35}
36
37fn load_config() -> Config {
38 let path = config_path();
39 let text = std::fs::read_to_string(&path).unwrap_or_else(|_| {
40 fail(&format!(
41 "no config at {}. Run `ot use-token` first.",
42 path.display()
43 ))
44 });
45 serde_json::from_str(&text).expect("valid config file")
46}
47
48fn save_config(c: &Config) {
49 use std::os::unix::fs::OpenOptionsExt;
50 let path = config_path();
51 std::fs::create_dir_all(path.parent().unwrap()).expect("create config dir");
52 let file = std::fs::OpenOptions::new()
53 .write(true)
54 .create(true)
55 .truncate(true)
56 .mode(0o600)
57 .open(&path)
58 .expect("write config");
59 serde_json::to_writer_pretty(file, c).expect("write config");
60 println!("saved {}", path.display());
61}
62
63fn fail(msg: &str) -> ! {
64 eprintln!("{msg}");
65 std::process::exit(1);
66}
67
68fn now() -> i64 {
69 SystemTime::now()
70 .duration_since(UNIX_EPOCH)
71 .unwrap()
72 .as_secs() as i64
73}
74
75/// Removes `--name value` from `args` and returns the value.
76fn take_opt(args: &mut Vec<String>, name: &str) -> Option<String> {
77 let i = args.iter().position(|a| a == name)?;
78 if i + 1 >= args.len() {
79 fail(&format!("{name} needs a value"));
80 }
81 args.remove(i);
82 Some(args.remove(i))
83}
84
85fn take_flag(args: &mut Vec<String>, name: &str) -> bool {
86 let found = args.iter().any(|a| a == name);
87 args.retain(|a| a != name);
88 found
89}
90
91fn num<T: std::str::FromStr>(s: &str) -> T {
92 s.parse()
93 .unwrap_or_else(|_| fail(&format!("not a number: {s}")))
94}
95
96struct Http {
97 client: reqwest::Client,
98 http3: bool,
99}
100
101impl Http {
102 fn get(&self, url: String) -> reqwest::RequestBuilder {
103 self.version(self.client.get(url))
104 }
105
106 fn post(&self, url: String) -> reqwest::RequestBuilder {
107 self.version(self.client.post(url))
108 }
109
110 /// reqwest picks HTTP/3 per request, not per client.
111 fn version(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
112 if self.http3 {
113 req.version(reqwest::Version::HTTP_3)
114 } else {
115 req
116 }
117 }
118}
119
120#[tokio::main(flavor = "current_thread")]
121async fn main() {
122 let mut args: Vec<String> = std::env::args().skip(1).collect();
123 let http3 = take_flag(&mut args, "--http3");
124 let insecure = take_flag(&mut args, "--insecure");
125 let mut builder = reqwest::Client::builder()
126 .timeout(Duration::from_secs(15))
127 .danger_accept_invalid_certs(insecure);
128 if http3 {
129 builder = builder.http3_prior_knowledge();
130 }
131 let client = Http {
132 client: builder.build().expect("HTTP client"),
133 http3,
134 };
135
136 let interval = take_opt(&mut args, "--interval").map_or(2.0, |s| num::<f64>(&s));
137 let batch = take_opt(&mut args, "--batch").map_or(5, |s| num::<usize>(&s));
138 let args: Vec<&str> = args.iter().map(String::as_str).collect();
139 match args[..] {
140 ["use-token", url, token] => save_config(&Config {
141 url: url.trim_end_matches('/').into(),
142 token: token.into(),
143 }),
144 ["send", lat, lon] => {
145 let p = Point {
146 ts: now(),
147 ..point(num(lat), num(lon))
148 };
149 let up = upload(&client, &load_config(), &[p])
150 .await
151 .unwrap_or_else(|e| fail(&e));
152 println!("stored {}, skipped {}", up.stored, up.skipped);
153 }
154 ["simulate"] => simulate(&client, 48.1372, 11.5754, interval, batch).await,
155 ["simulate", lat, lon] => simulate(&client, num(lat), num(lon), interval, batch).await,
156 ["position"] => {
157 let cfg = load_config();
158 let res = client
159 .get(format!("{}/api/device", cfg.url))
160 .bearer_auth(&cfg.token)
161 .send()
162 .await
163 .unwrap_or_else(|e| fail(&format!("{e:?}")));
164 let res = check(res).await.unwrap_or_else(|e| fail(&e));
165 print_person(&res.json().await.expect("person response"));
166 }
167 _ => fail(USAGE),
168 }
169}
170
171async fn check(res: reqwest::Response) -> Result<reqwest::Response, String> {
172 if res.status().is_success() {
173 return Ok(res);
174 }
175 let status = res.status();
176 let body = res.text().await.unwrap_or_default();
177 Err(format!("{status} {body}"))
178}
179
180async fn upload(client: &Http, cfg: &Config, points: &[Point]) -> Result<Uploaded, String> {
181 let res = client
182 .post(format!("{}/api/points", cfg.url))
183 .bearer_auth(&cfg.token)
184 .json(points)
185 .send()
186 .await
187 .map_err(|e| format!("{e:?}"))?;
188 let res = check(res).await?;
189 eprintln!("[{:?}] uploaded {} point(s)", res.version(), points.len());
190 res.json().await.map_err(|e| e.to_string())
191}
192
193fn point(lat: f64, lon: f64) -> Point {
194 Point {
195 ts: 0,
196 lat,
197 lon,
198 acc: None,
199 alt: None,
200 speed: None,
201 bearing: None,
202 battery: None,
203 }
204}
205
206fn print_person(p: &Person) {
207 {
208 match p.last() {
209 Some(d) => {
210 let l = &d.last;
211 println!(
212 "{:<16} {:<12} {:>10.6} {:>11.6} {:>5}s ago acc {:>4} bat {:>3}",
213 p.username,
214 d.name,
215 l.lat,
216 l.lon,
217 now() - l.ts,
218 l.acc.map_or("-".into(), |a| format!("{a:.0}m")),
219 l.battery.map_or("-".into(), |b| format!("{b}%")),
220 )
221 }
222 None => println!("{:<16} no position yet", p.username),
223 }
224 }
225}
226
227/// Uniform in [0, 1).
228fn rand01() -> f64 {
229 getrandom::u32().expect("OS random number generator") as f64 / (u32::MAX as f64 + 1.0)
230}
231
232async fn simulate(client: &Http, mut lat: f64, mut lon: f64, interval: f64, batch: usize) {
233 let cfg = load_config();
234 let speed = 8.0; // m/s, fast enough to see movement on the map
235 let mut heading = rand01() * 360.0;
236 let mut pending: Vec<Point> = Vec::new();
237 let start = now();
238 let mut tick = tokio::time::interval(Duration::from_secs_f64(interval));
239 loop {
240 tick.tick().await;
241 heading = (heading + (rand01() - 0.5) * 40.0).rem_euclid(360.0);
242 let dist = speed * interval;
243 lat += dist * heading.to_radians().cos() / 111_320.0;
244 lon += dist * heading.to_radians().sin() / (111_320.0 * lat.to_radians().cos());
245 pending.push(Point {
246 ts: now(),
247 acc: Some(5.0 + rand01() as f32 * 10.0),
248 speed: Some(speed as f32),
249 bearing: Some(heading as f32),
250 battery: Some((100 - (now() - start) / 60 % 100) as u8),
251 ..point(lat, lon)
252 });
253 // Several points can share one second when the interval is short. The server keeps the first.
254 pending.dedup_by_key(|p| p.ts);
255 if pending.len() < batch {
256 continue;
257 }
258 match upload(client, &cfg, &pending).await {
259 Ok(_) => pending.clear(),
260 Err(e) => {
261 eprintln!("upload failed, keeping {} point(s): {e}", pending.len());
262 if pending.len() > MAX_BATCH {
263 pending.drain(..pending.len() - MAX_BATCH);
264 }
265 }
266 }
267 }
268}
269