first commit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 13:57:58 +07:00
commit 0f09c70215
7 changed files with 1595 additions and 0 deletions

20
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1 @@
3.13

299
PROTOCOL.md Normal file
View 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
View 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
View 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
View 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
View 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()