commit 0f09c702157678d1976788dd311373e83f53b2fb Author: grabowski Date: Wed Feb 4 13:57:58 2026 +0700 first commit Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fa5578 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..bc90564 --- /dev/null +++ b/PROTOCOL.md @@ -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 [payload...] +RX: 8C [data...] +``` + +Write commands receive a 2-byte ACK (`8C `). 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 -> Set output mode (00=AC, 01=DC) + 4. TX 8C 78/73 ... -> Configure voltage/frequency/current as needed + 5. TX 8C 01 -> 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 ` + +| 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 -> Set voltage (V), range 100-240 +TX: 8C 78 01 -> Set frequency (Hz), 50 or 60 +RX: 8C 78 -> ACK +``` + +### DC Parameters (`8C 73`) + +``` +TX: 8C 73 00 -> Set current limit (A), range 0-5 +TX: 8C 73 01 -> 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 -> 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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..ebd0488 --- /dev/null +++ b/README.md @@ -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. diff --git a/hpcs6500.py b/hpcs6500.py new file mode 100644 index 0000000..81e9488 --- /dev/null +++ b/hpcs6500.py @@ -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("" + 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(" deadline: + break + return data + + def _recv_response(self, cmd_id, timeout=3.0): + """Read a response, expecting it to start with 8C .""" + # 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 + 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("= ATHD_OFFSET + 4: + h1_v = struct.unpack_from(" 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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..212445f --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/usb_capture.py b/usb_capture.py new file mode 100644 index 0000000..0737ad4 --- /dev/null +++ b/usb_capture.py @@ -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(" 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(" 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 ") + 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()