20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# UV
|
||||||
|
uv
|
||||||
|
uv.lock
|
||||||
|
|
||||||
|
# Captures (binary data, not tracked)
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
299
PROTOCOL.md
Normal file
299
PROTOCOL.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# HPCS 6500 USB Protocol Reference
|
||||||
|
|
||||||
|
Reverse-engineered from USB packet captures of the vendor software.
|
||||||
|
|
||||||
|
## Device Overview
|
||||||
|
|
||||||
|
The HPCS 6500 is a spectrophotometer / integrating sphere system for LED and
|
||||||
|
light source testing. It includes a built-in programmable AC/DC power supply.
|
||||||
|
|
||||||
|
**Capabilities:**
|
||||||
|
- Spectral power distribution (380-1050 nm, 350 points)
|
||||||
|
- Photometric values (lumen, luminous efficacy, CCT, CRI Ra/R1-R15)
|
||||||
|
- Chromaticity coordinates (CIE xy, uv, u'v')
|
||||||
|
- Radiometric flux (total, UV, blue, yellow, red, far-red, IR)
|
||||||
|
- CIE 1931 tristimulus (X, Y, Z), TLCI-2012, SDCM
|
||||||
|
- Electrical parameters (voltage, current, power, power factor, frequency)
|
||||||
|
- Harmonics analysis (50 harmonics voltage/current %, UThd, AThd, waveforms)
|
||||||
|
- Built-in power supply: AC 100-240 V / 50-60 Hz, DC 1-60 V / 0-5 A
|
||||||
|
|
||||||
|
**Connection:**
|
||||||
|
- USB interface via STM32 Virtual COM Port (VID `0483`, PID `5741`)
|
||||||
|
- Appears as a serial port (e.g. COM42 on Windows)
|
||||||
|
- Baud rate: 115200
|
||||||
|
|
||||||
|
## Frame Format
|
||||||
|
|
||||||
|
Every command and response starts with the sync byte `0x8C` followed by a
|
||||||
|
command ID byte. The device echoes the command ID in the response. All
|
||||||
|
multi-byte numeric values are **little-endian**.
|
||||||
|
|
||||||
|
```
|
||||||
|
TX: 8C <cmd> [payload...]
|
||||||
|
RX: 8C <cmd> [data...]
|
||||||
|
```
|
||||||
|
|
||||||
|
Write commands receive a 2-byte ACK (`8C <cmd>`). Read commands return
|
||||||
|
variable-length data after the 2-byte header.
|
||||||
|
|
||||||
|
## Command Table
|
||||||
|
|
||||||
|
| Cmd | Direction | TX len | RX len | Description |
|
||||||
|
|------|-----------|--------|----------------|--------------------------------------|
|
||||||
|
| `00` | read | 2 | 16 | Identify device |
|
||||||
|
| `01` | write | 6 | 2 | Set integration time (uint32 LE, µs) |
|
||||||
|
| `03` | read | 2 | 9 | Poll instrument state |
|
||||||
|
| `05` | read | 2 | 7 | Status check |
|
||||||
|
| `0E` | write | 3 | 2 | Start (`01`) / Stop (`02`) measuring |
|
||||||
|
| `13` | read | 2 | 4 hdr + 3904 | Read measurement block |
|
||||||
|
| `25` | write | 2 | 2 | Reset instrument |
|
||||||
|
| `26` | read | 2 | 196 | Read instrument ranges |
|
||||||
|
| `2A` | read | 2 | 122 | Read device configuration |
|
||||||
|
| `72` | write | 3 | 2 | PSU output on (`00`) / off (`01`) |
|
||||||
|
| `73` | write | 7 | 2 | Set DC parameter (sub + float32) |
|
||||||
|
| `77` | read | 2 | 4 hdr + 1584 | Read electrical / harmonics block |
|
||||||
|
| `78` | write | 7 | 2 | Set AC parameter (sub + float32) |
|
||||||
|
| `79` | read | 2 | 20 | Read power supply settings |
|
||||||
|
| `7A` | write | 3 | 2 | Set output mode (`00`=AC, `01`=DC) |
|
||||||
|
| `7B` | read | 2 | 2 | Read output mode |
|
||||||
|
|
||||||
|
## Measurement Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
1. TX 8C 00 -> Identify (expect "HPCS6500" in response)
|
||||||
|
2. TX 8C 79 -> Read current PSU settings
|
||||||
|
3. TX 8C 7A <mode> -> Set output mode (00=AC, 01=DC)
|
||||||
|
4. TX 8C 78/73 ... -> Configure voltage/frequency/current as needed
|
||||||
|
5. TX 8C 01 <uint32> -> Set integration time (µs), or skip for auto
|
||||||
|
6. TX 8C 72 00 -> Turn PSU output ON
|
||||||
|
7. TX 8C 0E 01 -> Start measurement
|
||||||
|
8. TX 8C 03 -> Poll state until byte[2] = 0x01 (data ready)
|
||||||
|
9. TX 8C 13 -> Read measurement data (3904 bytes)
|
||||||
|
10. TX 8C 77 -> Read electrical data (1584 bytes)
|
||||||
|
(repeat 8-10 for continuous readings, ~550 ms per cycle)
|
||||||
|
11. TX 8C 0E 02 -> Stop measurement
|
||||||
|
12. TX 8C 72 01 -> Turn PSU output OFF
|
||||||
|
13. TX 8C 25 -> Reset instrument
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Polling (`8C 03`)
|
||||||
|
|
||||||
|
9-byte response: `8C 03 <b2> <b3> <b4> <b5> <b6> <b7> <b8>`
|
||||||
|
|
||||||
|
| Byte | Meaning |
|
||||||
|
|------|------------------------------------------------------|
|
||||||
|
| b2 | Data flag: `00` = no new data, `01` = data available |
|
||||||
|
| b3 | Mirrors b2 |
|
||||||
|
| b4 | Always `00` |
|
||||||
|
| b5 | State: `04` = idle, `01` = measuring |
|
||||||
|
| b6 | Always `00` |
|
||||||
|
| b7 | Always `00` |
|
||||||
|
| b8 | Always `01` |
|
||||||
|
|
||||||
|
## Measurement Block (`8C 13` — 3904 bytes)
|
||||||
|
|
||||||
|
The response has a 4-byte transport header (`8C 13 0F 40`) followed by a
|
||||||
|
3904-byte payload. All floats are IEEE 754 single-precision, little-endian.
|
||||||
|
|
||||||
|
### Device Header (offset 0-35)
|
||||||
|
|
||||||
|
| Offset | Size | Type | Field |
|
||||||
|
|--------|------|--------|---------------------------|
|
||||||
|
| 0 | 10 | ASCII | Device ID (`"HPCS6500"`) |
|
||||||
|
| 10 | 26 | — | Internal calibration data |
|
||||||
|
|
||||||
|
### Photometric (offset 36-75)
|
||||||
|
|
||||||
|
| Offset | Type | Field | Unit | Example |
|
||||||
|
|--------|---------|------------------------|---------|---------|
|
||||||
|
| 36 | float32 | Luminous flux | lm | 479.57 |
|
||||||
|
| 40 | float32 | Luminous efficacy | lm/W | 57.05 |
|
||||||
|
| 44 | float32 | Correlated color temp | K | 5653 |
|
||||||
|
| 48 | float32 | Duv | — | 0.00553 |
|
||||||
|
| 52 | float32 | CIE x | — | 0.3289 |
|
||||||
|
| 56 | float32 | CIE y | — | 0.3489 |
|
||||||
|
| 60 | float32 | CIE u | — | 0.2015 |
|
||||||
|
| 64 | float32 | CIE v | — | 0.3206 |
|
||||||
|
| 68 | float32 | CIE u' | — | 0.2015 |
|
||||||
|
| 72 | float32 | CIE v' | — | 0.4809 |
|
||||||
|
|
||||||
|
### Color Rendering (offset 76-143)
|
||||||
|
|
||||||
|
| Offset | Type | Field | Example |
|
||||||
|
|--------|---------|-------|---------|
|
||||||
|
| 76 | float32 | SDCM | 4.71 |
|
||||||
|
| 80 | float32 | Ra | 83.0 |
|
||||||
|
| 84-140 | float32 | R1-R15 (4 bytes each) | R1=82 .. R15=76 |
|
||||||
|
|
||||||
|
R*n* is at offset `80 + n × 4` (R1 at 84, R2 at 88, ..., R15 at 140).
|
||||||
|
|
||||||
|
### Radiometric (offset 144-175)
|
||||||
|
|
||||||
|
| Offset | Type | Field | Unit | Example |
|
||||||
|
|--------|---------|--------------------|------|----------|
|
||||||
|
| 144 | float32 | Total radiant flux | mW | 1491.256 |
|
||||||
|
| 148 | float32 | UV flux | mW | 0.000 |
|
||||||
|
| 152 | float32 | Blue flux | mW | 469.836 |
|
||||||
|
| 156 | float32 | Yellow flux | mW | 679.454 |
|
||||||
|
| 160 | float32 | Red flux | mW | 330.864 |
|
||||||
|
| 164 | float32 | Far-red flux | mW | 11.462 |
|
||||||
|
| 168 | float32 | IR flux | mW | 0.000 |
|
||||||
|
| 172 | float32 | Total (duplicate) | mW | 1491.256 |
|
||||||
|
|
||||||
|
### CIE Tristimulus & Sensor (offset 224-255)
|
||||||
|
|
||||||
|
| Offset | Type | Field | Example |
|
||||||
|
|--------|---------|----------------|---------|
|
||||||
|
| 224 | float32 | CIE 1931 X | 661.9 |
|
||||||
|
| 228 | float32 | CIE 1931 Y | 702.15 |
|
||||||
|
| 232 | float32 | CIE 1931 Z | 648.535 |
|
||||||
|
| 236 | float32 | TLCI-2012 | 68 |
|
||||||
|
| 244 | float32 | Peak Signal | 53088 |
|
||||||
|
| 248 | float32 | Dark Signal | 2267 |
|
||||||
|
| 252 | float32 | Compensate lvl | 2834 |
|
||||||
|
|
||||||
|
### Timestamps (offset 272-291)
|
||||||
|
|
||||||
|
| Offset | Size | Type | Field | Example |
|
||||||
|
|--------|------|-------|-----------|----------------|
|
||||||
|
| 272 | 11 | ASCII | Test date | `2026-02-04\0` |
|
||||||
|
| 283 | 9 | ASCII | Test time | `16:04:17\0` |
|
||||||
|
|
||||||
|
### Spectral Data (offset 432-1831)
|
||||||
|
|
||||||
|
| Offset | Type | Field |
|
||||||
|
|-----------|---------------|----------------------------------|
|
||||||
|
| 430-431 | uint16 | Spectrum header (zero) |
|
||||||
|
| 432-1831 | float32 × 350 | Spectral irradiance (µW/cm²/nm) |
|
||||||
|
|
||||||
|
The 350 floats cover **380 nm to 1050 nm** at ~1.92 nm steps
|
||||||
|
((1050-380) / (350-1) = 1.917 nm/step). Values are absolute spectral
|
||||||
|
irradiance; the vendor software normalizes by dividing by the peak value.
|
||||||
|
|
||||||
|
### Unmapped Regions
|
||||||
|
|
||||||
|
Offsets 176-223, 256-271, and 1832-3903 contain additional data not yet
|
||||||
|
fully mapped. They likely hold extended parameters visible in the vendor
|
||||||
|
software's advanced views.
|
||||||
|
|
||||||
|
## Electrical / Harmonics Block (`8C 77` — 1584 bytes)
|
||||||
|
|
||||||
|
The response has a 4-byte transport header (`8C 77 06 30`) followed by a
|
||||||
|
1584-byte payload.
|
||||||
|
|
||||||
|
### Basic Electrical (always present)
|
||||||
|
|
||||||
|
| Offset | Type | Field | Example |
|
||||||
|
|--------|---------|------------------|---------|
|
||||||
|
| 0-7 | — | Reserved (zero) | — |
|
||||||
|
| 8 | float32 | Voltage | 230.30 V|
|
||||||
|
| 12 | float32 | Current | 0.065 A |
|
||||||
|
| 16 | float32 | Active power | 8.406 W |
|
||||||
|
| 20 | float32 | Frequency | 50.02 Hz|
|
||||||
|
| 24 | float32 | Power factor | 0.558 |
|
||||||
|
|
||||||
|
### Waveforms (when harmonics enabled, offset 28-543)
|
||||||
|
|
||||||
|
| Offset | Type | Count | Field |
|
||||||
|
|---------|--------------|-------|------------------------|
|
||||||
|
| 30-285 | int16 LE | 128 | Voltage waveform |
|
||||||
|
| 286-541 | int16 LE | 128 | Current waveform |
|
||||||
|
|
||||||
|
Each waveform contains 128 signed 16-bit samples representing one complete
|
||||||
|
AC cycle.
|
||||||
|
|
||||||
|
### Voltage Harmonics (when harmonics enabled, offset 544-747)
|
||||||
|
|
||||||
|
| Offset | Type | Count | Field |
|
||||||
|
|---------|--------------|-------|----------------------------|
|
||||||
|
| 544-740 | float32 | 50 | H1..H50 voltage (% of H1) |
|
||||||
|
| 744 | float32 | 1 | UThd (%) |
|
||||||
|
|
||||||
|
H1 = 100.0%. H*n* is at offset `544 + (n-1) * 4`.
|
||||||
|
|
||||||
|
### Current Harmonics (when harmonics enabled, offset 800-1003)
|
||||||
|
|
||||||
|
| Offset | Type | Count | Field |
|
||||||
|
|---------|--------------|-------|----------------------------|
|
||||||
|
| 800-996 | float32 | 50 | H1..H50 current (% of H1) |
|
||||||
|
| 1000 | float32 | 1 | AThd (%) |
|
||||||
|
|
||||||
|
H1 = 100.0%. H*n* is at offset `800 + (n-1) * 4`.
|
||||||
|
|
||||||
|
### Harmonics Detection
|
||||||
|
|
||||||
|
When harmonics is disabled, offsets 28+ are all zero. To detect whether
|
||||||
|
harmonics data is present, check if the float32 at offset 544 equals 100.0
|
||||||
|
(H1 voltage fundamental = 100%).
|
||||||
|
|
||||||
|
## Power Supply Control
|
||||||
|
|
||||||
|
### Output On/Off (`8C 72`)
|
||||||
|
|
||||||
|
```
|
||||||
|
TX: 8C 72 00 -> Output ON
|
||||||
|
TX: 8C 72 01 -> Output OFF
|
||||||
|
RX: 8C 72 -> ACK
|
||||||
|
```
|
||||||
|
|
||||||
|
The PSU output must be turned on before taking measurements.
|
||||||
|
|
||||||
|
### Output Mode (`8C 7A`)
|
||||||
|
|
||||||
|
```
|
||||||
|
TX: 8C 7A 00 -> AC mode
|
||||||
|
TX: 8C 7A 01 -> DC mode
|
||||||
|
RX: 8C 7A -> ACK
|
||||||
|
```
|
||||||
|
|
||||||
|
### AC Parameters (`8C 78`)
|
||||||
|
|
||||||
|
```
|
||||||
|
TX: 8C 78 00 <float32 LE> -> Set voltage (V), range 100-240
|
||||||
|
TX: 8C 78 01 <float32 LE> -> Set frequency (Hz), 50 or 60
|
||||||
|
RX: 8C 78 -> ACK
|
||||||
|
```
|
||||||
|
|
||||||
|
### DC Parameters (`8C 73`)
|
||||||
|
|
||||||
|
```
|
||||||
|
TX: 8C 73 00 <float32 LE> -> Set current limit (A), range 0-5
|
||||||
|
TX: 8C 73 01 <float32 LE> -> Set voltage (V), range 1-60
|
||||||
|
RX: 8C 73 -> ACK
|
||||||
|
```
|
||||||
|
|
||||||
|
### Read Settings (`8C 79`)
|
||||||
|
|
||||||
|
20-byte response: `8C 79` + 18 bytes payload.
|
||||||
|
|
||||||
|
| Offset | Type | Field |
|
||||||
|
|--------|---------|--------------------|
|
||||||
|
| 0 | float32 | AC voltage (V) |
|
||||||
|
| 4 | float32 | AC frequency (Hz) |
|
||||||
|
| 8 | float32 | DC voltage (V) |
|
||||||
|
| 12 | float32 | DC current (A) |
|
||||||
|
| 16 | uint8 | Mode (0=AC, 1=DC) |
|
||||||
|
| 17 | uint8 | Marker (0xFF) |
|
||||||
|
|
||||||
|
### Integration Time (`8C 01`)
|
||||||
|
|
||||||
|
```
|
||||||
|
TX: 8C 01 <uint32 LE> -> Integration time in microseconds
|
||||||
|
RX: 8C 01 -> ACK
|
||||||
|
```
|
||||||
|
|
||||||
|
Common values: 200000 (200 ms), 500000 (500 ms), 1000000 (1000 ms).
|
||||||
|
|
||||||
|
## Identify (`8C 00`)
|
||||||
|
|
||||||
|
16-byte response: `8C 00` + `"HPCS6500\0\0"` + 4 bytes device metadata.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The instrument computes all photometric, colorimetric, and radiometric
|
||||||
|
values internally. The host software only reads and displays results.
|
||||||
|
- During continuous measurement, the host polls `8C 03` and reads `8C 13`
|
||||||
|
+ `8C 77` on each cycle (~550 ms per reading).
|
||||||
|
- All float values are IEEE 754 single-precision (4 bytes), little-endian.
|
||||||
|
- All integer values are unsigned little-endian unless noted (waveform
|
||||||
|
samples are signed int16).
|
||||||
96
README.md
Normal file
96
README.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# HPCS 6500 — Open-Source Driver
|
||||||
|
|
||||||
|
Python driver and CLI for the HPCS 6500 spectrophotometer / integrating sphere.
|
||||||
|
|
||||||
|
Communicates directly with the instrument over USB serial, replacing the
|
||||||
|
vendor software for measurement automation and data extraction.
|
||||||
|
|
||||||
|
## What This Does
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Power Supply Control
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Read current PSU settings
|
||||||
|
uv run hpcs6500.py --psu-status
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Offline Parsing
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------------------|----------------------------------------------------|
|
||||||
|
| `hpcs6500.py` | Driver class (`HPCS6500`) and CLI |
|
||||||
|
| `usb_capture.py` | USB traffic capture tool (requires USBPcap) |
|
||||||
|
| `PROTOCOL.md` | Complete protocol reference (byte-level) |
|
||||||
|
| `pyproject.toml` | Project metadata and dependencies |
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- `pyserial` (serial communication)
|
||||||
|
- USBPcap (only for `usb_capture.py`, not needed for normal operation)
|
||||||
|
|
||||||
|
## 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.
|
||||||
829
hpcs6500.py
Normal file
829
hpcs6500.py
Normal file
@@ -0,0 +1,829 @@
|
|||||||
|
"""
|
||||||
|
HPCS 6500 Spectrophotometer / Integrating Sphere — Direct Reader
|
||||||
|
|
||||||
|
Communicates with the HPCS 6500 over USB serial (STM32 VCP) to take
|
||||||
|
measurements and extract photometric, colorimetric, radiometric,
|
||||||
|
electrical, and spectral data.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run hpcs6500.py # auto-detect port, single reading
|
||||||
|
uv run hpcs6500.py --port COM42 # specify port
|
||||||
|
uv run hpcs6500.py --continuous # continuous readings
|
||||||
|
uv run hpcs6500.py --csv out.csv # save to CSV
|
||||||
|
uv run hpcs6500.py --spectrum # also print full spectrum
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import serial
|
||||||
|
import serial.tools.list_ports
|
||||||
|
|
||||||
|
# Protocol constants
|
||||||
|
SYNC = 0x8C
|
||||||
|
CMD_IDENTIFY = 0x00
|
||||||
|
CMD_INTEGRATION_TIME = 0x01
|
||||||
|
CMD_POLL_STATE = 0x03
|
||||||
|
CMD_STATUS = 0x05
|
||||||
|
CMD_START = 0x0E
|
||||||
|
CMD_READ_MEASUREMENT = 0x13
|
||||||
|
CMD_RESET = 0x25
|
||||||
|
CMD_CONFIG = 0x2A
|
||||||
|
CMD_SET_DC = 0x73
|
||||||
|
CMD_READ_ELECTRICAL = 0x77
|
||||||
|
CMD_SET_AC = 0x78
|
||||||
|
CMD_READ_PSU = 0x79
|
||||||
|
CMD_PSU_OUTPUT = 0x72
|
||||||
|
CMD_SET_MODE = 0x7A
|
||||||
|
|
||||||
|
# State polling byte[5] values
|
||||||
|
STATE_IDLE = 0x04
|
||||||
|
STATE_MEASURING = 0x01
|
||||||
|
|
||||||
|
# Spectrum parameters
|
||||||
|
SPECTRUM_START_NM = 380
|
||||||
|
SPECTRUM_END_NM = 1050
|
||||||
|
SPECTRUM_OFFSET = 432
|
||||||
|
SPECTRUM_COUNT = 350
|
||||||
|
SPECTRUM_STEP = (SPECTRUM_END_NM - SPECTRUM_START_NM) / (SPECTRUM_COUNT - 1)
|
||||||
|
|
||||||
|
# Measurement block field offsets (all float32 LE)
|
||||||
|
FIELDS = {
|
||||||
|
"Phi_lm": 36,
|
||||||
|
"eta_lm_W": 40,
|
||||||
|
"CCT_K": 44,
|
||||||
|
"Duv": 48,
|
||||||
|
"x": 52,
|
||||||
|
"y": 56,
|
||||||
|
"u": 60,
|
||||||
|
"v": 64,
|
||||||
|
"u_prime": 68,
|
||||||
|
"v_prime": 72,
|
||||||
|
"SDCM": 76,
|
||||||
|
"Ra": 80,
|
||||||
|
"R1": 84,
|
||||||
|
"R2": 88,
|
||||||
|
"R3": 92,
|
||||||
|
"R4": 96,
|
||||||
|
"R5": 100,
|
||||||
|
"R6": 104,
|
||||||
|
"R7": 108,
|
||||||
|
"R8": 112,
|
||||||
|
"R9": 116,
|
||||||
|
"R10": 120,
|
||||||
|
"R11": 124,
|
||||||
|
"R12": 128,
|
||||||
|
"R13": 132,
|
||||||
|
"R14": 136,
|
||||||
|
"R15": 140,
|
||||||
|
"Phi_e_mW": 144,
|
||||||
|
"Phi_euv_mW": 148,
|
||||||
|
"Phi_eb_mW": 152,
|
||||||
|
"Phi_ey_mW": 156,
|
||||||
|
"Phi_er_mW": 160,
|
||||||
|
"Phi_efr_mW": 164,
|
||||||
|
"Phi_eir_mW": 168,
|
||||||
|
"Phi_e_total": 172,
|
||||||
|
"CIE_X": 224,
|
||||||
|
"CIE_Y": 228,
|
||||||
|
"CIE_Z": 232,
|
||||||
|
"TLCI": 236,
|
||||||
|
"PeakSignal": 244,
|
||||||
|
"DarkSignal": 248,
|
||||||
|
"Compensate": 252,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Electrical block field offsets
|
||||||
|
ELECTRICAL_FIELDS = {
|
||||||
|
"Voltage_V": 8,
|
||||||
|
"Current_A": 12,
|
||||||
|
"Power_W": 16,
|
||||||
|
"Freq_Hz": 20,
|
||||||
|
"PF": 24,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Harmonics offsets within the electrical block
|
||||||
|
HARMONICS_V_OFFSET = 544 # 50 × float32 voltage harmonics (%)
|
||||||
|
HARMONICS_V_COUNT = 50
|
||||||
|
UTHD_OFFSET = 744 # float32 UThd (%)
|
||||||
|
HARMONICS_I_OFFSET = 800 # 50 × float32 current harmonics (%)
|
||||||
|
HARMONICS_I_COUNT = 50
|
||||||
|
ATHD_OFFSET = 1000 # float32 AThd (%)
|
||||||
|
|
||||||
|
# Waveform offsets (int16 LE samples, 128 per waveform)
|
||||||
|
WAVEFORM_V_OFFSET = 30
|
||||||
|
WAVEFORM_I_OFFSET = 286
|
||||||
|
WAVEFORM_SAMPLES = 128
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pcap_messages(filepath):
|
||||||
|
"""Parse a USBPcap pcap file and extract bulk transfer payloads."""
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
magic = struct.unpack_from("<I", data, 0)[0]
|
||||||
|
endian = "<" if magic == 0xA1B2C3D4 else ">"
|
||||||
|
offset = 24
|
||||||
|
messages = []
|
||||||
|
first_ts = None
|
||||||
|
while offset + 16 <= len(data):
|
||||||
|
ts_sec, ts_usec, incl_len, orig_len = struct.unpack_from(
|
||||||
|
f"{endian}IIII", data, offset
|
||||||
|
)
|
||||||
|
offset += 16
|
||||||
|
if offset + incl_len > len(data):
|
||||||
|
break
|
||||||
|
pkt_data = data[offset : offset + incl_len]
|
||||||
|
offset += incl_len
|
||||||
|
if len(pkt_data) < 27:
|
||||||
|
continue
|
||||||
|
hdr_len = struct.unpack_from("<H", pkt_data, 0)[0]
|
||||||
|
endpoint = pkt_data[21]
|
||||||
|
transfer = pkt_data[22]
|
||||||
|
payload = pkt_data[hdr_len:]
|
||||||
|
if len(payload) == 0 or transfer != 3:
|
||||||
|
continue
|
||||||
|
ts = ts_sec + ts_usec / 1_000_000
|
||||||
|
if first_ts is None:
|
||||||
|
first_ts = ts
|
||||||
|
ep_dir = "IN" if (endpoint & 0x80) else "OUT"
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"ts": ts - first_ts,
|
||||||
|
"dir": "TX" if ep_dir == "OUT" else "RX",
|
||||||
|
"data": payload,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
class HPCS6500:
|
||||||
|
"""Driver for the HPCS 6500 spectrophotometer."""
|
||||||
|
|
||||||
|
def __init__(self, port, timeout=2.0):
|
||||||
|
self.ser = serial.Serial(
|
||||||
|
port=port,
|
||||||
|
baudrate=115200,
|
||||||
|
timeout=timeout,
|
||||||
|
write_timeout=timeout,
|
||||||
|
)
|
||||||
|
self.port = port
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.ser.close()
|
||||||
|
|
||||||
|
def _send(self, *cmd_bytes):
|
||||||
|
"""Send a command to the device."""
|
||||||
|
data = bytes([SYNC] + list(cmd_bytes))
|
||||||
|
self.ser.write(data)
|
||||||
|
|
||||||
|
def _recv(self, expected_len, timeout=3.0):
|
||||||
|
"""Read expected_len bytes from the device."""
|
||||||
|
data = b""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while len(data) < expected_len:
|
||||||
|
remaining = expected_len - len(data)
|
||||||
|
chunk = self.ser.read(remaining)
|
||||||
|
if chunk:
|
||||||
|
data += chunk
|
||||||
|
elif time.monotonic() > deadline:
|
||||||
|
break
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _recv_response(self, cmd_id, timeout=3.0):
|
||||||
|
"""Read a response, expecting it to start with 8C <cmd_id>."""
|
||||||
|
# Read the 2-byte header first
|
||||||
|
hdr = self._recv(2, timeout)
|
||||||
|
if len(hdr) < 2:
|
||||||
|
return None
|
||||||
|
if hdr[0] != SYNC or hdr[1] != cmd_id:
|
||||||
|
# Try to resync — read until we find 8C <cmd_id>
|
||||||
|
buf = hdr
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
b = self.ser.read(1)
|
||||||
|
if not b:
|
||||||
|
continue
|
||||||
|
buf += b
|
||||||
|
idx = buf.find(bytes([SYNC, cmd_id]))
|
||||||
|
if idx >= 0:
|
||||||
|
hdr = buf[idx:idx+2]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return hdr
|
||||||
|
|
||||||
|
def identify(self):
|
||||||
|
"""Send identify command, return device name string."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
self._send(CMD_IDENTIFY)
|
||||||
|
resp = self._recv(16, timeout=2.0)
|
||||||
|
if len(resp) < 16:
|
||||||
|
return None
|
||||||
|
if resp[0] != SYNC or resp[1] != CMD_IDENTIFY:
|
||||||
|
return None
|
||||||
|
name = resp[2:10].decode("ascii", errors="replace").rstrip("\x00")
|
||||||
|
return name
|
||||||
|
|
||||||
|
def read_measurement_block(self):
|
||||||
|
"""Send 8C 13 and read the 3904-byte measurement block."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
self._send(CMD_READ_MEASUREMENT)
|
||||||
|
# First, read the 4-byte header: 8C 13 0F 40
|
||||||
|
hdr = self._recv(4, timeout=3.0)
|
||||||
|
if len(hdr) < 4 or hdr[0] != SYNC or hdr[1] != CMD_READ_MEASUREMENT:
|
||||||
|
return None
|
||||||
|
# Then read the 3904-byte payload
|
||||||
|
payload = self._recv(3904, timeout=5.0)
|
||||||
|
if len(payload) < 3904:
|
||||||
|
print(f"Warning: measurement block short ({len(payload)}/3904 bytes)")
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def read_electrical_block(self):
|
||||||
|
"""Send 8C 77 and read the 1584-byte electrical block."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
self._send(CMD_READ_ELECTRICAL)
|
||||||
|
# 4-byte header: 8C 77 06 30
|
||||||
|
hdr = self._recv(4, timeout=3.0)
|
||||||
|
if len(hdr) < 4 or hdr[0] != SYNC or hdr[1] != CMD_READ_ELECTRICAL:
|
||||||
|
return None
|
||||||
|
payload = self._recv(1584, timeout=5.0)
|
||||||
|
if len(payload) < 1584:
|
||||||
|
print(f"Warning: electrical block short ({len(payload)}/1584 bytes)")
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def poll_state(self):
|
||||||
|
"""Send 8C 03 and return (data_available, state)."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
self._send(CMD_POLL_STATE)
|
||||||
|
resp = self._recv(9, timeout=2.0)
|
||||||
|
if len(resp) < 9 or resp[0] != SYNC or resp[1] != CMD_POLL_STATE:
|
||||||
|
return None, None
|
||||||
|
data_available = resp[2] == 1
|
||||||
|
state = resp[5]
|
||||||
|
return data_available, state
|
||||||
|
|
||||||
|
def start_measurement(self):
|
||||||
|
"""Send 8C 0E 01 to start measurement."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
self._send(CMD_START, 0x01)
|
||||||
|
resp = self._recv(2, timeout=2.0)
|
||||||
|
return resp == bytes([SYNC, CMD_START])
|
||||||
|
|
||||||
|
def stop_measurement(self):
|
||||||
|
"""Send 8C 0E 02 to stop measurement."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
self._send(CMD_START, 0x02)
|
||||||
|
resp = self._recv(2, timeout=2.0)
|
||||||
|
return resp == bytes([SYNC, CMD_START])
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Send 8C 25 to reset."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
self._send(CMD_RESET)
|
||||||
|
resp = self._recv(2, timeout=2.0)
|
||||||
|
return resp == bytes([SYNC, CMD_RESET])
|
||||||
|
|
||||||
|
# --- Power supply control ---
|
||||||
|
|
||||||
|
def psu_on(self):
|
||||||
|
"""Turn PSU output on."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
self._send(CMD_PSU_OUTPUT, 0x00)
|
||||||
|
resp = self._recv(2, timeout=2.0)
|
||||||
|
return resp == bytes([SYNC, CMD_PSU_OUTPUT])
|
||||||
|
|
||||||
|
def psu_off(self):
|
||||||
|
"""Turn PSU output off."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
self._send(CMD_PSU_OUTPUT, 0x01)
|
||||||
|
resp = self._recv(2, timeout=2.0)
|
||||||
|
return resp == bytes([SYNC, CMD_PSU_OUTPUT])
|
||||||
|
|
||||||
|
def set_mode(self, mode):
|
||||||
|
"""Set output mode: 'ac' or 'dc'."""
|
||||||
|
mode_byte = 0x00 if mode.lower() == "ac" else 0x01
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
self._send(CMD_SET_MODE, mode_byte)
|
||||||
|
resp = self._recv(2, timeout=2.0)
|
||||||
|
return resp == bytes([SYNC, CMD_SET_MODE])
|
||||||
|
|
||||||
|
def set_ac_voltage(self, volts):
|
||||||
|
"""Set AC output voltage (100-240 V)."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
data = bytes([SYNC, CMD_SET_AC, 0x00]) + struct.pack("<f", volts)
|
||||||
|
self.ser.write(data)
|
||||||
|
resp = self._recv(2, timeout=2.0)
|
||||||
|
return resp == bytes([SYNC, CMD_SET_AC])
|
||||||
|
|
||||||
|
def set_ac_frequency(self, hz):
|
||||||
|
"""Set AC output frequency (50 or 60 Hz)."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
data = bytes([SYNC, CMD_SET_AC, 0x01]) + struct.pack("<f", hz)
|
||||||
|
self.ser.write(data)
|
||||||
|
resp = self._recv(2, timeout=2.0)
|
||||||
|
return resp == bytes([SYNC, CMD_SET_AC])
|
||||||
|
|
||||||
|
def set_dc_voltage(self, volts):
|
||||||
|
"""Set DC output voltage (1-60 V)."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
data = bytes([SYNC, CMD_SET_DC, 0x01]) + struct.pack("<f", volts)
|
||||||
|
self.ser.write(data)
|
||||||
|
resp = self._recv(2, timeout=2.0)
|
||||||
|
return resp == bytes([SYNC, CMD_SET_DC])
|
||||||
|
|
||||||
|
def set_dc_current(self, amps):
|
||||||
|
"""Set DC current limit (0-5 A)."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
data = bytes([SYNC, CMD_SET_DC, 0x00]) + struct.pack("<f", amps)
|
||||||
|
self.ser.write(data)
|
||||||
|
resp = self._recv(2, timeout=2.0)
|
||||||
|
return resp == bytes([SYNC, CMD_SET_DC])
|
||||||
|
|
||||||
|
def set_integration_time(self, ms):
|
||||||
|
"""Set integration time in milliseconds. Use 0 for auto."""
|
||||||
|
us = int(ms * 1000)
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
data = bytes([SYNC, CMD_INTEGRATION_TIME]) + struct.pack("<I", us)
|
||||||
|
self.ser.write(data)
|
||||||
|
resp = self._recv(2, timeout=2.0)
|
||||||
|
return resp == bytes([SYNC, CMD_INTEGRATION_TIME])
|
||||||
|
|
||||||
|
def read_psu_settings(self):
|
||||||
|
"""Read current power supply settings."""
|
||||||
|
self.ser.reset_input_buffer()
|
||||||
|
self._send(CMD_READ_PSU)
|
||||||
|
resp = self._recv(20, timeout=2.0)
|
||||||
|
if len(resp) < 20 or resp[0] != SYNC or resp[1] != CMD_READ_PSU:
|
||||||
|
return None
|
||||||
|
payload = resp[2:]
|
||||||
|
return {
|
||||||
|
"ac_voltage": struct.unpack_from("<f", payload, 0)[0],
|
||||||
|
"ac_frequency": struct.unpack_from("<f", payload, 4)[0],
|
||||||
|
"dc_voltage": struct.unpack_from("<f", payload, 8)[0],
|
||||||
|
"dc_current": struct.unpack_from("<f", payload, 12)[0],
|
||||||
|
"mode": "DC" if payload[16] == 1 else "AC",
|
||||||
|
}
|
||||||
|
|
||||||
|
def wait_for_data(self, timeout=30.0):
|
||||||
|
"""Poll until data is available or timeout."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
data_avail, state = self.poll_state()
|
||||||
|
if data_avail:
|
||||||
|
return True
|
||||||
|
time.sleep(0.1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def take_reading(self, psu=True):
|
||||||
|
"""Take a single measurement reading. Returns dict or None.
|
||||||
|
If psu=True, turns PSU output on before measuring and off after."""
|
||||||
|
if psu:
|
||||||
|
self.psu_on()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# Start measurement
|
||||||
|
if not self.start_measurement():
|
||||||
|
print("Failed to start measurement")
|
||||||
|
if psu:
|
||||||
|
self.psu_off()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Wait for data
|
||||||
|
if not self.wait_for_data(timeout=30.0):
|
||||||
|
print("Timeout waiting for measurement data")
|
||||||
|
self.stop_measurement()
|
||||||
|
if psu:
|
||||||
|
self.psu_off()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Read measurement block
|
||||||
|
meas = self.read_measurement_block()
|
||||||
|
elec = self.read_electrical_block()
|
||||||
|
|
||||||
|
if meas is None:
|
||||||
|
print("Failed to read measurement block")
|
||||||
|
self.stop_measurement()
|
||||||
|
if psu:
|
||||||
|
self.psu_off()
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = self._parse_measurement(meas)
|
||||||
|
|
||||||
|
if elec is not None:
|
||||||
|
result.update(self._parse_electrical(elec))
|
||||||
|
|
||||||
|
# Stop measurement
|
||||||
|
self.stop_measurement()
|
||||||
|
|
||||||
|
if psu:
|
||||||
|
self.psu_off()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def read_current(self):
|
||||||
|
"""Read current data without starting/stopping measurement.
|
||||||
|
Use this when the vendor software is controlling the instrument."""
|
||||||
|
meas = self.read_measurement_block()
|
||||||
|
elec = self.read_electrical_block()
|
||||||
|
|
||||||
|
if meas is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = self._parse_measurement(meas)
|
||||||
|
if elec is not None:
|
||||||
|
result.update(self._parse_electrical(elec))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _parse_measurement(self, data):
|
||||||
|
"""Parse the 3904-byte measurement block into a dict."""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Check header
|
||||||
|
header = data[:10].decode("ascii", errors="replace").rstrip("\x00")
|
||||||
|
result["device"] = header
|
||||||
|
|
||||||
|
# Extract all named fields
|
||||||
|
for name, offset in FIELDS.items():
|
||||||
|
if offset + 4 <= len(data):
|
||||||
|
result[name] = struct.unpack_from("<f", data, offset)[0]
|
||||||
|
|
||||||
|
# Extract date/time strings
|
||||||
|
date_off = 272
|
||||||
|
time_off = 283
|
||||||
|
if date_off + 10 <= len(data):
|
||||||
|
date_str = data[date_off:date_off+10].decode("ascii", errors="replace").rstrip("\x00")
|
||||||
|
result["test_date"] = date_str
|
||||||
|
if time_off + 8 <= len(data):
|
||||||
|
time_str = data[time_off:time_off+8].decode("ascii", errors="replace").rstrip("\x00")
|
||||||
|
result["test_time"] = time_str
|
||||||
|
|
||||||
|
# Extract spectrum
|
||||||
|
spectrum = []
|
||||||
|
for i in range(SPECTRUM_COUNT):
|
||||||
|
off = SPECTRUM_OFFSET + i * 4
|
||||||
|
if off + 4 <= len(data):
|
||||||
|
val = struct.unpack_from("<f", data, off)[0]
|
||||||
|
spectrum.append(val)
|
||||||
|
result["spectrum"] = spectrum
|
||||||
|
result["spectrum_nm"] = [
|
||||||
|
SPECTRUM_START_NM + i * SPECTRUM_STEP for i in range(len(spectrum))
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _parse_electrical(self, data):
|
||||||
|
"""Parse the 1584-byte electrical block into a dict."""
|
||||||
|
result = {}
|
||||||
|
for name, offset in ELECTRICAL_FIELDS.items():
|
||||||
|
if offset + 4 <= len(data):
|
||||||
|
result[name] = struct.unpack_from("<f", data, offset)[0]
|
||||||
|
|
||||||
|
# Check if harmonics data is present (non-zero at harmonics offset)
|
||||||
|
if len(data) >= ATHD_OFFSET + 4:
|
||||||
|
h1_v = struct.unpack_from("<f", data, HARMONICS_V_OFFSET)[0]
|
||||||
|
if abs(h1_v - 100.0) < 1.0: # H1 should be ~100%
|
||||||
|
# Voltage harmonics
|
||||||
|
v_harmonics = []
|
||||||
|
for i in range(HARMONICS_V_COUNT):
|
||||||
|
off = HARMONICS_V_OFFSET + i * 4
|
||||||
|
v_harmonics.append(struct.unpack_from("<f", data, off)[0])
|
||||||
|
result["V_harmonics"] = v_harmonics
|
||||||
|
result["UThd"] = struct.unpack_from("<f", data, UTHD_OFFSET)[0]
|
||||||
|
|
||||||
|
# Current harmonics
|
||||||
|
i_harmonics = []
|
||||||
|
for i in range(HARMONICS_I_COUNT):
|
||||||
|
off = HARMONICS_I_OFFSET + i * 4
|
||||||
|
i_harmonics.append(struct.unpack_from("<f", data, off)[0])
|
||||||
|
result["I_harmonics"] = i_harmonics
|
||||||
|
result["AThd"] = struct.unpack_from("<f", data, ATHD_OFFSET)[0]
|
||||||
|
|
||||||
|
# Waveforms (int16 LE)
|
||||||
|
v_waveform = []
|
||||||
|
for i in range(WAVEFORM_SAMPLES):
|
||||||
|
off = WAVEFORM_V_OFFSET + i * 2
|
||||||
|
v_waveform.append(struct.unpack_from("<h", data, off)[0])
|
||||||
|
result["V_waveform"] = v_waveform
|
||||||
|
|
||||||
|
i_waveform = []
|
||||||
|
for i in range(WAVEFORM_SAMPLES):
|
||||||
|
off = WAVEFORM_I_OFFSET + i * 2
|
||||||
|
i_waveform.append(struct.unpack_from("<h", data, off)[0])
|
||||||
|
result["I_waveform"] = i_waveform
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def find_hpcs_port():
|
||||||
|
"""Auto-detect the HPCS 6500 COM port."""
|
||||||
|
for port in serial.tools.list_ports.comports():
|
||||||
|
if port.vid == 0x0483 and port.pid == 0x5741:
|
||||||
|
return port.device
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_reading(r, show_spectrum=False):
|
||||||
|
"""Format a reading dict for display."""
|
||||||
|
lines = []
|
||||||
|
lines.append(f" Device: {r.get('device', '?')}")
|
||||||
|
lines.append(f" Date: {r.get('test_date', '?')} Time: {r.get('test_time', '?')}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(" --- Photometric ---")
|
||||||
|
lines.append(f" Phi (lm): {r.get('Phi_lm', 0):10.2f}")
|
||||||
|
lines.append(f" eta (lm/W): {r.get('eta_lm_W', 0):10.2f}")
|
||||||
|
lines.append(f" CCT (K): {r.get('CCT_K', 0):10.0f}")
|
||||||
|
lines.append(f" Duv: {r.get('Duv', 0):10.5f}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(" --- Chromaticity ---")
|
||||||
|
lines.append(f" x: {r.get('x', 0):.4f} y: {r.get('y', 0):.4f}")
|
||||||
|
lines.append(f" u: {r.get('u', 0):.4f} v: {r.get('v', 0):.4f}")
|
||||||
|
lines.append(f" u': {r.get('u_prime', 0):.4f} v': {r.get('v_prime', 0):.4f}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(" --- CRI ---")
|
||||||
|
ra = r.get('Ra', 0)
|
||||||
|
lines.append(f" Ra: {ra:.1f} SDCM: {r.get('SDCM', 0):.2f}")
|
||||||
|
ri = []
|
||||||
|
for i in range(1, 16):
|
||||||
|
ri.append(f"R{i}={r.get(f'R{i}', 0):.0f}")
|
||||||
|
lines.append(f" {', '.join(ri[:8])}")
|
||||||
|
lines.append(f" {', '.join(ri[8:])}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(" --- CIE 1931 ---")
|
||||||
|
lines.append(f" X: {r.get('CIE_X', 0):.3f} Y: {r.get('CIE_Y', 0):.3f} Z: {r.get('CIE_Z', 0):.3f}")
|
||||||
|
lines.append(f" TLCI-2012: {r.get('TLCI', 0):.0f}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(" --- Radiometric ---")
|
||||||
|
lines.append(f" Phi_e (mW): {r.get('Phi_e_mW', 0):10.3f}")
|
||||||
|
lines.append(f" Phi_eb (mW): {r.get('Phi_eb_mW', 0):10.3f}")
|
||||||
|
lines.append(f" Phi_ey (mW): {r.get('Phi_ey_mW', 0):10.3f}")
|
||||||
|
lines.append(f" Phi_er (mW): {r.get('Phi_er_mW', 0):10.3f}")
|
||||||
|
lines.append(f" Phi_efr (mW): {r.get('Phi_efr_mW', 0):10.3f}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(" --- Electrical ---")
|
||||||
|
lines.append(f" Voltage (V): {r.get('Voltage_V', 0):10.2f}")
|
||||||
|
lines.append(f" Current (A): {r.get('Current_A', 0):10.4f}")
|
||||||
|
lines.append(f" Power (W): {r.get('Power_W', 0):10.3f}")
|
||||||
|
lines.append(f" Frequency (Hz):{r.get('Freq_Hz', 0):10.2f}")
|
||||||
|
lines.append(f" Power Factor: {r.get('PF', 0):10.4f}")
|
||||||
|
|
||||||
|
if "UThd" in r:
|
||||||
|
lines.append(f" UThd (%): {r['UThd']:10.4f}")
|
||||||
|
lines.append(f" AThd (%): {r.get('AThd', 0):10.4f}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if show_spectrum and "V_harmonics" in r:
|
||||||
|
lines.append(" --- Voltage Harmonics (%) ---")
|
||||||
|
vh = r["V_harmonics"]
|
||||||
|
for i, val in enumerate(vh):
|
||||||
|
if val > 0.001 or i == 0:
|
||||||
|
lines.append(f" H{i+1:2d}: {val:8.4f}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(" --- Current Harmonics (%) ---")
|
||||||
|
ih = r["I_harmonics"]
|
||||||
|
for i, val in enumerate(ih):
|
||||||
|
if val > 0.001 or i == 0:
|
||||||
|
lines.append(f" H{i+1:2d}: {val:8.4f}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(" --- Sensor ---")
|
||||||
|
lines.append(f" Peak Signal: {r.get('PeakSignal', 0):10.0f}")
|
||||||
|
lines.append(f" Dark Signal: {r.get('DarkSignal', 0):10.0f}")
|
||||||
|
lines.append(f" Compensate: {r.get('Compensate', 0):10.0f}")
|
||||||
|
|
||||||
|
if show_spectrum and "spectrum" in r:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" --- Spectrum (380-1050nm) ---")
|
||||||
|
spectrum = r["spectrum"]
|
||||||
|
nm = r["spectrum_nm"]
|
||||||
|
peak_val = max(spectrum) if spectrum else 1
|
||||||
|
for i, (wl, val) in enumerate(zip(nm, spectrum)):
|
||||||
|
if i % 5 == 0: # Print every 5th point (~10nm spacing)
|
||||||
|
norm = val / peak_val if peak_val > 0 else 0
|
||||||
|
bar = "#" * int(norm * 40)
|
||||||
|
lines.append(f" {wl:7.1f}nm: {val:8.4f} [{bar}]")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_quick(r, reading_num=None):
|
||||||
|
"""Format a reading for quick test mode: lumen + CCT only."""
|
||||||
|
lm = r.get("Phi_lm", 0)
|
||||||
|
cct = r.get("CCT_K", 0)
|
||||||
|
prefix = f"#{reading_num} " if reading_num else ""
|
||||||
|
return f" {prefix}{lm:.1f} lm {cct:.0f} K"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="HPCS 6500 Spectrophotometer Reader")
|
||||||
|
parser.add_argument("--port", help="COM port (auto-detect if not specified)")
|
||||||
|
parser.add_argument("--continuous", action="store_true", help="Continuous reading mode")
|
||||||
|
parser.add_argument("--csv", metavar="FILE", help="Save readings to CSV file")
|
||||||
|
parser.add_argument("--spectrum", action="store_true", help="Show full spectrum data")
|
||||||
|
parser.add_argument("--passive", action="store_true",
|
||||||
|
help="Passive mode: read data without controlling instrument")
|
||||||
|
parser.add_argument("--quick", action="store_true",
|
||||||
|
help="Quick test mode: only report lumen and CCT")
|
||||||
|
parser.add_argument("--harmonics", action="store_true",
|
||||||
|
help="Show harmonics data (voltage/current THD and per-harmonic)")
|
||||||
|
parser.add_argument("--parse", metavar="PCAP", help="Parse a pcap capture file instead")
|
||||||
|
|
||||||
|
# Power supply control
|
||||||
|
psu = parser.add_argument_group("power supply")
|
||||||
|
psu.add_argument("--mode", choices=["ac", "dc"], help="Set output mode")
|
||||||
|
psu.add_argument("--voltage", type=float, help="Set output voltage (V)")
|
||||||
|
psu.add_argument("--frequency", type=float, help="Set AC frequency (Hz)")
|
||||||
|
psu.add_argument("--current", type=float, help="Set DC current limit (A)")
|
||||||
|
psu.add_argument("--integration", type=float, help="Set integration time (ms), 0=auto")
|
||||||
|
psu.add_argument("--psu-on", action="store_true", help="Turn PSU output on")
|
||||||
|
psu.add_argument("--psu-off", action="store_true", help="Turn PSU output off")
|
||||||
|
psu.add_argument("--no-psu", action="store_true",
|
||||||
|
help="Don't auto-control PSU during measurements")
|
||||||
|
psu.add_argument("--psu-status", action="store_true", help="Read power supply settings")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# --harmonics implies --spectrum for the detailed display
|
||||||
|
if args.harmonics:
|
||||||
|
args.spectrum = True
|
||||||
|
|
||||||
|
# Parse mode: analyze an existing capture
|
||||||
|
if args.parse:
|
||||||
|
messages = parse_pcap_messages(args.parse)
|
||||||
|
blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 3904
|
||||||
|
and m["data"][:8] == b"HPCS6500"]
|
||||||
|
config_blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 1584]
|
||||||
|
|
||||||
|
dev = HPCS6500.__new__(HPCS6500)
|
||||||
|
for i, block in enumerate(blocks):
|
||||||
|
result = dev._parse_measurement(block["data"])
|
||||||
|
if i < len(config_blocks):
|
||||||
|
result.update(dev._parse_electrical(config_blocks[i]["data"]))
|
||||||
|
if args.quick:
|
||||||
|
print(format_quick(result, i + 1))
|
||||||
|
else:
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Reading {i+1} (t={block['ts']:.2f}s)")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(format_reading(result, show_spectrum=args.spectrum))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find port
|
||||||
|
port = args.port or find_hpcs_port()
|
||||||
|
if not port:
|
||||||
|
print("ERROR: HPCS 6500 not found. Connect the device or specify --port.")
|
||||||
|
print("\nAvailable ports:")
|
||||||
|
for p in serial.tools.list_ports.comports():
|
||||||
|
print(f" {p.device}: {p.description} [{p.hwid}]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"HPCS 6500 Spectrophotometer Reader")
|
||||||
|
print(f"Port: {port}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
dev = HPCS6500(port)
|
||||||
|
|
||||||
|
# Identify
|
||||||
|
name = dev.identify()
|
||||||
|
if name:
|
||||||
|
print(f"Device: {name}")
|
||||||
|
else:
|
||||||
|
print("Warning: no identify response (device may be busy)")
|
||||||
|
|
||||||
|
# Power supply control
|
||||||
|
has_psu_cmd = any([args.mode, args.voltage is not None, args.frequency is not None,
|
||||||
|
args.current is not None, args.integration is not None,
|
||||||
|
args.psu_status, args.psu_on, args.psu_off])
|
||||||
|
|
||||||
|
if args.psu_status:
|
||||||
|
settings = dev.read_psu_settings()
|
||||||
|
if settings:
|
||||||
|
print(f"\nPower Supply Settings:")
|
||||||
|
print(f" Mode: {settings['mode']}")
|
||||||
|
print(f" AC Voltage: {settings['ac_voltage']:.0f} V")
|
||||||
|
print(f" AC Frequency: {settings['ac_frequency']:.0f} Hz")
|
||||||
|
print(f" DC Voltage: {settings['dc_voltage']:.1f} V")
|
||||||
|
print(f" DC Current: {settings['dc_current']:.1f} A")
|
||||||
|
else:
|
||||||
|
print("Failed to read PSU settings")
|
||||||
|
if not args.continuous and args.voltage is None and args.mode is None:
|
||||||
|
dev.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.mode:
|
||||||
|
ok = dev.set_mode(args.mode)
|
||||||
|
print(f"Set mode to {args.mode.upper()}: {'OK' if ok else 'FAILED'}")
|
||||||
|
|
||||||
|
if args.voltage is not None:
|
||||||
|
mode = args.mode or "ac"
|
||||||
|
if mode == "ac":
|
||||||
|
ok = dev.set_ac_voltage(args.voltage)
|
||||||
|
else:
|
||||||
|
ok = dev.set_dc_voltage(args.voltage)
|
||||||
|
print(f"Set {mode.upper()} voltage to {args.voltage} V: {'OK' if ok else 'FAILED'}")
|
||||||
|
|
||||||
|
if args.frequency is not None:
|
||||||
|
ok = dev.set_ac_frequency(args.frequency)
|
||||||
|
print(f"Set AC frequency to {args.frequency} Hz: {'OK' if ok else 'FAILED'}")
|
||||||
|
|
||||||
|
if args.current is not None:
|
||||||
|
ok = dev.set_dc_current(args.current)
|
||||||
|
print(f"Set DC current limit to {args.current} A: {'OK' if ok else 'FAILED'}")
|
||||||
|
|
||||||
|
if args.integration is not None:
|
||||||
|
ok = dev.set_integration_time(args.integration)
|
||||||
|
print(f"Set integration time to {args.integration} ms: {'OK' if ok else 'FAILED'}")
|
||||||
|
|
||||||
|
if args.psu_on:
|
||||||
|
ok = dev.psu_on()
|
||||||
|
print(f"PSU output ON: {'OK' if ok else 'FAILED'}")
|
||||||
|
|
||||||
|
if args.psu_off:
|
||||||
|
ok = dev.psu_off()
|
||||||
|
print(f"PSU output OFF: {'OK' if ok else 'FAILED'}")
|
||||||
|
|
||||||
|
# If only PSU commands were given with no measurement request, exit
|
||||||
|
if has_psu_cmd and not args.continuous and not args.passive:
|
||||||
|
dev.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
csv_writer = None
|
||||||
|
csv_file = None
|
||||||
|
if args.csv:
|
||||||
|
csv_file = open(args.csv, "w", newline="")
|
||||||
|
fieldnames = ["timestamp"] + list(FIELDS.keys()) + list(ELECTRICAL_FIELDS.keys())
|
||||||
|
csv_writer = csv.DictWriter(csv_file, fieldnames=fieldnames, extrasaction="ignore")
|
||||||
|
csv_writer.writeheader()
|
||||||
|
print(f"Saving to: {args.csv}")
|
||||||
|
|
||||||
|
use_psu = not args.no_psu and not args.passive
|
||||||
|
|
||||||
|
try:
|
||||||
|
if use_psu:
|
||||||
|
dev.psu_on()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
reading_num = 0
|
||||||
|
while True:
|
||||||
|
reading_num += 1
|
||||||
|
|
||||||
|
if not args.quick:
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Reading {reading_num} ({datetime.now().strftime('%H:%M:%S')})")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
if args.passive:
|
||||||
|
result = dev.read_current()
|
||||||
|
else:
|
||||||
|
result = dev.take_reading(psu=False)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
print("Failed to get reading")
|
||||||
|
if not args.continuous:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if args.quick:
|
||||||
|
print(format_quick(result, reading_num))
|
||||||
|
else:
|
||||||
|
print(format_reading(result, show_spectrum=args.spectrum))
|
||||||
|
|
||||||
|
if csv_writer:
|
||||||
|
row = {"timestamp": datetime.now().isoformat()}
|
||||||
|
row.update({k: v for k, v in result.items()
|
||||||
|
if isinstance(v, (int, float))})
|
||||||
|
csv_writer.writerow(row)
|
||||||
|
csv_file.flush()
|
||||||
|
|
||||||
|
if not args.continuous:
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopped.")
|
||||||
|
finally:
|
||||||
|
if use_psu:
|
||||||
|
dev.psu_off()
|
||||||
|
dev.reset()
|
||||||
|
dev.close()
|
||||||
|
if csv_file:
|
||||||
|
csv_file.close()
|
||||||
|
print(f"Data saved to: {args.csv}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[project]
|
||||||
|
name = "hpcs6500"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Open-source driver and CLI for the HPCS 6500 spectrophotometer / integrating sphere"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"pyserial>=3.5",
|
||||||
|
]
|
||||||
341
usb_capture.py
Normal file
341
usb_capture.py
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
"""
|
||||||
|
HPCS6500 USB Traffic Capture & Parser
|
||||||
|
|
||||||
|
1. Starts USBPcap to capture USB traffic from the HPCS6500 device
|
||||||
|
2. You use the vendor software normally while this runs
|
||||||
|
3. Press Ctrl+C to stop
|
||||||
|
4. Parses the .pcap file and extracts serial data (USB bulk transfers)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
USBPCAP_CMD = r"C:\Program Files\USBPcap\USBPcapCMD.exe"
|
||||||
|
CAPTURE_DIR = "captures"
|
||||||
|
DEVICE_ADDRESS = 4 # From registry: Address = 4
|
||||||
|
|
||||||
|
|
||||||
|
def find_usbpcap_devices():
|
||||||
|
"""Return all USBPcap devices (USBPcap1 through USBPcap10)."""
|
||||||
|
devices = []
|
||||||
|
for i in range(1, 11):
|
||||||
|
device = rf"\\.\USBPcap{i}"
|
||||||
|
# Check if the device path exists by trying a quick capture to NUL
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[USBPCAP_CMD, "-d", device, "-o", "-", "-A"],
|
||||||
|
capture_output=True, timeout=2,
|
||||||
|
)
|
||||||
|
devices.append(device)
|
||||||
|
print(f" {device} - available")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
devices.append(device)
|
||||||
|
print(f" {device} - available (active)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def start_capture(device, output_file):
|
||||||
|
"""Start USBPcap capture in background."""
|
||||||
|
cmd = [
|
||||||
|
USBPCAP_CMD,
|
||||||
|
"-d", device,
|
||||||
|
"-o", output_file,
|
||||||
|
"-A", # Capture from all devices on this hub (safe, we filter later)
|
||||||
|
"--devices", str(DEVICE_ADDRESS), # Only our device
|
||||||
|
"-s", "65535", # Max snapshot length
|
||||||
|
"-b", "1048576", # 1MB buffer
|
||||||
|
]
|
||||||
|
print(f"Starting capture: {' '.join(cmd)}")
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pcap(filepath):
|
||||||
|
"""Parse a pcap file with USBPcap headers and extract serial data."""
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
if len(data) < 24:
|
||||||
|
print("Capture file too small or empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse pcap global header
|
||||||
|
magic = struct.unpack_from("<I", data, 0)[0]
|
||||||
|
if magic == 0xA1B2C3D4:
|
||||||
|
endian = "<"
|
||||||
|
elif magic == 0xD4C3B2A1:
|
||||||
|
endian = ">"
|
||||||
|
else:
|
||||||
|
print(f"Unknown pcap magic: {hex(magic)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
ver_major, ver_minor = struct.unpack_from(f"{endian}HH", data, 4)
|
||||||
|
snaplen = struct.unpack_from(f"{endian}I", data, 16)[0]
|
||||||
|
link_type = struct.unpack_from(f"{endian}I", data, 20)[0]
|
||||||
|
print(f"PCAP: v{ver_major}.{ver_minor}, snaplen={snaplen}, link_type={link_type}")
|
||||||
|
|
||||||
|
# link_type 249 = USBPcap
|
||||||
|
if link_type != 249:
|
||||||
|
print(f"Warning: expected link_type 249 (USBPcap), got {link_type}")
|
||||||
|
|
||||||
|
offset = 24 # Start of first packet
|
||||||
|
packets = []
|
||||||
|
tx_data = bytearray() # Host -> Device (OUT)
|
||||||
|
rx_data = bytearray() # Device -> Host (IN)
|
||||||
|
|
||||||
|
pkt_num = 0
|
||||||
|
while offset + 16 <= len(data):
|
||||||
|
# pcap packet header: ts_sec(4), ts_usec(4), incl_len(4), orig_len(4)
|
||||||
|
ts_sec, ts_usec, incl_len, orig_len = struct.unpack_from(f"{endian}IIII", data, offset)
|
||||||
|
offset += 16
|
||||||
|
|
||||||
|
if offset + incl_len > len(data):
|
||||||
|
break
|
||||||
|
|
||||||
|
pkt_data = data[offset:offset + incl_len]
|
||||||
|
offset += incl_len
|
||||||
|
pkt_num += 1
|
||||||
|
|
||||||
|
# Parse USBPcap packet header (minimum 27 bytes)
|
||||||
|
if len(pkt_data) < 27:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# USBPcap header:
|
||||||
|
# headerLen (2 bytes LE)
|
||||||
|
# irpId (8 bytes)
|
||||||
|
# status (4 bytes)
|
||||||
|
# function (2 bytes) - URB function
|
||||||
|
# info (1 byte) - direction: bit 0: 0=OUT(host->dev), 1=IN(dev->host)
|
||||||
|
# bus (2 bytes)
|
||||||
|
# device (2 bytes)
|
||||||
|
# endpoint (1 byte) - bit 7 = direction, bits 0-3 = endpoint number
|
||||||
|
# transfer (1 byte) - 0=isoch, 1=interrupt, 2=control, 3=bulk
|
||||||
|
# dataLength (4 bytes)
|
||||||
|
|
||||||
|
hdr_len = struct.unpack_from("<H", pkt_data, 0)[0]
|
||||||
|
irp_id = struct.unpack_from("<Q", pkt_data, 2)[0]
|
||||||
|
status = struct.unpack_from("<I", pkt_data, 10)[0]
|
||||||
|
function = struct.unpack_from("<H", pkt_data, 14)[0]
|
||||||
|
info = pkt_data[16]
|
||||||
|
bus = struct.unpack_from("<H", pkt_data, 17)[0]
|
||||||
|
device = struct.unpack_from("<H", pkt_data, 19)[0]
|
||||||
|
endpoint = pkt_data[21]
|
||||||
|
transfer = pkt_data[22]
|
||||||
|
data_length = struct.unpack_from("<I", pkt_data, 23)[0]
|
||||||
|
|
||||||
|
# Payload starts after the USBPcap header
|
||||||
|
payload = pkt_data[hdr_len:]
|
||||||
|
|
||||||
|
if len(payload) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# We care about bulk transfers (transfer type 3) with actual data
|
||||||
|
transfer_types = {0: "ISOCH", 1: "INT", 2: "CTRL", 3: "BULK"}
|
||||||
|
transfer_name = transfer_types.get(transfer, f"UNK({transfer})")
|
||||||
|
|
||||||
|
direction = "IN" if (info & 1) else "OUT"
|
||||||
|
ep_num = endpoint & 0x0F
|
||||||
|
ep_dir = "IN" if (endpoint & 0x80) else "OUT"
|
||||||
|
|
||||||
|
ts = ts_sec + ts_usec / 1_000_000
|
||||||
|
|
||||||
|
# Log all packets with data
|
||||||
|
if transfer == 3 and len(payload) > 0: # Bulk transfers
|
||||||
|
packets.append({
|
||||||
|
"num": pkt_num,
|
||||||
|
"ts": ts,
|
||||||
|
"direction": direction,
|
||||||
|
"ep": endpoint,
|
||||||
|
"ep_dir": ep_dir,
|
||||||
|
"transfer": transfer_name,
|
||||||
|
"data": payload,
|
||||||
|
"status": status,
|
||||||
|
"device": device,
|
||||||
|
})
|
||||||
|
|
||||||
|
if direction == "IN" or ep_dir == "IN":
|
||||||
|
rx_data.extend(payload)
|
||||||
|
else:
|
||||||
|
tx_data.extend(payload)
|
||||||
|
|
||||||
|
print(f"\nParsed {pkt_num} total packets, {len(packets)} bulk transfers with data")
|
||||||
|
print(f"TX (host -> device): {len(tx_data)} bytes")
|
||||||
|
print(f"RX (device -> host): {len(rx_data)} bytes")
|
||||||
|
|
||||||
|
if not packets:
|
||||||
|
print("\nNo bulk transfer data found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Print timeline
|
||||||
|
print(f"\n{'='*80}")
|
||||||
|
print("TRAFFIC TIMELINE")
|
||||||
|
print(f"{'='*80}")
|
||||||
|
|
||||||
|
first_ts = packets[0]["ts"] if packets else 0
|
||||||
|
for pkt in packets[:100]: # First 100 packets
|
||||||
|
rel_ts = pkt["ts"] - first_ts
|
||||||
|
d = pkt["direction"]
|
||||||
|
payload = pkt["data"]
|
||||||
|
|
||||||
|
if d == "IN" or pkt["ep_dir"] == "IN":
|
||||||
|
color = "\033[92m" # green for device -> host
|
||||||
|
label = "DEV->PC"
|
||||||
|
else:
|
||||||
|
color = "\033[93m" # yellow for host -> device
|
||||||
|
label = "PC->DEV"
|
||||||
|
reset = "\033[0m"
|
||||||
|
|
||||||
|
hex_str = payload.hex(" ")
|
||||||
|
if len(hex_str) > 90:
|
||||||
|
hex_str = hex_str[:90] + f" ... (+{len(payload) - 30}B)"
|
||||||
|
|
||||||
|
ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in payload[:40])
|
||||||
|
|
||||||
|
print(f"{color}[{rel_ts:8.4f}] {label} EP{pkt['ep']:02X} ({len(payload):4d}B): {hex_str}{reset}")
|
||||||
|
# Show ASCII if useful
|
||||||
|
printable = sum(1 for b in payload if 32 <= b < 127)
|
||||||
|
if printable > len(payload) * 0.4:
|
||||||
|
print(f" ASCII: {ascii_str!r}")
|
||||||
|
|
||||||
|
if len(packets) > 100:
|
||||||
|
print(f" ... ({len(packets) - 100} more packets)")
|
||||||
|
|
||||||
|
# Save extracted serial data
|
||||||
|
if tx_data:
|
||||||
|
tx_file = filepath.replace(".pcap", "_TX.bin")
|
||||||
|
with open(tx_file, "wb") as f:
|
||||||
|
f.write(tx_data)
|
||||||
|
print(f"\nTX data saved to: {tx_file}")
|
||||||
|
|
||||||
|
if rx_data:
|
||||||
|
rx_file = filepath.replace(".pcap", "_RX.bin")
|
||||||
|
with open(rx_file, "wb") as f:
|
||||||
|
f.write(rx_data)
|
||||||
|
print(f"\nRX data saved to: {rx_file}")
|
||||||
|
|
||||||
|
# Quick analysis of the data
|
||||||
|
print(f"\n{'='*80}")
|
||||||
|
print("QUICK ANALYSIS")
|
||||||
|
print(f"{'='*80}")
|
||||||
|
|
||||||
|
for label, stream in [("TX (PC -> Device)", tx_data), ("RX (Device -> PC)", rx_data)]:
|
||||||
|
if not stream:
|
||||||
|
continue
|
||||||
|
print(f"\n--- {label}: {len(stream)} bytes ---")
|
||||||
|
printable = sum(1 for b in stream if 32 <= b < 127 or b in (0x0A, 0x0D))
|
||||||
|
print(f" Printable ratio: {printable/len(stream):.0%}")
|
||||||
|
|
||||||
|
# Show first 200 bytes
|
||||||
|
sample = bytes(stream[:200])
|
||||||
|
print(f" HEX: {sample.hex(' ')}")
|
||||||
|
print(f" ASCII: {''.join(chr(b) if 32 <= b < 127 else '.' for b in sample)}")
|
||||||
|
|
||||||
|
# Try to find repeating patterns
|
||||||
|
if len(stream) > 10:
|
||||||
|
# Look for common delimiters
|
||||||
|
for delim_name, delim in [("\\r\\n", b"\r\n"), ("\\n", b"\n"), ("0xFF", b"\xff"), ("0xAA", b"\xaa")]:
|
||||||
|
count = bytes(stream).count(delim)
|
||||||
|
if count > 2:
|
||||||
|
print(f" Delimiter {delim_name}: appears {count} times")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(CAPTURE_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "--parse":
|
||||||
|
# Parse mode: just parse an existing pcap file
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: usb_capture.py --parse <file.pcap>")
|
||||||
|
sys.exit(1)
|
||||||
|
parse_pcap(sys.argv[2])
|
||||||
|
return
|
||||||
|
|
||||||
|
print("HPCS6500 USB Traffic Capture")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Device address: {DEVICE_ADDRESS}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Find all USBPcap devices
|
||||||
|
print("Looking for USBPcap filter devices...")
|
||||||
|
devices = find_usbpcap_devices()
|
||||||
|
if not devices:
|
||||||
|
print("ERROR: No USBPcap devices found.")
|
||||||
|
print(" - You may need to REBOOT after installing USBPcap")
|
||||||
|
print(" - You must run this script as ADMINISTRATOR")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\nFound {len(devices)} USBPcap hub(s)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Try ALL hubs — capture from each one simultaneously
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
procs = []
|
||||||
|
|
||||||
|
for i, device in enumerate(devices):
|
||||||
|
pcap_file = os.path.join(CAPTURE_DIR, f"usb_capture_{timestamp}_hub{i+1}.pcap")
|
||||||
|
print(f"Starting capture on {device} -> {pcap_file}")
|
||||||
|
proc = start_capture(device, pcap_file)
|
||||||
|
procs.append((proc, device, pcap_file))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Capturing on ALL hubs simultaneously.")
|
||||||
|
print(">>> Now use the vendor software — take a measurement")
|
||||||
|
print(">>> Press Ctrl+C when done")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Check if any process died
|
||||||
|
all_dead = True
|
||||||
|
for proc, device, _ in procs:
|
||||||
|
if proc.poll() is None:
|
||||||
|
all_dead = False
|
||||||
|
if all_dead:
|
||||||
|
print("\nAll capture processes have exited.")
|
||||||
|
for proc, device, _ in procs:
|
||||||
|
stderr = proc.stderr.read().decode(errors="replace").strip()
|
||||||
|
print(f" {device}: exit code {proc.returncode}" +
|
||||||
|
(f" - {stderr}" if stderr else ""))
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nStopping captures...")
|
||||||
|
for proc, _, _ in procs:
|
||||||
|
proc.terminate()
|
||||||
|
for proc, _, _ in procs:
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
# Check which captures got data
|
||||||
|
print()
|
||||||
|
best_file = None
|
||||||
|
best_size = 0
|
||||||
|
for proc, device, pcap_file in procs:
|
||||||
|
if os.path.exists(pcap_file):
|
||||||
|
size = os.path.getsize(pcap_file)
|
||||||
|
print(f"{device} -> {pcap_file}: {size} bytes")
|
||||||
|
if size > best_size:
|
||||||
|
best_size = size
|
||||||
|
best_file = pcap_file
|
||||||
|
else:
|
||||||
|
print(f"{device} -> no file created")
|
||||||
|
|
||||||
|
if best_file and best_size > 24:
|
||||||
|
print(f"\nBest capture: {best_file} ({best_size} bytes)")
|
||||||
|
print("\nParsing capture...\n")
|
||||||
|
parse_pcap(best_file)
|
||||||
|
else:
|
||||||
|
print("\nNo USB data captured from any hub.")
|
||||||
|
print("Make sure you're running as ADMINISTRATOR.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user