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.
428 lines
12 KiB
Rust
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
|
|
}
|