Files
HPCS6500/src/cli/main.rs
T
grabowski 54242da04b Rewrite the driver in Rust; move the testbench tools out
The repository is now only the HPCS 6500 driver: a Rust library (serial
transport, every protocol command, block parsing, USBPcap captures, a
simulated instrument) and the hpcs6500 CLI with the flags of the former
hpcs6500.py, plus dump/capture for reverse engineering.

The lamp station, lamp export, TM-30, label printer and LED calibration
move to B4L/lamp-testbench. Golden tests pin the Rust output to the Python
driver's: parsed values bit-exact, report text, CSV and number formatting.
The Python driver is at tag python-final.
2026-10-22 17:01:47 +07:00

428 lines
12 KiB
Rust

//! `hpcs6500`: command line tool for the HPCS 6500.
//!
//! The flags are those of the former Python tool (`hpcs6500.py`), so
//! `uv run hpcs6500.py --continuous --csv out.csv` becomes
//! `hpcs6500 --continuous --csv out.csv`.
mod capture;
use clap::{Parser, Subcommand};
use hpcs6500::format::{csv_header, csv_row, format_quick, format_reading, pcap_report};
use hpcs6500::sim::Simulator;
use hpcs6500::{Error, Hpcs6500, Mode, Reading, Transport, pcap};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
#[derive(Parser)]
#[command(
name = "hpcs6500",
version,
about = "HPCS 6500 Spectrophotometer Reader"
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
/// COM port (auto-detect if not specified)
#[arg(long)]
port: Option<String>,
/// Continuous reading mode
#[arg(long)]
continuous: bool,
/// With --continuous: stop after this many readings
#[arg(long, value_name = "N")]
count: Option<usize>,
/// Save readings to CSV file
#[arg(long, value_name = "FILE")]
csv: Option<PathBuf>,
/// Show full spectrum data
#[arg(long)]
spectrum: bool,
/// Passive mode: read data without controlling instrument
#[arg(long)]
passive: bool,
/// Quick test mode: only report lumen and CCT
#[arg(long)]
quick: bool,
/// Show harmonics data (voltage/current THD and per-harmonic)
#[arg(long)]
harmonics: bool,
/// Parse a pcap capture file instead
#[arg(long, value_name = "PCAP")]
parse: Option<PathBuf>,
/// Print each reading as one JSON line on stdout (messages go to stderr)
#[arg(long)]
json: bool,
/// Talk to a simulated instrument instead of the serial port; replays
/// the readings of a USB capture, or a built-in sample without one
#[arg(long, value_name = "PCAP", num_args = 0..=1, default_missing_value = "")]
simulate: Option<String>,
/// Set output mode
#[arg(long, help_heading = "Power supply")]
mode: Option<Mode>,
/// Set output voltage (V)
#[arg(long, help_heading = "Power supply")]
voltage: Option<f64>,
/// Set AC frequency (Hz)
#[arg(long, help_heading = "Power supply")]
frequency: Option<f64>,
/// Set DC current limit (A)
#[arg(long, help_heading = "Power supply")]
current: Option<f64>,
/// Set integration time (ms), 0=auto
#[arg(long, help_heading = "Power supply")]
integration: Option<f64>,
/// Turn PSU output on
#[arg(long, help_heading = "Power supply")]
psu_on: bool,
/// Turn PSU output off
#[arg(long, help_heading = "Power supply")]
psu_off: bool,
/// Don't auto-control PSU during measurements
#[arg(long, help_heading = "Power supply")]
no_psu: bool,
/// Read power supply settings
#[arg(long, help_heading = "Power supply")]
psu_status: bool,
}
#[derive(Subcommand)]
enum Command {
/// List serial ports (the HPCS 6500 is marked)
Ports,
/// Show the USB traffic in a capture and save the TX/RX byte streams
Dump {
pcap: PathBuf,
/// Packets to show in the timeline
#[arg(long, default_value_t = 100)]
limit: usize,
/// Do not write <name>_TX.bin / <name>_RX.bin
#[arg(long)]
no_bin: bool,
},
/// Capture the USB traffic of the vendor software with USBPcap (Windows, run as administrator)
Capture {
/// USB device address of the instrument on its hub
#[arg(long, default_value_t = 4)]
address: u32,
#[arg(long, default_value = "captures")]
out_dir: PathBuf,
#[arg(long, default_value = r"C:\Program Files\USBPcap\USBPcapCMD.exe")]
usbpcap: PathBuf,
},
}
/// Human messages: stdout normally, stderr with --json.
macro_rules! say {
($json:expr, $($t:tt)*) => {
if $json { eprintln!($($t)*) } else { println!($($t)*) }
};
}
fn ok(r: &hpcs6500::Result<()>) -> &'static str {
if r.is_ok() { "OK" } else { "FAILED" }
}
fn main() {
let cli = Cli::parse();
let code = match &cli.command {
Some(Command::Ports) => ports(),
Some(Command::Dump {
pcap,
limit,
no_bin,
}) => capture::dump(pcap, *limit, !*no_bin),
Some(Command::Capture {
address,
out_dir,
usbpcap,
}) => capture::capture(*address, out_dir, usbpcap),
None => run(cli),
};
std::process::exit(code);
}
fn ports() -> i32 {
for p in hpcs6500::list_ports() {
println!(
" {}: {} [{}]{}",
p.name,
p.description,
p.hwid,
if p.is_hpcs { " <- HPCS 6500" } else { "" }
);
}
0
}
fn run(mut cli: Cli) -> i32 {
// --harmonics implies --spectrum for the detailed display
if cli.harmonics {
cli.spectrum = true;
}
if let Some(path) = &cli.parse {
return match pcap::read_messages(path) {
Ok(msgs) if cli.json => {
for r in pcap::readings(&msgs) {
println!("{}", serde_json::to_string(&r).expect("json"));
}
0
}
Ok(msgs) => {
print!("{}", pcap_report(&msgs, cli.quick, cli.spectrum));
0
}
Err(e) => {
eprintln!("ERROR: {e}");
1
}
};
}
match &cli.simulate {
Some(src) => {
let sim = if src.is_empty() {
Ok(Simulator::sample())
} else {
Simulator::from_pcap(src)
};
match sim {
Ok(s) => {
let label = if src.is_empty() {
"simulator (built-in sample)".to_string()
} else {
format!("simulator ({src})")
};
session(&cli, Hpcs6500::with_transport(s, label))
}
Err(e) => {
eprintln!("ERROR: {e}");
1
}
}
}
None => {
let Some(port) = cli.port.clone().or_else(hpcs6500::find_port) else {
println!("ERROR: HPCS 6500 not found. Connect the device or specify --port.");
println!("\nAvailable ports:");
ports();
return 1;
};
match Hpcs6500::open(&port) {
Ok(dev) => session(&cli, dev),
Err(e) => {
eprintln!("ERROR: cannot open {port}: {e}");
1
}
}
}
}
}
fn session<T: Transport>(cli: &Cli, mut dev: Hpcs6500<T>) -> i32 {
let j = cli.json;
let stop = Arc::new(AtomicBool::new(false));
{
let stop = stop.clone();
let _ = ctrlc::set_handler(move || stop.store(true, Ordering::SeqCst));
}
dev.set_cancel_flag(stop.clone());
say!(j, "HPCS 6500 Spectrophotometer Reader");
say!(j, "Port: {}", dev.port());
say!(j, "{}", "=".repeat(60));
match dev.identify() {
Ok(name) => say!(j, "Device: {name}"),
Err(_) => say!(j, "Warning: no identify response (device may be busy)"),
}
let has_psu_cmd = cli.mode.is_some()
|| cli.voltage.is_some()
|| cli.frequency.is_some()
|| cli.current.is_some()
|| cli.integration.is_some()
|| cli.psu_status
|| cli.psu_on
|| cli.psu_off;
if cli.psu_status {
match dev.read_psu_settings() {
Ok(s) => {
say!(j, "\nPower Supply Settings:");
say!(j, " Mode: {}", s.mode);
say!(j, " AC Voltage: {:.0} V", s.ac_voltage);
say!(j, " AC Frequency: {:.0} Hz", s.ac_frequency);
say!(j, " DC Voltage: {:.1} V", s.dc_voltage);
say!(j, " DC Current: {:.1} A", s.dc_current);
}
Err(_) => say!(j, "Failed to read PSU settings"),
}
if !cli.continuous && cli.voltage.is_none() && cli.mode.is_none() {
return 0;
}
}
if let Some(m) = cli.mode {
let r = dev.set_mode(m);
say!(j, "Set mode to {m}: {}", ok(&r));
}
if let Some(v) = cli.voltage {
let mode = cli.mode.unwrap_or(Mode::Ac);
let r = if mode == Mode::Ac {
dev.set_ac_voltage(v as f32)
} else {
dev.set_dc_voltage(v as f32)
};
say!(
j,
"Set {mode} voltage to {} V: {}",
hpcs6500::pyfmt::repr(v),
ok(&r)
);
}
if let Some(f) = cli.frequency {
let r = dev.set_ac_frequency(f as f32);
say!(
j,
"Set AC frequency to {} Hz: {}",
hpcs6500::pyfmt::repr(f),
ok(&r)
);
}
if let Some(a) = cli.current {
let r = dev.set_dc_current(a as f32);
say!(
j,
"Set DC current limit to {} A: {}",
hpcs6500::pyfmt::repr(a),
ok(&r)
);
}
if let Some(ms) = cli.integration {
let r = dev.set_integration_time(ms);
say!(
j,
"Set integration time to {} ms: {}",
hpcs6500::pyfmt::repr(ms),
ok(&r)
);
}
if cli.psu_on {
let r = dev.psu_on();
say!(j, "PSU output ON: {}", ok(&r));
}
if cli.psu_off {
let r = dev.psu_off();
say!(j, "PSU output OFF: {}", ok(&r));
}
// only supply commands, no measurement requested
if has_psu_cmd && !cli.continuous && !cli.passive {
return 0;
}
let mut csv = None;
if let Some(path) = &cli.csv {
match File::create(path) {
Ok(mut f) => {
let _ = f.write_all(csv_header().as_bytes());
say!(j, "Saving to: {}", path.display());
csv = Some(f);
}
Err(e) => {
eprintln!("ERROR: {}: {e}", path.display());
return 1;
}
}
}
// the supply is only controlled in continuous mode
let use_psu = cli.continuous && !cli.no_psu && !cli.passive;
let mut code = 0;
if use_psu {
let _ = dev.psu_on();
std::thread::sleep(Duration::from_millis(200));
}
let mut n = 0;
loop {
if stop.load(Ordering::SeqCst) {
say!(j, "\nStopped.");
break;
}
n += 1;
if !cli.quick {
say!(j, "\n{}", "=".repeat(60));
say!(
j,
"Reading {n} ({})",
chrono::Local::now().format("%H:%M:%S")
);
say!(j, "{}", "=".repeat(60));
}
let result: hpcs6500::Result<Reading> = if cli.passive {
dev.read_current()
} else if cli.continuous {
dev.take_reading(false)
} else {
dev.take_single_reading()
};
let r = match result {
Ok(r) => r,
Err(Error::Cancelled) => {
say!(j, "\nStopped.");
break;
}
Err(e) => {
say!(j, "Failed to get reading ({e})");
if !cli.continuous {
code = 1;
break;
}
let _ = dev.pause(Duration::from_secs(1));
continue;
}
};
if j {
println!("{}", serde_json::to_string(&r).expect("json"));
} else if cli.quick {
println!("{}", format_quick(&r, Some(n)));
} else {
println!("{}", format_reading(&r, cli.spectrum));
}
if let Some(f) = csv.as_mut() {
let now = chrono::Local::now().naive_local();
let ts = if now.and_utc().timestamp_subsec_micros() == 0 {
now.format("%Y-%m-%dT%H:%M:%S").to_string()
} else {
now.format("%Y-%m-%dT%H:%M:%S%.6f").to_string()
};
let _ = f.write_all(csv_row(&ts, &r).as_bytes());
let _ = f.flush();
}
if !cli.continuous || cli.count.is_some_and(|c| n >= c) {
break;
}
if dev.pause(Duration::from_millis(500)).is_err() {
say!(j, "\nStopped.");
break;
}
}
if use_psu {
let _ = dev.psu_off();
}
let _ = dev.reset();
if let Some(path) = &cli.csv {
say!(j, "Data saved to: {}", path.display());
}
code
}