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:
grabowski committed 2026-10-22 17:01:47 +07:00
1 parent 3ac7cd243b
commit 54242da04b
79 files changed
+23340 -3246

No files matched your search

+118 -136
View File
@@ -1,170 +1,152 @@
# HPCS 6500 — Open-Source Driver
# HPCS 6500 — open-source driver
Python driver and CLI for the HPCS 6500 spectrophotometer / integrating sphere.
Rust driver and command line tool for the HPCS 6500 spectrophotometer /
integrating sphere. It talks to the instrument directly over its USB serial
port and replaces the vendor software for measurement automation and data
extraction.
Communicates directly with the instrument over USB serial, replacing the
vendor software for measurement automation and data extraction.
This repository is only the driver. The lamp measurement station, the lamp
comparison export (TM-30, label printing) and the LED light calibration are
in the testbench repository,
[B4L/lamp-testbench](https://git.b4l.co.th/B4L/lamp-testbench), which pulls
this repository in as a submodule (the same layout as
[mppt-testbench](https://git.b4l.co.th/B4L/mppt-testbench)).
## What This Does
## What it reads
- **Measure**: Luminous flux (lm), CCT (K), CRI (Ra, R1-R15), chromaticity
(CIE xy, uv, u'v'), radiometric flux, spectrum 380-1050 nm
- **Electrical**: Voltage, current, power, power factor, frequency
- **Harmonics**: 50-harmonic voltage/current analysis, UThd, AThd, waveforms
- **Power supply**: Control the built-in AC (100-240V, 50/60Hz) and DC
(1-60V, 0-5A) power supply
- **Export**: CSV output for data logging
- **TM-30**: ANSI/IES TM-30-18 Rf, Rg, and hue-bin data computed from the
measured spectrum (via colour-science)
- **Lamp bundles**: one-command export of spectrum + TM-30 + metrics for the
[buildfor.life lamp comparison](https://buildfor.life/comparisons)
- **Photometric**: luminous flux (lm), efficacy (lm/W), CCT (K), Duv
- **Colour**: CRI Ra and R1-R15, SDCM, TLCI-2012, CIE xy / uv / u'v', CIE 1931 XYZ
- **Radiometric**: total, UV, blue, yellow, red, far-red and IR flux (mW)
- **Spectrum**: 350 points, 380-1050 nm
- **Electrical**: voltage, current, power, power factor, frequency
- **Harmonics**: 50 voltage and current harmonics, UThd, AThd, one cycle of both waveforms
- **Power supply**: the built-in AC (100-240 V, 50/60 Hz) and DC (1-60 V, 0-5 A) supply
## Quick Start
## Install
```bash
# Install
uv sync
# Single measurement (auto-detects device, turns PSU on/off)
uv run hpcs6500.py
# Continuous measurements
uv run hpcs6500.py --continuous
# Quick test (lumen + CCT only)
uv run hpcs6500.py --quick
uv run hpcs6500.py --quick --continuous
# Show full spectrum + harmonics
uv run hpcs6500.py --spectrum --harmonics
# Save to CSV
uv run hpcs6500.py --continuous --csv output.csv
cargo install --git https://git.b4l.co.th/B4L/HPCS6500-py # puts `hpcs6500` on the PATH
# or, in a checkout:
cargo build --release # target/release/hpcs6500(.exe)
```
## Power Supply Control
Windows needs no driver: the instrument is an STM32 virtual COM port
(VID `0483`, PID `5741`) and is found automatically. Linux: add yourself to
the `dialout` group.
## Command line
The flags are the ones of the former Python tool: `uv run hpcs6500.py ...`
is now `hpcs6500 ...`.
```bash
# Read current PSU settings
uv run hpcs6500.py --psu-status
hpcs6500 # single reading (vendor single-test flow)
hpcs6500 --continuous # continuous readings, PSU on while measuring
hpcs6500 --continuous --count 10 --csv out.csv
hpcs6500 --quick --continuous # lumen + CCT only
hpcs6500 --spectrum --harmonics # full spectrum and harmonics
hpcs6500 --json # one JSON object per reading on stdout
hpcs6500 --passive --continuous # read only, while the vendor software drives the instrument
hpcs6500 --port COM42 # pick the port
hpcs6500 ports # list serial ports, the instrument is marked
# Set AC mode, 230V 50Hz
uv run hpcs6500.py --mode ac --voltage 230 --frequency 50
# Set DC mode, 12V with 1A current limit
uv run hpcs6500.py --mode dc --voltage 12 --current 1.0
# Manual PSU on/off
uv run hpcs6500.py --psu-on
uv run hpcs6500.py --psu-off
# Set integration time (ms)
uv run hpcs6500.py --integration 500
# power supply
hpcs6500 --psu-status
hpcs6500 --mode ac --voltage 230 --frequency 50
hpcs6500 --mode dc --voltage 12 --current 1.0
hpcs6500 --psu-on / hpcs6500 --psu-off
hpcs6500 --integration 500 # ms, 0 = auto
```
## Lamp Comparison Export
Ctrl+C stops a continuous run cleanly: the supply is switched off and the
instrument reset. The text output and the CSV files are character for
character those of the Python tool, so scripts that read them keep working.
Produces the per-lamp data bundle consumed by the buildfor.life comparison
pages: `spd.csv` (full 380-1050 nm spectrum), `tm30.csv` (TM-30-18 hue-bin
data for the color vector graphic), and `metrics.json` (photometric,
colorimetric, CRI R1-R15, TM-30 Rf/Rg, electrical).
### Without the instrument
`--simulate` talks to a simulated instrument that answers every command of
the protocol: with a capture it replays the readings recorded in it,
without one it uses a built-in sample reading.
```bash
# Single reading from the device
uv run lamp_export.py --name philips-a60-8w --manufacturer Philips --model "A60 8W 927"
# Average several readings
uv run lamp_export.py --name philips-a60-8w --readings 5
# Power the lamp from the built-in supply: 230 V / 50 Hz, 60 s warm-up,
# PSU switches on before the readings and off afterwards
uv run lamp_export.py --name philips-a60-8w --voltage 230 --frequency 50 --settle 60
# From an existing pcap capture
uv run lamp_export.py --name some-lamp --parse captures/run.pcap
hpcs6500 --simulate # built-in sample
hpcs6500 --simulate captures/run.pcap --continuous # replay a capture
```
Output lands in `lamps/<name>/`. TM-30 is computed from the measured spectrum
with [colour-science](https://www.colour-science.org/); the spectrum is
relative, which TM-30 is invariant to. Sanity check of the implementation:
`uv run tm30.py` reproduces the published values for the CIE FL2 illuminant
(Rf 70, Rg 86).
## Lamp Station (barcode workflow)
Interactive loop around `lamp_export.py` for building the comparison database
end to end. Every lamp gets an internal ID (L0001, L0002, ...) as primary key;
the EAN barcode is optional metadata, so one barcode can cover daylight/warm
white variants (distinct IDs + variant field) and unbranded lamps work too.
Every run opens with a freshly allocated ID (the ID only becomes real once a
bundle is written, so aborting never burns a number). Then: scan the box's EAN
(optional, Enter skips it), or type an existing ID like L0004 to re-measure
that lamp with everything pre-filled, which also rewrites its bundle in the
current metrics format. Scanned EANs are checksum-validated and resolved to
manufacturer/model (previously measured lamps, local cache, then
upcitemdb.com; GS1-restricted 20-29 barcodes are flagged as
retailer-internal), lamps already measured under the same EAN are offered for
re-measurement, you confirm the identification and type the ADVERTISED values
from the packaging (flux, CCT, power, CRI, lifetime, equivalent W -> stored
under "rated" in metrics.json for claimed-vs-measured comparison), insert the
lamp, and the tool measures per the published procedure (230 V / 50 Hz, 60 s
settle, 5 readings averaged), writes lamps/<ID>/ into the comparison-data
checkout, commits, and pushes, then prints two ID labels (QR code linking to
the lamp's page plus the ID, DK-11221 23x23 mm) on a Brother QL-820NWB network
printer: one for the box, one for the lamp. Set HPCS_LABEL_PRINTER (e.g.
tcp://192.168.1.50) or pass --printer; reprint anytime with
`uv run label_printer.py <ID>`. The website picks the lamp up automatically:
its device list is data-driven from the comparison-data bundles and the push
triggers the auto-bump deploy. An .md page in the web repo is only ever needed
for photos, where-to-buy links, or notes.
### USB captures (reverse engineering)
```bash
uv run lamp_station.py # scan -> measure -> commit -> push
uv run lamp_station.py --no-push # commit locally only
uv run lamp_station.py --no-commit # just write the bundle
uv run lamp_station.py --settle 0 --readings 1 # quick smoke test
hpcs6500 --parse captures/run.pcap [--quick] [--spectrum] [--json] # readings in a capture
hpcs6500 dump captures/run.pcap # traffic timeline + <name>_TX.bin / _RX.bin
hpcs6500 capture # record the vendor software with USBPcap (admin, Windows)
```
Defaults assume `C:/dev/buildfor_life_web` with the comparison-data submodule
initialized; override with --data-repo.
`capture` starts `USBPcapCMD` on every root hub, waits for Ctrl+C, keeps the
largest file and dumps it. Captures go to `captures/` (not in git).
## Offline Parsing
## Library
Parse previously captured USB traffic (pcap files from USBPcap):
```bash
uv run hpcs6500.py --parse captures/some_capture.pcap
uv run hpcs6500.py --parse captures/some_capture.pcap --quick
```toml
[dependencies]
hpcs6500 = { git = "https://git.b4l.co.th/B4L/HPCS6500-py", default-features = false }
```
## Files
`default-features = false` leaves out the command line tool's dependencies.
| File | Description |
|------------------|----------------------------------------------------|
| `hpcs6500.py` | Driver class (`HPCS6500`) and CLI |
| `lamp_export.py` | Lamp comparison bundle export (spd/tm30/metrics) |
| `tm30.py` | ANSI/IES TM-30-18 computation from a spectrum |
| `usb_capture.py` | USB traffic capture tool (requires USBPcap) |
| `PROTOCOL.md` | Complete protocol reference (byte-level) |
| `pyproject.toml` | Project metadata and dependencies |
```rust
use hpcs6500::{Hpcs6500, Mode};
## Hardware
let port = hpcs6500::find_port().ok_or(hpcs6500::Error::NotFound)?;
let mut dev = Hpcs6500::open(&port)?;
dev.identify()?; // "HPCS6500"
dev.set_mode(Mode::Ac)?;
dev.set_ac_voltage(230.0)?;
dev.set_ac_frequency(50.0)?;
dev.psu_on()?;
dev.send_test_config(false)?; // the vendor's single-test configuration
let r = dev.take_single_reading()?; // trigger, poll, read both blocks, reset
dev.psu_off()?;
println!("{:.0} lm {:.0} K Ra {:.1}", r.flux_lm(), r.cct_k(), r.ra());
println!("{:?}", r.get("Phi_eb_mW")); // every field by its CSV name
let nm = r.spectrum_nm(); // with r.spectrum
```
- **Device**: HPCS 6500 spectrophotometer / integrating sphere
- **USB**: STM32 Virtual COM Port (VID `0483`, PID `5741`)
- **Protocol**: Custom binary over serial, documented in [PROTOCOL.md](PROTOCOL.md)
| Item | |
|---|---|
| `Hpcs6500::open(port)` / `with_transport(t, name)` | the instrument over the serial port or any `Transport` |
| `identify`, `poll_state`, `wait_for_data` | |
| `take_single_reading`, `take_reading(psu)`, `read_current` | single test, one continuous reading, passive read |
| `start_measurement`, `stop_measurement`, `reset`, `send_test_config` | the raw sequence steps |
| `psu_on`, `psu_off`, `set_mode`, `set_ac_voltage`, `set_ac_frequency`, `set_dc_voltage`, `set_dc_current`, `read_psu_settings` | built-in supply |
| `set_integration_time(ms)` | 0 = auto |
| `read_measurement_block`, `read_electrical_block` | raw 3904 / 1584-byte blocks |
| `set_cancel_flag(flag)`, `pause(d)` | long waits end with `Error::Cancelled` when the flag goes up (Ctrl+C) |
| `Reading` | `values` by `FIELDS`, `get(name)`, `scalars()`, `spectrum`, `spectrum_nm()`, `electrical` with `harmonics`; serializes to the Python driver's JSON keys |
| `pcap::read_messages`, `pcap::readings`, `pcap::blocks` | USBPcap captures |
| `sim::Simulator` | simulated instrument (`sample()`, `from_pcap(path)`), logs every frame |
| `format::*` | the text report and the CSV export |
| `pyfmt::*` | Python-compatible number formatting (`repr`, `.6g`, `.2f`, `round`) |
## Dependencies
- Python 3.11+
- `pyserial` (serial communication)
- `colour-science` (TM-30 computation)
- USBPcap (only for `usb_capture.py`, not needed for normal operation)
Every method returns `Result`; errors say what was expected and what came
back (`no acknowledge for command 0x72 (got nothing)`).
## Protocol
The binary protocol is fully documented in [PROTOCOL.md](PROTOCOL.md),
including all command bytes, data block layouts, field offsets, and the
complete measurement sequence. This was reverse-engineered from USB
packet captures of the vendor software.
The binary protocol is documented byte by byte in [PROTOCOL.md](PROTOCOL.md):
command bytes, block layouts, field offsets and the measurement sequences.
It was reverse-engineered from USB captures of the vendor software.
## Development
```bash
cargo test # protocol, simulator, golden tests
cargo clippy --all-targets -- -D warnings
```
`tests/golden/` holds the output of the former Python driver for real
readings (`tests/fixtures/`): the parsed values (bit-exact), the text report,
the CSV row and 2,000 number-formatting cases. The Rust driver reproduces all
of them exactly. Where `captures/` is present, the `--parse` reports of real
captures are compared too.
The Python driver this replaces is at tag `python-final`.