Skip to main content

tag_dwnld/
tag-dwnld.rs

1//! `tag-dwnld`: download a stopped tag's log into a SQLite database.
2//!
3//! Port of `host/commandline/dwnld.cc`. The tag side uses tagcore-rs; the
4//! database is written by the C++ writer (`SqliteTagLogWriter`) through the
5//! `taglogwriter` crate and the `tagcore_c` library (static on macOS, shared
6//! on Linux and Windows), so the file is the one the C++ `tag-dwnld` writes
7//! from the same tag: byte for byte, on the IMUTag and UIUCTag downloads it
8//! was checked with.
9//!
10//! The sequence, as in the C++:
11//!
12//! 1. Check `--format` before attaching, since attaching resets the tag.
13//! 2. Attach, then poll status until the tag reports a definite state.
14//! 3. With `--rescue-exception`, turn an old BitTag's EXCEPTION into
15//!    ABORTED; with `--stop`, stop a RUNNING tag and poll until it stops.
16//! 4. Refuse unless the tag is FINISHED, ABORTED or EXCEPTION.
17//! 5. Read the config, open the database, and write the header from the
18//!    same requests, in the same order, as `readTagLogHeader()`.
19//! 6. Request pages from index 0. The writer decodes each one and says how
20//!    many records it consumed; the next request is at the index advanced
21//!    by that, and a page that consumes nothing ends the log. A NODATA page
22//!    below the tag's record count is skipped over.
23//!
24//! Exits 0 when the log is written (or there was nothing to download), 1 on
25//! any failure, 2 on an option error (clap).
26//!
27//! Built only with the `sqlitelog` feature, which needs `libtagcore_c`; see
28//! `host/libraries/taglogwriter-rs/README.md`.
29//!
30//! # Hardware side effects
31//!
32//! The attach resets the tag; `--stop` ends a run; `--rescue-exception`
33//! rewrites one backup-state word. Downloading itself changes nothing on the
34//! tag, so a download can be repeated.
35//!
36//! # Differences from the C++
37//!
38//! - An unsupported tag type is reported by the writer when the database is
39//!   created ("Could not open output: SQLite log output does not support tag
40//!   type ..."), not by a separate check first. The writer refuses before
41//!   touching the file, so the effect is the same, and the list of supported
42//!   types stays in one place.
43//! - Durations are printed at full precision (`0.022837829`) rather than
44//!   C++ stream precision (`0.0233391`).
45//! - Option errors exit 2 (clap) rather than 1, as for every Rust tool.
46//!
47//! # Maintaining
48//!
49//! Keep this in step with `dwnld.cc`: same options, messages, exit status
50//! and request sequence, since scripts and the bench procedures use either
51//! tool. Two parts must match the C++ exactly, because they decide what is
52//! read from the tag:
53//!
54//! - [`read_header`] mirrors `readTagLogHeader()`: the same requests, in the
55//!   same order, and the same ways of stopping (a refused calibration read;
56//!   a refused or empty state-log chunk).
57//! - The page loop: the index advances by what the writer consumed, a
58//!   NODATA page below the record count advances it by one, and the loop
59//!   ends when the writer consumes nothing.
60//!
61//! To check a change, download the same finished tag with both tools and
62//! `cmp` the databases; downloading does not change the tag.
63
64use std::io::Write as _;
65use std::process::ExitCode;
66use std::thread::sleep;
67use std::time::{Duration, Instant};
68
69use clap::Parser;
70use tag_tools::{BaseArgs, exit_on_interrupt, init_logging, select_base, state_name};
71use tagcore::proto::{self, Ack, Status, TagState, TagType, ack};
72use tagcore::{LinkAdaptStats, Tag, TagMonitorStats};
73use taglogwriter::{LogHeader, LogWriter};
74
75/// Downloads data from a tag
76#[derive(Parser)]
77#[command(name = "tag-dwnld")]
78struct Cli {
79    /// The shared `--debug` and `--base` options.
80    #[command(flatten)]
81    base: BaseArgs,
82
83    /// Output file path. Defaults to tag-download.db3
84    #[arg(short, long, value_name = "PATH")]
85    output: Option<String>,
86
87    /// Output format: default or sqlite (the text format is retired)
88    #[arg(short, long, default_value = "default")]
89    format: String,
90
91    /// Stop a running tag before downloading
92    #[arg(short, long)]
93    stop: bool,
94
95    /// For old firmware only: change EXCEPTION backup state to ABORTED before
96    /// downloading
97    #[arg(long)]
98    rescue_exception: bool,
99
100    /// Print download timing and link transport profile
101    #[arg(long)]
102    profile: bool,
103
104    /// Seconds to wait after attach for the tag to report a definite state.
105    /// Attach connects under reset, so the first status can legitimately be
106    /// STATE_UNSPECIFIED while the tag boots
107    #[arg(long, default_value_t = 10, value_name = "SECONDS")]
108    settle_timeout: u64,
109
110    /// With --stop, seconds to wait for the tag to reach FINISHED or ABORTED.
111    /// Stop() returns when the request is accepted; the state machine acts on
112    /// it later
113    #[arg(long, default_value_t = 30, value_name = "SECONDS")]
114    stop_timeout: u64,
115}
116
117/// Prints `message` to stderr and returns exit status 1.
118fn fail(message: impl std::fmt::Display) -> ExitCode {
119    eprintln!("{message}");
120    ExitCode::FAILURE
121}
122
123/// Checks `--format` without touching the tag: `Ok` for SQLite (the only
124/// format), `Err` with the message otherwise.
125///
126/// # Errors
127///
128/// For the retired text format and for an unknown name.
129fn check_format(name: &str) -> Result<(), String> {
130    match name.to_lowercase().as_str() {
131        "default" | "sqlite" | "sql" | "db" | "db3" => Ok(()),
132        "text" | "txt" => Err("The text log format is retired; downloads are written as \
133                               SQLite. tag-txt-import converts existing .txt logs."
134            .into()),
135        _ => Err(format!("Unknown output format: {name}")),
136    }
137}
138
139/// Polls status a second apart, at least once, until the tag reports a
140/// state other than STATE_UNSPECIFIED, and returns that status.
141///
142/// # Errors
143///
144/// When a status read fails (with the tag's own reason), or the tag is
145/// still unsettled after `timeout_s`.
146fn settle(tag: &Tag, timeout_s: u64) -> Result<Status, String> {
147    for _ in 0..timeout_s.max(1) {
148        let status = tag
149            .status()
150            .map_err(|_| format!("Could not read tag status: {}", tag.debug_message()))?;
151        if status.state != TagState::StateUnspecified as i32 {
152            return Ok(status);
153        }
154        sleep(Duration::from_secs(1));
155    }
156    Err(format!(
157        "tag still reports STATE_UNSPECIFIED after {timeout_s} s; it is not merely settling"
158    ))
159}
160
161/// Stops a running tag and polls every 2 s, at least once, until it reaches
162/// FINISHED or ABORTED; returns the final status.
163///
164/// # Errors
165///
166/// When the stop or a status read fails, or the tag has not stopped after
167/// `timeout_s`.
168fn stop(tag: &Tag, timeout_s: u64) -> Result<Status, String> {
169    tag.stop()
170        .map_err(|_| format!("Could not stop running tag: {}", tag.debug_message()))?;
171    let mut status = Status::default();
172    for _ in 0..timeout_s.div_ceil(2).max(1) {
173        status = tag.status().map_err(|_| {
174            format!(
175                "Could not read tag status after stop: {}",
176                tag.debug_message()
177            )
178        })?;
179        if status.state == TagState::Finished as i32 || status.state == TagState::Aborted as i32 {
180            return Ok(status);
181        }
182        sleep(Duration::from_secs(2));
183    }
184    Err(format!(
185        "Tag did not reach a stopped state within {timeout_s} s; last state {}",
186        state_name(status.state)
187    ))
188}
189
190/// Whether a download may run in `state`. EXCEPTION counts: collection is
191/// no longer changing the log, and field recovery may depend on reading it.
192fn is_downloadable(state: i32) -> bool {
193    state == TagState::Finished as i32
194        || state == TagState::Aborted as i32
195        || state == TagState::Exception as i32
196}
197
198/// Reads the log header with the requests, in the order, that the C++
199/// `readTagLogHeader()` makes: Config, TagInfo, calibration constants from
200/// index 0 until one is refused, then the state log in chunks until one is
201/// refused or empty.
202///
203/// # Errors
204///
205/// When the config or the info cannot be read.
206fn read_header(tag: &Tag) -> Result<LogHeader, String> {
207    let config = tag
208        .config()
209        .map_err(|_| "Could not read tag config".to_string())?;
210    let info = tag
211        .tag_info()
212        .map_err(|_| "Could not read tag info".to_string())?;
213    let mut calibration = Vec::new();
214    while let Ok(constants) = tag.read_calibration(calibration.len() as i32) {
215        calibration.push(constants);
216    }
217    let mut states = Vec::new();
218    while let Ok(chunk) = tag.state_log(states.len() as i32) {
219        if chunk.states.is_empty() {
220            break;
221        }
222        states.extend(chunk.states);
223    }
224    Ok(LogHeader {
225        config,
226        info,
227        calibration,
228        states,
229    })
230}
231
232/// Draws the 40-column progress bar on stdout, as the C++ does.
233fn print_progress(current: usize, total: usize) {
234    const WIDTH: usize = 40;
235    let ratio = if total > 0 {
236        (current as f64 / total as f64).min(1.0)
237    } else {
238        1.0
239    };
240    let filled = (ratio * WIDTH as f64) as usize;
241    let bar: String = (0..WIDTH)
242        .map(|i| if i < filled { '#' } else { '-' })
243        .collect();
244    print!(
245        "\r[{bar}] {:>3}% {current}/{total}",
246        (ratio * 100.0) as usize
247    );
248    let _ = std::io::stdout().flush();
249}
250
251/// Prints one timing line, with the per-request figure when `count` > 0.
252fn duration_line(label: &str, total: Duration, count: u64) {
253    let seconds = total.as_secs_f64();
254    if count > 0 {
255        println!(
256            "{label}: {seconds} s total, {} s/request",
257            seconds / count as f64
258        );
259    } else {
260        println!("{label}: {seconds} s");
261    }
262}
263
264/// Prints one transfer line: calls, bytes, time and rate.
265fn transfer_line(label: &str, calls: u64, bytes: u64, ns: u64) {
266    let seconds = ns as f64 / 1e9;
267    let mut line = format!("{label}: calls={calls} bytes={bytes} time={seconds} s");
268    if ns > 0 {
269        line += &format!(" rate={} KiB/s", bytes as f64 / 1024.0 / seconds);
270    }
271    println!("{line}");
272}
273
274/// Prints `--profile`: the download's own timing, then the monitor RPC and
275/// link transport counters.
276fn print_profile(
277    link: &LinkAdaptStats,
278    monitor: &TagMonitorStats,
279    get_log: Duration,
280    write_log: Duration,
281    requests: u64,
282) {
283    let ns = |ns: u64| Duration::from_nanos(ns);
284    println!("\nDownload profile");
285    duration_line("GetDataLog wait", get_log, requests);
286    duration_line("Decode/write log", write_log, requests);
287    if !link.enabled || !monitor.enabled {
288        println!("Host link instrumentation was compiled out");
289        return;
290    }
291    println!("\nMonitor RPC");
292    println!(
293        "rpc calls={} request_bytes={} response_bytes={}",
294        monitor.rpc_calls, monitor.request_bytes, monitor.response_bytes
295    );
296    let calls = monitor.rpc_calls;
297    duration_line("rpc total", ns(monitor.rpc_total_ns), calls);
298    duration_line("serialize request", ns(monitor.serialize_ns), calls);
299    duration_line("write request buffer", ns(monitor.write_request_ns), calls);
300    duration_line("monitor call", ns(monitor.monitor_call_ns), calls);
301    duration_line("read response buffer", ns(monitor.read_response_ns), calls);
302    duration_line("parse response", ns(monitor.parse_ns), calls);
303    println!("\nLink transport");
304    transfer_line(
305        "usb out",
306        link.usb_out_calls,
307        link.usb_out_bytes,
308        link.usb_out_ns,
309    );
310    transfer_line(
311        "usb in",
312        link.usb_in_calls,
313        link.usb_in_bytes,
314        link.usb_in_ns,
315    );
316    duration_line("stlink command", ns(link.cmd_ns), link.cmd_calls);
317    transfer_line(
318        "read mem32",
319        link.read_mem_calls,
320        link.read_mem_bytes,
321        link.read_mem_ns,
322    );
323    transfer_line(
324        "write mem32",
325        link.write_mem_calls,
326        link.write_mem_bytes,
327        link.write_mem_ns,
328    );
329    duration_line(
330        "read debug32",
331        ns(link.read_debug_ns),
332        link.read_debug_calls,
333    );
334    duration_line(
335        "write debug32",
336        ns(link.write_debug_ns),
337        link.write_debug_calls,
338    );
339    duration_line("rw status", ns(link.rw_status_ns), link.rw_status_calls);
340}
341
342/// Attaches, brings the tag to a downloadable state, and writes the log.
343fn main() -> ExitCode {
344    let cli = Cli::parse();
345    init_logging(&cli.base);
346    taglogwriter::set_debug(cli.base.debug);
347
348    // Before attaching, which resets the tag: a mistyped option must not cost
349    // a running tag its run.
350    if let Err(message) = check_format(&cli.format) {
351        return fail(message);
352    }
353
354    let tag = Tag::new();
355    let dev = select_base::<Cli>(&tag, &cli.base);
356    if !tag.attach(&dev) {
357        return fail("Attach failed");
358    }
359    exit_on_interrupt();
360
361    let mut status = match settle(&tag, cli.settle_timeout) {
362        Ok(status) => status,
363        Err(message) => return fail(message),
364    };
365    if cli.base.debug && !status.debug_message.is_empty() {
366        eprint!("{}", status.debug_message);
367    }
368
369    let rescue = cli.rescue_exception;
370    if status.state == TagState::Exception as i32 && rescue {
371        // Old BitTag firmware latched EXCEPTION but refused log reads unless
372        // ABORTED or FINISHED; rewriting only that word is the least invasive
373        // unlock. The helper verifies the write itself.
374        eprintln!("Rescue: changing monitor-visible state from EXCEPTION to ABORTED");
375        if !tag.force_backup_state(TagState::Aborted) {
376            return fail(format!(
377                "Could not rescue exception state: {}",
378                tag.debug_message()
379            ));
380        }
381        status.state = TagState::Aborted as i32;
382        eprintln!("Rescue: backup state verified; continuing with pre-rescue log counts");
383    }
384
385    if status.state == TagState::Running as i32 && cli.stop {
386        status = match stop(&tag, cli.stop_timeout) {
387            Ok(status) => status,
388            Err(message) => return fail(message),
389        };
390    }
391
392    if !is_downloadable(status.state) {
393        eprintln!("Can't dump logs from current state");
394        eprintln!("{}", proto::debug_string(&status));
395        return ExitCode::FAILURE;
396    }
397
398    let config = match tag.config() {
399        Ok(config) => config,
400        Err(_) => {
401            return fail(format!(
402                "Could not read tag config: {}",
403                tag.debug_message()
404            ));
405        }
406    };
407    let output = cli.output.unwrap_or_else(|| "tag-download.db3".into());
408    // The writer refuses a tag type it has no schema for before creating
409    // the file.
410    let mut writer = match LogWriter::create(output.as_ref(), &config, true) {
411        Ok(writer) => writer,
412        Err(error) => return fail(format!("Could not open output: {error}")),
413    };
414    println!("Writing sqlite log to {output}");
415
416    let header = match read_header(&tag) {
417        Ok(header) => header,
418        Err(message) => return fail(format!("Could not write log header: {message}")),
419    };
420    if let Err(error) = writer.write_header(&header) {
421        return fail(format!("Could not write log header: {error}"));
422    }
423
424    let bittag_rescue = rescue && config.tag_type == TagType::Bittag as i32;
425    let mut max_count = if status.external_data_count > 0 {
426        status.external_data_count as usize
427    } else {
428        status.internal_data_count.max(0) as usize
429    };
430    if bittag_rescue && max_count == 0 {
431        // Some exception paths leave the page count at zero although the
432        // BitTag's internal flash headers are intact; count them directly.
433        match tag.count_bittag_log_headers() {
434            Some(count) => max_count = count,
435            None => return fail("Could not scan BitTag log headers"),
436        }
437        eprintln!("Rescue: found {max_count} BitTag log records");
438    }
439    if max_count == 0 {
440        println!("No log records to download");
441        return ExitCode::SUCCESS;
442    }
443
444    let (mut total, mut requests) = (0usize, 0u64);
445    let (mut get_log, mut write_log) = (Duration::ZERO, Duration::ZERO);
446    print_progress(total, max_count);
447    tag.reset_link_stats();
448    tag.reset_monitor_stats();
449
450    let start = Instant::now();
451    if let Err(error) = writer.begin_log() {
452        println!();
453        return fail(format!("Could not begin log download: {error}"));
454    }
455    write_log += start.elapsed();
456
457    loop {
458        let start = Instant::now();
459        // In BitTag rescue mode the firmware's data-log RPC is avoided: the
460        // host reads the raw headers from flash, wrapped as ordinary pages.
461        let page: Option<Ack> = if bittag_rescue {
462            tag.bittag_log_from_flash(total as i32)
463        } else {
464            tag.data_log(total as i32).ok()
465        };
466        let Some(page) = page else {
467            println!();
468            return fail(format!(
469                "GetDataLog failed at entry {total}: {}",
470                tag.debug_message()
471            ));
472        };
473        get_log += start.elapsed();
474        requests += 1;
475
476        if !page.error_message().is_empty() {
477            println!();
478            return fail(page.error_message());
479        }
480        if page.err == ack::Err::Nodata as i32 && total < max_count {
481            total += 1;
482            print_progress(total, max_count);
483            continue;
484        }
485
486        let start = Instant::now();
487        let consumed = match writer.write_log(&page) {
488            Ok(consumed) => consumed,
489            Err(error) => {
490                println!();
491                return fail(format!("Could not write log data: {error}"));
492            }
493        };
494        write_log += start.elapsed();
495        if consumed == 0 {
496            break;
497        }
498        total += consumed;
499        print_progress(total, max_count);
500    }
501
502    let start = Instant::now();
503    if let Err(error) = writer.end_log() {
504        println!();
505        return fail(format!("Could not finish log download: {error}"));
506    }
507    write_log += start.elapsed();
508
509    println!();
510    println!("Downloaded {total} records in {requests} requests");
511    println!("Decoding time {} seconds", write_log.as_secs_f64());
512    if cli.profile {
513        print_profile(
514            &tag.link_stats(),
515            &tag.monitor_stats(),
516            get_log,
517            write_log,
518            requests,
519        );
520    }
521    ExitCode::SUCCESS
522}