Skip to main content

qtmonitor_fixture_capture/
qtmonitor-fixture-capture.rs

1//! `qtmonitor-fixture-capture`: capture qtmonitor fake-tag fixture data from
2//! an attached tag.
3//!
4//! Port of `host/commandline/qtmonitor-fixture-capture.cc`. Reads the monitor
5//! information, configuration, status, and voltage responses used by
6//! qtmonitor and serializes them as protobuf JSON inside the documentation
7//! fixture schema (`host/docs/fixtures/qtmonitor/`). The tool is
8//! intentionally read-only: it does not start, stop, erase, test, or
9//! otherwise drive tag state.
10//!
11//! Usage: `qtmonitor-fixture-capture [-d] [-b BUS:DEVICE] [-o PATH|-]
12//! [--id ID] [--label TEXT] [--notes TEXT] [--fallback-config PATH]
13//! [--status-name NAME | --state NAME] [--no-config] [--sanitize]
14//! [--print-summary]`. The output defaults to `qtmonitor-fixture.json`, and
15//! its parent directories are created. `--capture-default-config` is
16//! accepted and does nothing, since capturing the configuration is already
17//! the default. Unlike the other tools it does not use
18//! [`tag_tools::BaseArgs`]: with several bases and no `--base` it lists them
19//! rather than printing help.
20//!
21//! `sectors_erased` is always cleared from the status unless the tag is in
22//! S_RESET; `--sanitize` additionally replaces the chip UUID with zeros and
23//! `source_path` with a path derived from `--id` or the tag type.
24//!
25//! # Exit status
26//!
27//! 0 once the fixture is written; 1 if no single base is selected, or the
28//! attach, an info, config, status or voltage read, or the write fails.
29//! Option errors exit 2 (clap).
30//!
31//! # Hardware side effects
32//!
33//! "Read-only" means no request changes tag state; the attach itself still
34//! resets the tag, so the status captured is the one after that reset.
35
36use 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/// capture qtmonitor fake-tag fixture data from an attached tag
49#[derive(Parser)]
50#[command(name = "qtmonitor-fixture-capture")]
51struct Cli {
52    /// Select bus:device
53    #[arg(short, long, value_name = "BUS:DEVICE")]
54    base: Option<String>,
55
56    /// Set log level to DEBUG
57    #[arg(short, long)]
58    debug: bool,
59
60    /// Output fixture JSON path, or '-' for stdout
61    #[arg(short, long, default_value = "qtmonitor-fixture.json")]
62    output: String,
63
64    /// Fixture id; defaults to the captured tag type
65    #[arg(long)]
66    id: Option<String>,
67
68    /// Human-readable fixture label; defaults to the tag type
69    #[arg(long)]
70    label: Option<String>,
71
72    /// Optional fixture notes
73    #[arg(long)]
74    notes: Option<String>,
75
76    /// Source-tree default config path kept as fallback_ref
77    #[arg(long)]
78    fallback_config: Option<String>,
79
80    /// Name for the captured status slot
81    #[arg(long, default_value = "current")]
82    status_name: String,
83
84    /// Alias for --status-name; does not change tag state
85    #[arg(long)]
86    state: Option<String>,
87
88    /// Capture current GetConfig() as the fixture's default config; this is
89    /// the default
90    #[arg(long)]
91    capture_default_config: bool,
92
93    /// Do not include the captured Config message
94    #[arg(long)]
95    no_config: bool,
96
97    /// Replace hardware UUID and local source path placeholders
98    #[arg(long)]
99    sanitize: bool,
100
101    /// Print captured tag summary to stderr
102    #[arg(long)]
103    print_summary: bool,
104}
105
106/// The options after parsing, as the C++ `CaptureOptions` holds them.
107struct Capture {
108    /// The `--base` value, or empty to use the only base connected.
109    base: String,
110    /// Output path, or `-` for stdout.
111    output: String,
112    /// Fixture id, or empty to derive it from the tag type.
113    id: String,
114    /// Fixture label, or empty to use the tag type name.
115    label: String,
116    /// Free-text notes; omitted from the fixture when empty.
117    notes: String,
118    /// Source-tree default config path written as `fallback_ref`; omitted when
119    /// empty.
120    fallback_config: String,
121    /// Key under `statuses` for the captured status.
122    status_name: String,
123    /// Whether to read and include the tag's configuration (false only with
124    /// `--no-config`).
125    capture_default_config: bool,
126    /// Whether to replace the UUID and source path placeholders.
127    sanitize: bool,
128    /// Whether to print a summary to stderr after writing.
129    print_summary: bool,
130    /// Whether to log at DEBUG; otherwise logging is off.
131    debug: bool,
132}
133
134impl From<Cli> for Capture {
135    /// Folds the parsed options into a [`Capture`]; `--state` overrides
136    /// `--status-name`.
137    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            // --state wins over --status-name, as the C++ reads it second.
146            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
155/// Protobuf enum names as stable lower-kebab fixture ids.
156fn 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
166/// Escapes a string for the hand-written JSON envelope.
167fn 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
185/// The current UTC time as ISO-8601, e.g. `2026-10-26T12:01:09Z`.
186fn utc_timestamp() -> String {
187    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
188}
189
190/// The protobuf name of a tag type number, such as `"BITTAG_LE"`, or the
191/// number itself when it has no name.
192fn 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
198/// Serialises a message in the fixture JSON convention: the C++
199/// `MessageToJsonString` with `add_whitespace`, `preserve_proto_field_names`
200/// and always-print-fields-without-presence.
201///
202/// One space of indent per level, fields in field-number order, every field
203/// without presence printed even at its default, unset messages and unset
204/// `optional`/oneof fields omitted, 64-bit integers as strings, enums by
205/// name, floats as the C++ library writes them. Ends with a newline, as the
206/// C++ output does.
207fn 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
214/// The indent for nesting `level`: one space per level, as the C++ JSON
215/// printer writes it.
216fn indent(level: usize) -> String {
217    " ".repeat(level)
218}
219
220/// Appends `msg` as a JSON object at nesting `level`, following the field
221/// rules of [`message_to_json`]; a message with no printable fields is `{}`.
222fn 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
247/// Appends one field value as JSON at nesting `level`.
248///
249/// `kind` is the field's type, used to name enum values (an unknown number
250/// prints as a bare number) and for list elements. Maps print as `{}`,
251/// since `tag.proto` has none.
252fn 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("{}"), // tag.proto has no map fields
294    }
295}
296
297/// A float as proto3 JSON written by the C++ library: `SimpleFtoa` /
298/// `SimpleDtoa` digits (`short`, else `long` significant digits when that
299/// does not round-trip), non-finite values as the strings proto3 requires.
300fn 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
315/// Standard base64 with padding, as proto3 JSON writes `bytes`.
316fn 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
337/// Chooses the base, requiring --base when more than one is found.
338fn 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
367/// Replaces hardware- and workstation-specific TagInfo fields.
368fn 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
380/// Clears `sectors_erased` unless the tag is mid-reset, where it means
381/// something.
382fn sanitize_status(status: &mut Status) {
383    if status.state != TagState::SReset as i32 {
384        status.sectors_erased = 0;
385    }
386}
387
388/// Composes the fixture and writes it to the output path, or stdout for `-`.
389fn 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
472/// Prints the tag type, firmware, git hash, captured state, flash page
473/// counts, voltage (V) and output path to stderr.
474fn 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
492/// Selects a base, attaches, reads the four responses and writes the
493/// fixture.
494fn main() -> ExitCode {
495    let capture = Capture::from(Cli::parse());
496    // Quiet unless --debug.
497    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)]
558/// Unit tests for the fixture JSON writer and its helpers.
559mod tests {
560    use super::*;
561    use tagcore::proto::config;
562
563    #[test]
564    /// Checks indentation, field order, enum names and omission of unset
565    /// messages against the layout of the checked-in fixtures.
566    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        // period (7) follows bittag_log (4); unset messages are omitted.
581        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    /// Checks an explicit-presence field prints only when set, even at zero,
589    /// and that 64-bit integers print as strings.
590    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    /// Checks kebab-casing, JSON escaping and base64 padding.
602    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}