1use std::fmt::Write as _;
37use std::path::Path;
38use std::process::ExitCode;
39
40use clap::Parser;
41use log::LevelFilter;
42use prost_reflect::{DynamicMessage, Kind, ReflectMessage, Value};
43use tag_tools::{exit_on_interrupt, init_logging_at, parse_bus_device, state_name};
44use tagcore::proto::{Config, Status, TagInfo, TagState, TagType};
45use tagcore::textformat::format_g;
46use tagcore::{Tag, UsbDev};
47
48#[derive(Parser)]
50#[command(name = "qtmonitor-fixture-capture")]
51struct Cli {
52 #[arg(short, long, value_name = "BUS:DEVICE")]
54 base: Option<String>,
55
56 #[arg(short, long)]
58 debug: bool,
59
60 #[arg(short, long, default_value = "qtmonitor-fixture.json")]
62 output: String,
63
64 #[arg(long)]
66 id: Option<String>,
67
68 #[arg(long)]
70 label: Option<String>,
71
72 #[arg(long)]
74 notes: Option<String>,
75
76 #[arg(long)]
78 fallback_config: Option<String>,
79
80 #[arg(long, default_value = "current")]
82 status_name: String,
83
84 #[arg(long)]
86 state: Option<String>,
87
88 #[arg(long)]
91 capture_default_config: bool,
92
93 #[arg(long)]
95 no_config: bool,
96
97 #[arg(long)]
99 sanitize: bool,
100
101 #[arg(long)]
103 print_summary: bool,
104}
105
106struct Capture {
108 base: String,
110 output: String,
112 id: String,
114 label: String,
116 notes: String,
118 fallback_config: String,
121 status_name: String,
123 capture_default_config: bool,
126 sanitize: bool,
128 print_summary: bool,
130 debug: bool,
132}
133
134impl From<Cli> for Capture {
135 fn from(cli: Cli) -> Self {
138 Self {
139 base: cli.base.unwrap_or_default(),
140 output: cli.output,
141 id: cli.id.unwrap_or_default(),
142 label: cli.label.unwrap_or_default(),
143 notes: cli.notes.unwrap_or_default(),
144 fallback_config: cli.fallback_config.unwrap_or_default(),
145 status_name: cli.state.unwrap_or(cli.status_name),
147 capture_default_config: !cli.no_config,
148 sanitize: cli.sanitize,
149 print_summary: cli.print_summary,
150 debug: cli.debug,
151 }
152 }
153}
154
155fn lower_kebab(value: &str) -> String {
157 value
158 .chars()
159 .map(|c| match c {
160 '_' | ' ' => '-',
161 c => c.to_ascii_lowercase(),
162 })
163 .collect()
164}
165
166fn json_escape(value: &str) -> String {
168 let mut out = String::new();
169 for c in value.chars() {
170 match c {
171 '"' => out.push_str("\\\""),
172 '\\' => out.push_str("\\\\"),
173 '\u{8}' => out.push_str("\\b"),
174 '\u{c}' => out.push_str("\\f"),
175 '\n' => out.push_str("\\n"),
176 '\r' => out.push_str("\\r"),
177 '\t' => out.push_str("\\t"),
178 c if (c as u32) < 0x20 => write!(out, "\\u{:04x}", c as u32).unwrap(),
179 c => out.push(c),
180 }
181 }
182 out
183}
184
185fn utc_timestamp() -> String {
187 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
188}
189
190fn tag_type_name(tag_type: i32) -> String {
193 TagType::try_from(tag_type)
194 .map(|t| t.as_str_name().to_owned())
195 .unwrap_or_else(|_| tag_type.to_string())
196}
197
198fn message_to_json<M: ReflectMessage>(msg: &M) -> String {
208 let mut out = String::new();
209 write_message(&msg.transcode_to_dynamic(), 0, &mut out);
210 out.push('\n');
211 out
212}
213
214fn indent(level: usize) -> String {
217 " ".repeat(level)
218}
219
220fn write_message(msg: &DynamicMessage, level: usize, out: &mut String) {
223 let mut fields: Vec<_> = msg
224 .descriptor()
225 .fields()
226 .filter(|f| f.is_list() || f.is_map() || !f.supports_presence() || msg.has_field(f))
227 .collect();
228 if fields.is_empty() {
229 out.push_str("{}");
230 return;
231 }
232 fields.sort_by_key(|f| f.number());
233 out.push_str("{\n");
234 for (i, field) in fields.iter().enumerate() {
235 out.push_str(&indent(level + 1));
236 write!(out, "\"{}\": ", json_escape(field.name())).unwrap();
237 write_value(&field.kind(), &msg.get_field(field), level + 1, out);
238 if i + 1 < fields.len() {
239 out.push(',');
240 }
241 out.push('\n');
242 }
243 out.push_str(&indent(level));
244 out.push('}');
245}
246
247fn write_value(kind: &Kind, value: &Value, level: usize, out: &mut String) {
253 match value {
254 Value::Bool(b) => write!(out, "{b}").unwrap(),
255 Value::I32(v) => write!(out, "{v}").unwrap(),
256 Value::U32(v) => write!(out, "{v}").unwrap(),
257 Value::I64(v) => write!(out, "\"{v}\"").unwrap(),
258 Value::U64(v) => write!(out, "\"{v}\"").unwrap(),
259 Value::F32(v) => {
260 let v = *v;
261 out.push_str(&json_float(f64::from(v), 6, 9, |s| {
262 s.parse::<f32>().ok() == Some(v)
263 }))
264 }
265 Value::F64(v) => {
266 let v = *v;
267 out.push_str(&json_float(v, 15, 17, |s| s.parse::<f64>().ok() == Some(v)))
268 }
269 Value::String(s) => write!(out, "\"{}\"", json_escape(s)).unwrap(),
270 Value::Bytes(b) => write!(out, "\"{}\"", base64(b)).unwrap(),
271 Value::EnumNumber(n) => match kind.as_enum().and_then(|e| e.get_value(*n)) {
272 Some(v) => write!(out, "\"{}\"", v.name()).unwrap(),
273 None => write!(out, "{n}").unwrap(),
274 },
275 Value::Message(m) => write_message(m, level, out),
276 Value::List(items) => {
277 if items.is_empty() {
278 out.push_str("[]");
279 return;
280 }
281 out.push_str("[\n");
282 for (i, item) in items.iter().enumerate() {
283 out.push_str(&indent(level + 1));
284 write_value(kind, item, level + 1, out);
285 if i + 1 < items.len() {
286 out.push(',');
287 }
288 out.push('\n');
289 }
290 out.push_str(&indent(level));
291 out.push(']');
292 }
293 Value::Map(_) => out.push_str("{}"), }
295}
296
297fn json_float(v: f64, short: usize, long: usize, round_trips: impl Fn(&str) -> bool) -> String {
301 if v.is_nan() {
302 return "\"NaN\"".into();
303 }
304 if v.is_infinite() {
305 return if v > 0.0 {
306 "\"Infinity\""
307 } else {
308 "\"-Infinity\""
309 }
310 .into();
311 }
312 format_g(v, short, long, round_trips)
313}
314
315fn base64(data: &[u8]) -> String {
317 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
318 let mut out = String::new();
319 for chunk in data.chunks(3) {
320 let b = [
321 chunk[0],
322 *chunk.get(1).unwrap_or(&0),
323 *chunk.get(2).unwrap_or(&0),
324 ];
325 let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
326 for i in 0..4 {
327 if i <= chunk.len() {
328 out.push(TABLE[((n >> (18 - 6 * i)) & 63) as usize] as char);
329 } else {
330 out.push('=');
331 }
332 }
333 }
334 out
335}
336
337fn select_device(tag: &Tag, capture: &Capture) -> Option<UsbDev> {
339 let devs = tag.available();
340 if devs.is_empty() {
341 eprintln!("No tag bases found");
342 return None;
343 }
344
345 if !capture.base.is_empty() {
346 let Some((bus, address)) = parse_bus_device(&capture.base) else {
347 eprintln!("Invalid --base value; expected bus:device");
348 return None;
349 };
350 if let Some(dev) = devs.iter().find(|d| d.bus == bus && d.address == address) {
351 return Some(dev.clone());
352 }
353 eprintln!("No matching device {}", capture.base);
354 return None;
355 }
356
357 if devs.len() > 1 {
358 eprintln!("Multiple tag bases found; select one with --base:");
359 for d in &devs {
360 eprintln!(" {}:{} 0x{:x}:0x{:x}", d.bus, d.address, d.vid, d.pid);
361 }
362 return None;
363 }
364 Some(devs[0].clone())
365}
366
367fn sanitize_info(info: &mut TagInfo, id: &str) {
369 info.uuid = "000000000000000000000000".into();
370 info.source_path = if id.is_empty() {
371 format!(
372 "/embedded/tags/{}",
373 lower_kebab(&tag_type_name(info.tag_type))
374 )
375 } else {
376 format!("/embedded/tags/{id}")
377 };
378}
379
380fn sanitize_status(status: &mut Status) {
383 if status.state != TagState::SReset as i32 {
384 status.sectors_erased = 0;
385 }
386}
387
388fn write_fixture(
390 capture: &Capture,
391 info: &TagInfo,
392 config: Option<&Config>,
393 status: &Status,
394 voltage: f32,
395) -> bool {
396 let info_json = message_to_json(info);
397 let config_json = config.map(message_to_json);
398 let status_json = message_to_json(status);
399
400 let type_name = tag_type_name(info.tag_type);
401 let id = if capture.id.is_empty() {
402 lower_kebab(&type_name)
403 } else {
404 capture.id.clone()
405 };
406 let label = if capture.label.is_empty() {
407 type_name
408 } else {
409 capture.label.clone()
410 };
411
412 let mut out = String::from("{\n");
413 out += " \"schema\": \"tag-designs.qtmonitor.fake-tag.v1\",\n";
414 writeln!(out, " \"id\": \"{}\",", json_escape(&id)).unwrap();
415 writeln!(out, " \"label\": \"{}\",", json_escape(&label)).unwrap();
416 if !capture.notes.is_empty() {
417 writeln!(out, " \"notes\": \"{}\",", json_escape(&capture.notes)).unwrap();
418 }
419 writeln!(out, " \"captured_at_utc\": \"{}\",", utc_timestamp()).unwrap();
420 writeln!(out, " \"info\": {info_json},").unwrap();
421 if let Some(config_json) = config_json {
422 out += " \"config\": {\n";
423 out += " \"source\": \"captured-default\"";
424 if !capture.fallback_config.is_empty() {
425 write!(
426 out,
427 ",\n \"fallback_ref\": \"{}\"",
428 json_escape(&capture.fallback_config)
429 )
430 .unwrap();
431 }
432 writeln!(out, ",\n \"value\": {config_json}").unwrap();
433 out += " },\n";
434 }
435 writeln!(out, " \"voltage\": {:.3},", f64::from(voltage)).unwrap();
436 out += " \"statuses\": {\n";
437 writeln!(
438 out,
439 " \"{}\": {status_json}",
440 json_escape(&capture.status_name)
441 )
442 .unwrap();
443 out += " }\n";
444 out += "}\n";
445
446 if capture.output == "-" {
447 print!("{out}");
448 return true;
449 }
450
451 let path = Path::new(&capture.output);
452 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty())
453 && let Err(e) = std::fs::create_dir_all(parent)
454 {
455 eprintln!("Could not create output directory {parent:?}: {e}");
456 return false;
457 }
458 let mut file = match std::fs::File::create(path) {
459 Ok(file) => file,
460 Err(_) => {
461 eprintln!("Could not open {} for writing", capture.output);
462 return false;
463 }
464 };
465 if std::io::Write::write_all(&mut file, out.as_bytes()).is_err() {
466 eprintln!("Could not write {}", capture.output);
467 return false;
468 }
469 true
470}
471
472fn print_summary(capture: &Capture, info: &TagInfo, status: &Status, voltage: f32) {
475 eprintln!("Captured qtmonitor fixture");
476 eprintln!(" tag_type: {}", tag_type_name(info.tag_type));
477 eprintln!(" firmware: {}", info.firmware);
478 eprintln!(" githash: {}", info.githash);
479 eprintln!(
480 " state[{}]: {}",
481 capture.status_name,
482 state_name(status.state)
483 );
484 eprintln!(
485 " counts: internal={} external={}",
486 status.internal_data_count, status.external_data_count
487 );
488 eprintln!(" voltage: {:.3}", f64::from(voltage));
489 eprintln!(" output: {}", capture.output);
490}
491
492fn main() -> ExitCode {
495 let capture = Capture::from(Cli::parse());
496 init_logging_at(if capture.debug {
498 LevelFilter::Debug
499 } else {
500 LevelFilter::Off
501 });
502
503 let tag = Tag::new();
504 let Some(dev) = select_device(&tag, &capture) else {
505 return ExitCode::FAILURE;
506 };
507 if !tag.attach(&dev) {
508 eprintln!("Attach failed");
509 return ExitCode::FAILURE;
510 }
511 exit_on_interrupt();
512
513 let mut info = match tag.tag_info() {
514 Ok(info) => info,
515 Err(_) => {
516 eprintln!("Could not read tag info: {}", tag.debug_message());
517 return ExitCode::FAILURE;
518 }
519 };
520 let config = if capture.capture_default_config {
521 match tag.config() {
522 Ok(config) => Some(config),
523 Err(_) => {
524 eprintln!("Could not read tag config: {}", tag.debug_message());
525 return ExitCode::FAILURE;
526 }
527 }
528 } else {
529 None
530 };
531 let mut status = match tag.status() {
532 Ok(status) => status,
533 Err(_) => {
534 eprintln!("Could not read tag status: {}", tag.debug_message());
535 return ExitCode::FAILURE;
536 }
537 };
538 let Some(voltage) = tag.voltage() else {
539 eprintln!("Could not read tag voltage");
540 return ExitCode::FAILURE;
541 };
542
543 if capture.sanitize {
544 sanitize_info(&mut info, &capture.id);
545 }
546 sanitize_status(&mut status);
547
548 if !write_fixture(&capture, &info, config.as_ref(), &status, voltage) {
549 return ExitCode::FAILURE;
550 }
551 if capture.print_summary {
552 print_summary(&capture, &info, &status, voltage);
553 }
554 ExitCode::SUCCESS
555}
556
557#[cfg(test)]
558mod tests {
560 use super::*;
561 use tagcore::proto::config;
562
563 #[test]
564 fn json_matches_the_checked_in_fixture_layout() {
567 let cfg = Config {
568 tag_type: TagType::BittagLe as i32,
569 active_interval: Some(config::Interval {
570 start_epoch: 0,
571 end_epoch: 2147483647,
572 }),
573 hibernate: vec![config::Interval::default(); 2],
574 ..Default::default()
575 };
576 let json = message_to_json(&cfg);
577 assert!(json.starts_with(
578 "{\n \"tag_type\": \"BITTAG_LE\",\n \"active_interval\": {\n \"start_epoch\": 0,\n \"end_epoch\": 2147483647\n },\n \"hibernate\": [\n {\n \"start_epoch\": 0,\n"
579 ));
580 assert!(json.contains(
582 "\"bittag_log\": \"BITTAG_UNSPECIFIED\",\n \"period\": 0,\n \"start_delay\": 0\n}\n"
583 ));
584 assert!(!json.contains("adxl362"));
585 }
586
587 #[test]
588 fn optional_fields_print_only_when_set() {
591 let mut info = TagInfo::default();
592 assert!(!message_to_json(&info).contains("ppm_clock_error"));
593 info.ppm_clock_error = Some(0.0);
594 info.intflashsz = 256;
595 let json = message_to_json(&info);
596 assert!(json.contains("\"ppm_clock_error\": 0\n"));
597 assert!(json.contains("\"intflashsz\": \"256\""));
598 }
599
600 #[test]
601 fn helpers() {
603 assert_eq!(lower_kebab("BITTAG_LE"), "bittag-le");
604 assert_eq!(json_escape("a\"\n\u{1}"), "a\\\"\\n\\u0001");
605 assert_eq!(base64(b"Man"), "TWFu");
606 assert_eq!(base64(b"Ma"), "TWE=");
607 assert_eq!(base64(b"M"), "TQ==");
608 }
609}