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.
This commit is contained in:
1 parent
3ac7cd243b
commit
54242da04b
79 files changed
+23340
-3246
No files matched your search
+427
@@ -0,0 +1,427 @@
|
||||
//! `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
|
||||
}
|
||||
Reference in new issue
Block a user