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
@@ -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`.
|
||||
Reference in new issue
Block a user