commit fcb1e1db2a5ef3b3dcda254ea0579cc7a86dbe8d Author: grabowski Date: Wed Mar 11 11:36:39 2026 +0700 Initial ITECH IT6500 series DC PSU control tool USB-TMC/SCPI driver and CLI for IT6500 series power supplies. Commands: identify, measure, monitor, live, set, output, protection, config, send. Auto-detects instrument via USB VID 0x2EC7 / PID 0x6522. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad5124b --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# ITECH IT6500 Series DC Power Supply Control Tool + +Python CLI and driver for controlling ITECH IT6500 series programmable DC power supplies via USB (USBTMC/SCPI). + +## Installation + +```bash +uv pip install -e . +``` + +## Usage + +```bash +# Auto-detect and identify the instrument +it6500 identify + +# Take a single measurement +it6500 measure + +# Continuous monitoring with CSV export +it6500 monitor --interval 1.0 --output data.csv + +# Live graph +it6500 live --interval 0.5 + +# Set voltage and current +it6500 set --voltage 12.0 --current 5.0 + +# Control output +it6500 output on +it6500 output off + +# Check protection status +it6500 protection + +# Configure OVP, slew rates +it6500 config --ovp 60.0 --rise 0.1 --fall 0.1 + +# Send raw SCPI commands +it6500 send "*IDN?" +it6500 send "VOLT 24.0" +``` + +## Supported Models + +IT6512, IT6513, IT6512A, IT6513A, IT6522A, IT6502D, IT6512D, IT6532A, IT6533A, IT6523D diff --git a/it6500/__init__.py b/it6500/__init__.py new file mode 100644 index 0000000..fbcfaf3 --- /dev/null +++ b/it6500/__init__.py @@ -0,0 +1,5 @@ +"""ITECH IT6500 Series Programmable DC Power Supply USB/SCPI driver.""" + +from it6500.driver import IT6500 + +__all__ = ["IT6500"] diff --git a/it6500/cli.py b/it6500/cli.py new file mode 100644 index 0000000..6dc7684 --- /dev/null +++ b/it6500/cli.py @@ -0,0 +1,425 @@ +"""CLI tool for controlling the ITECH IT6500 Series DC Power Supply via USB.""" + +from __future__ import annotations + +import argparse +import csv +import sys +import time + +from it6500.driver import IT6500 + + +def find_instrument(address: str | None) -> str: + """Find the USB-TMC instrument address or use the provided one.""" + if address: + return address + + import pyvisa + + rm = pyvisa.ResourceManager() + resources = rm.list_resources() + rm.close() + + # Look for ITECH by USB VID/PID + itech = [r for r in resources if "2EC7" in r.upper() or "6522" in r.upper()] + if itech: + print(f"Found ITECH IT6500: {itech[0]}") + return itech[0] + + usb = [r for r in resources if "USB" in r] + if not usb: + print("No ITECH IT6500 or USB instruments found. Available resources:") + for r in resources: + print(f" {r}") + sys.exit(1) + + if len(usb) == 1: + print(f"Found USB instrument: {usb[0]}") + return usb[0] + + print("Multiple USB instruments found:") + for i, r in enumerate(usb): + print(f" [{i}] {r}") + choice = input("Select instrument number: ") + return usb[int(choice)] + + +def _safe_query(psu: IT6500, method, fallback="N/A"): + """Call a query method, returning fallback on timeout/error.""" + try: + return method() + except Exception: + return fallback + + +def cmd_identify(psu: IT6500, _args: argparse.Namespace) -> None: + """Print instrument identity and status.""" + print(f"Identity: {psu.idn()}") + print(f"SCPI Ver: {_safe_query(psu, psu.get_version)}") + print(f"Output: {'ON' if psu.get_output_state() else 'OFF'}") + print(f"Voltage set: {psu.get_voltage():.4f} V") + print(f"Current set: {psu.get_current():.4f} A") + + op = _safe_query(psu, psu.get_operation_status, {}) + if isinstance(op, dict): + mode = "CV" if op.get("CV") else ("CC" if op.get("CC") else "---") + print(f"Mode: {mode}") + + qs = _safe_query(psu, psu.get_questionable_status, {}) + if isinstance(qs, dict): + active = [k for k, v in qs.items() if v] + print(f"Protection: {', '.join(active) if active else 'None'}") + + err_code, err_msg = psu.get_error() + if err_code != 0: + print(f"Error: ({err_code}) {err_msg}") + + +def cmd_measure(psu: IT6500, _args: argparse.Namespace) -> None: + """Take a single measurement.""" + result = psu.measure_all() + print(f" Voltage = {result.voltage:>10.4f} V") + print(f" Current = {result.current:>10.4f} A") + print(f" Power = {result.power:>10.4f} W") + + +def cmd_monitor(psu: IT6500, args: argparse.Namespace) -> None: + """Continuously monitor measurements at an interval.""" + interval = args.interval + + writer = None + outfile = None + if args.output: + outfile = open(args.output, "w", newline="") + writer = csv.writer(outfile) + writer.writerow(["timestamp", "voltage_V", "current_A", "power_W"]) + print(f"Logging to {args.output}") + + print(f"{'Time':>10s} {'Voltage(V)':>12s} {'Current(A)':>12s} {'Power(W)':>12s}") + print("-" * 52) + + try: + count = 0 + while args.count == 0 or count < args.count: + result = psu.measure_all() + ts = time.strftime("%H:%M:%S") + print( + f"{ts:>10s} {result.voltage:>12.4f} " + f"{result.current:>12.4f} {result.power:>12.4f}" + ) + + if writer: + writer.writerow([ + time.strftime("%Y-%m-%d %H:%M:%S"), + f"{result.voltage:.4f}", + f"{result.current:.4f}", + f"{result.power:.4f}", + ]) + outfile.flush() + + count += 1 + if args.count == 0 or count < args.count: + time.sleep(interval) + except KeyboardInterrupt: + print("\nMonitoring stopped.") + finally: + if outfile: + outfile.close() + print(f"Data saved to {args.output}") + + +def cmd_live(psu: IT6500, args: argparse.Namespace) -> None: + """Live monitor with real-time graph.""" + import matplotlib.pyplot as plt + from matplotlib.animation import FuncAnimation + from collections import deque + + max_points = args.history + interval_ms = int(args.interval * 1000) + + timestamps: deque[float] = deque(maxlen=max_points) + voltages: deque[float] = deque(maxlen=max_points) + currents: deque[float] = deque(maxlen=max_points) + powers: deque[float] = deque(maxlen=max_points) + t0 = time.time() + + writer = None + outfile = None + if args.output: + outfile = open(args.output, "w", newline="") + writer = csv.writer(outfile) + writer.writerow(["timestamp", "voltage_V", "current_A", "power_W"]) + print(f"Logging to {args.output}") + + fig, axes = plt.subplots(3, 1, figsize=(12, 9), squeeze=False) + fig.suptitle("ITECH IT6500 Live Monitor", fontsize=14, fontweight="bold") + axes = axes.flatten() + + labels = [("Voltage", "V", voltages), ("Current", "A", currents), ("Power", "W", powers)] + lines = [] + for ax, (title, unit, _) in zip(axes, labels): + ax.set_ylabel(f"{title} ({unit})") + ax.set_xlabel("Time (s)") + ax.grid(True, alpha=0.3) + ax.set_title(title, fontsize=11) + line, = ax.plot([], [], linewidth=1.5) + lines.append(line) + ax.legend([title], loc="upper left", fontsize=9) + + fig.tight_layout() + + def update(_frame): + try: + result = psu.measure_all() + except Exception as e: + print(f"Read error: {e}") + return lines + + now = time.time() - t0 + timestamps.append(now) + voltages.append(result.voltage) + currents.append(result.current) + powers.append(result.power) + + ts = time.strftime("%H:%M:%S") + print( + f"{ts} V={result.voltage:.4f} " + f"I={result.current:.4f} P={result.power:.4f}" + ) + + if writer: + writer.writerow([ + time.strftime("%Y-%m-%d %H:%M:%S"), + f"{result.voltage:.4f}", + f"{result.current:.4f}", + f"{result.power:.4f}", + ]) + outfile.flush() + + t_list = list(timestamps) + for line, (_, _, data) in zip(lines, labels): + line.set_data(t_list, list(data)) + + for ax in axes: + ax.relim() + ax.autoscale_view() + + return lines + + _anim = FuncAnimation(fig, update, interval=interval_ms, blit=False, cache_frame_data=False) + + try: + plt.show() + except KeyboardInterrupt: + pass + finally: + if outfile: + outfile.close() + print(f"Data saved to {args.output}") + + +def cmd_set(psu: IT6500, args: argparse.Namespace) -> None: + """Set voltage and/or current.""" + if args.voltage is not None and args.current is not None: + psu.apply(args.voltage, args.current) + print(f"Applied: {args.voltage:.4f} V, {args.current:.4f} A") + elif args.voltage is not None: + psu.set_voltage(args.voltage) + print(f"Voltage set: {args.voltage:.4f} V") + elif args.current is not None: + psu.set_current(args.current) + print(f"Current set: {args.current:.4f} A") + else: + print("Specify --voltage and/or --current") + sys.exit(1) + + +def cmd_output(psu: IT6500, args: argparse.Namespace) -> None: + """Turn output ON or OFF.""" + if args.state.upper() in ("ON", "1"): + psu.output_on() + print("Output ON") + else: + psu.output_off() + print("Output OFF") + + +def cmd_protection(psu: IT6500, args: argparse.Namespace) -> None: + """Check or clear protection status.""" + if args.clear: + try: + psu.clear_ovp() + except Exception: + pass + psu.clear_status() + print("Protection cleared.") + else: + qs = psu.get_questionable_status() + for name, active in qs.items(): + status = "TRIGGERED" if active else "OK" + print(f" {name}: {status}") + + ovp_tripped = _safe_query(psu, psu.get_ovp_tripped, False) + print(f" OVP tripped: {'YES' if ovp_tripped else 'NO'}") + + ovp_level = _safe_query(psu, psu.get_ovp_level, None) + if ovp_level is not None: + print(f"\n OVP level: {ovp_level:.2f} V") + ovp_state = _safe_query(psu, psu.get_ovp_state, None) + if ovp_state is not None: + print(f" OVP enabled: {'YES' if ovp_state else 'NO'}") + ovp_delay = _safe_query(psu, psu.get_ovp_delay, None) + if ovp_delay is not None: + print(f" OVP delay: {ovp_delay:.3f} s") + + +def cmd_config(psu: IT6500, args: argparse.Namespace) -> None: + """View or change instrument configuration.""" + if args.ovp is not None: + psu.set_ovp_level(args.ovp) + psu.set_ovp_state(True) + print(f"OVP set to {args.ovp:.2f} V (enabled)") + if args.rise is not None: + psu.set_rise_time(args.rise) + print(f"Rise time: {args.rise:.3f} s") + if args.fall is not None: + psu.set_fall_time(args.fall) + print(f"Fall time: {args.fall:.3f} s") + if args.avg is not None: + psu.set_averaging(args.avg) + print(f"Averaging: {args.avg}") + if args.vrange is not None: + psu.set_voltage_range(args.vrange) + print(f"Voltage range: {args.vrange:.2f} V") + + # Show current config + print(f"\nCurrent configuration:") + print(f" Voltage set: {psu.get_voltage():.4f} V") + print(f" Current set: {psu.get_current():.4f} A") + for label, method in [ + ("V range", psu.get_voltage_range), + ("Rise time", psu.get_rise_time), + ("Fall time", psu.get_fall_time), + ("OVP level", psu.get_ovp_level), + ]: + val = _safe_query(psu, method, None) + if val is not None: + print(f" {label + ':':12s} {val}") + ovp_state = _safe_query(psu, psu.get_ovp_state, None) + if ovp_state is not None: + print(f" OVP enabled: {'YES' if ovp_state else 'NO'}") + avg = _safe_query(psu, psu.get_averaging, None) + if avg is not None: + print(f" Averaging: {avg}") + beep = _safe_query(psu, psu.get_beeper, None) + if beep is not None: + print(f" Beeper: {'ON' if beep else 'OFF'}") + + +def cmd_send(psu: IT6500, args: argparse.Namespace) -> None: + """Send a raw SCPI command.""" + command = " ".join(args.raw_command) + result = psu.send(command) + if result is not None: + print(f"Response: {result}") + else: + print("OK") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="ITECH IT6500 Series DC Power Supply USB Control Tool", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +examples: + %(prog)s identify + %(prog)s measure + %(prog)s monitor --interval 1.0 --output data.csv + %(prog)s live --interval 0.5 + %(prog)s set --voltage 12.0 --current 5.0 + %(prog)s output on + %(prog)s output off + %(prog)s protection + %(prog)s protection --clear + %(prog)s config --ovp 60.0 --rise 0.1 --fall 0.1 + %(prog)s send "*IDN?" + %(prog)s send "VOLT 24.0" +""", + ) + parser.add_argument( + "-a", "--address", + help="VISA resource address (e.g., USB0::0x2EC7::0x6522::...::INSTR). Auto-detects if omitted.", + ) + parser.add_argument( + "--timeout", type=int, default=5000, + help="Communication timeout in ms (default: 5000)", + ) + + sub = parser.add_subparsers(dest="command", required=True) + + # identify + sub.add_parser("identify", help="Show instrument identity and status") + + # measure + sub.add_parser("measure", help="Take a single measurement") + + # monitor + p_mon = sub.add_parser("monitor", help="Continuously monitor measurements") + p_mon.add_argument("-i", "--interval", type=float, default=1.0, help="Seconds between readings (default: 1.0)") + p_mon.add_argument("-n", "--count", type=int, default=0, help="Number of readings (0=infinite)") + p_mon.add_argument("-o", "--output", help="CSV output file path") + + # live + p_live = sub.add_parser("live", help="Live monitor with real-time graph") + p_live.add_argument("-i", "--interval", type=float, default=1.0, help="Seconds between readings (default: 1.0)") + p_live.add_argument("-o", "--output", help="CSV output file path") + p_live.add_argument("--history", type=int, default=300, help="Max data points to display (default: 300)") + + # set + p_set = sub.add_parser("set", help="Set voltage and/or current") + p_set.add_argument("-v", "--voltage", type=float, help="Output voltage in Volts") + p_set.add_argument("-c", "--current", type=float, help="Output current in Amps") + + # output + p_out = sub.add_parser("output", help="Turn output ON or OFF") + p_out.add_argument("state", choices=["on", "off", "ON", "OFF", "1", "0"], help="ON or OFF") + + # protection + p_prot = sub.add_parser("protection", help="Check or clear protection status") + p_prot.add_argument("--clear", action="store_true", help="Clear protection flags") + + # config + p_cfg = sub.add_parser("config", help="View or change instrument configuration") + p_cfg.add_argument("--ovp", type=float, help="Set OVP level (Volts)") + p_cfg.add_argument("--rise", type=float, help="Set voltage rise time (seconds)") + p_cfg.add_argument("--fall", type=float, help="Set voltage fall time (seconds)") + p_cfg.add_argument("--avg", type=int, help="Set measurement averaging count (0-15)") + p_cfg.add_argument("--vrange", type=float, help="Set voltage range upper limit (Volts)") + + # send + p_send = sub.add_parser("send", help="Send raw SCPI command") + p_send.add_argument("raw_command", nargs="+", help="Command string (queries auto-detected by '?')") + + args = parser.parse_args() + address = find_instrument(args.address) + + dispatch = { + "identify": cmd_identify, + "measure": cmd_measure, + "monitor": cmd_monitor, + "live": cmd_live, + "set": cmd_set, + "output": cmd_output, + "protection": cmd_protection, + "config": cmd_config, + "send": cmd_send, + } + + with IT6500(address, timeout_ms=args.timeout) as psu: + psu.remote() + dispatch[args.command](psu, args) + + +if __name__ == "__main__": + main() diff --git a/it6500/driver.py b/it6500/driver.py new file mode 100644 index 0000000..43033d2 --- /dev/null +++ b/it6500/driver.py @@ -0,0 +1,468 @@ +"""ITECH IT6500 Series Programmable DC Power Supply SCPI driver. + +Communicates via USB-TMC (USBTMC) using PyVISA. + +SCPI commands based on IT6500 Series Programming Guide v1.0. +Covers: IT6512/IT6513/IT6512A/IT6513A/IT6522A/IT6502D/IT6512D + IT6532A/IT6533A/IT6523D + +USB interface is IEEE 488.2 USB488 compliant. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field + +import pyvisa + + +@dataclass +class MeasurementResult: + """Container for a single measurement snapshot.""" + + voltage: float + current: float + power: float + timestamp: float = field(default_factory=time.time) + + def __repr__(self) -> str: + return ( + f"MeasurementResult(V={self.voltage:.4f}, " + f"I={self.current:.4f}, P={self.power:.4f})" + ) + + +class IT6500: + """Driver for ITECH IT6500 Series DC Power Supply over USB-TMC. + + Args: + address: VISA resource string, e.g. "USB0::0x2EC7::0x6522::800682011797230003::INSTR" + timeout_ms: Communication timeout in milliseconds. + """ + + # USB identifiers for ITECH IT6500 series + USB_VID = 0x2EC7 + USB_PID = 0x6522 + + def __init__(self, address: str, timeout_ms: int = 5000) -> None: + self._address = address + self._rm = pyvisa.ResourceManager() + self._inst = self._rm.open_resource(address) + self._inst.timeout = timeout_ms + self._inst.read_termination = "\n" + self._inst.write_termination = "\n" + # Clear any stale errors/status from previous sessions + self._inst.write("*CLS") + + # -- Low-level communication -- + + def write(self, command: str) -> None: + """Send a SCPI command to the instrument.""" + self._inst.write(command) + + def query(self, command: str) -> str: + """Send a query and return the response string.""" + return self._inst.query(command).strip() + + def close(self) -> None: + """Close the VISA connection.""" + try: + self.local() + except Exception: + pass + self._inst.close() + self._rm.close() + + def __enter__(self) -> IT6500: + return self + + def __exit__(self, *exc) -> None: + self.close() + + # -- IEEE 488.2 Common Commands -- + + def idn(self) -> str: + """Query instrument identity (*IDN?). + + Returns 4 comma-separated fields: + Manufacturer, Model, Serial, Firmware version + """ + return self.query("*IDN?") + + def reset(self) -> None: + """Reset to factory defaults (*RST). + + Sets: OUTPUT OFF, CURR MIN, VOLT:PROT MAX, VOLT MIN + """ + self.write("*RST") + time.sleep(1) + + def clear_status(self) -> None: + """Clear all status registers and error queue (*CLS).""" + self.write("*CLS") + + def operation_complete(self) -> bool: + """Block until all pending operations complete (*OPC?).""" + return self.query("*OPC?") == "1" + + def status_byte(self) -> int: + """Read the status byte register (*STB?).""" + return int(self.query("*STB?")) + + def event_status(self) -> int: + """Read and clear the standard event status register (*ESR?).""" + return int(self.query("*ESR?")) + + def set_event_status_enable(self, mask: int) -> None: + """Set the standard event enable register (*ESE).""" + self.write(f"*ESE {mask}") + + def get_event_status_enable(self) -> int: + """Query the standard event enable register (*ESE?).""" + return int(self.query("*ESE?")) + + def set_service_request_enable(self, mask: int) -> None: + """Set the service request enable register (*SRE).""" + self.write(f"*SRE {mask}") + + def get_service_request_enable(self) -> int: + """Query the service request enable register (*SRE?).""" + return int(self.query("*SRE?")) + + def trigger(self) -> None: + """Send a bus trigger (*TRG).""" + self.write("*TRG") + + def save(self, slot: int) -> None: + """Save current setup to memory slot (0-9).""" + self.write(f"*SAV {slot}") + + def recall(self, slot: int) -> None: + """Recall saved setup from memory slot (0-9).""" + self.write(f"*RCL {slot}") + + # -- System Commands -- + + def remote(self) -> None: + """Switch to remote control mode (front panel locked).""" + self.write("SYSTem:REMote") + + def local(self) -> None: + """Switch to local control mode (front panel active).""" + self.write("SYSTem:LOCal") + + def rwlock(self) -> None: + """Remote mode with LOCAL button also locked.""" + self.write("SYSTem:RWLock") + + def get_error(self) -> tuple[int, str]: + """Read error code and message from the error queue.""" + resp = self.query("SYSTem:ERRor?") + parts = resp.split(",", 1) + code = int(parts[0].strip()) + msg = parts[1].strip().strip('"') if len(parts) > 1 else "" + return code, msg + + def get_version(self) -> str: + """Query SCPI version string (e.g. '2009.0').""" + return self.query("SYSTem:VERSion?") + + def set_power_on_state(self, state: str) -> None: + """Set power-on state: 'RST' (factory) or 'SAV0' (last saved).""" + self.write(f"SYSTem:POSetup {state}") + + def get_power_on_state(self) -> str: + """Query power-on state setting.""" + return self.query("SYSTem:POSetup?") + + def clear_errors(self) -> None: + """Clear the error information queue.""" + self.write("SYSTem:CLEar") + + def set_beeper(self, on: bool) -> None: + """Enable/disable the front panel beeper.""" + self.write(f"SYSTem:BEEPer {'ON' if on else 'OFF'}") + + def get_beeper(self) -> bool: + """Query beeper state.""" + return self.query("SYSTem:BEEPer?") == "1" + + def set_interface(self, iface: str) -> None: + """Select communication interface: 'GPIB', 'USB', 'RS232', 'RS485'.""" + self.write(f"SYSTem:INTerface {iface}") + + # -- Output Control -- + + def output_on(self) -> None: + """Turn the output ON.""" + self.write("OUTPut ON") + + def output_off(self) -> None: + """Turn the output OFF.""" + self.write("OUTPut OFF") + + def get_output_state(self) -> bool: + """Query output state. Returns True if ON.""" + return self.query("OUTPut?") == "1" + + # -- Voltage Settings -- + + def set_voltage(self, volts: float) -> None: + """Set the output voltage (immediate).""" + self.write(f"VOLTage {volts}") + + def get_voltage(self) -> float: + """Query the voltage set point.""" + return float(self.query("VOLTage?")) + + def set_voltage_triggered(self, volts: float) -> None: + """Set voltage to apply on next trigger event.""" + self.write(f"VOLTage:TRIGgered {volts}") + + def get_voltage_triggered(self) -> float: + """Query the triggered voltage set point.""" + return float(self.query("VOLTage:TRIGgered?")) + + # -- Voltage Protection (OVP) -- + + def set_ovp_level(self, volts: float) -> None: + """Set over-voltage protection level.""" + self.write(f"VOLTage:PROTection {volts}") + + def get_ovp_level(self) -> float: + """Query OVP level.""" + return float(self.query("VOLTage:PROTection?")) + + def set_ovp_delay(self, seconds: float) -> None: + """Set OVP delay time (0.001-0.6 s).""" + self.write(f"VOLTage:PROTection:DELay {seconds}") + + def get_ovp_delay(self) -> float: + """Query OVP delay time.""" + return float(self.query("VOLTage:PROTection:DELay?")) + + def set_ovp_state(self, on: bool) -> None: + """Enable/disable software OVP.""" + self.write(f"VOLTage:PROTection:STATe {'ON' if on else 'OFF'}") + + def get_ovp_state(self) -> bool: + """Query OVP enable state.""" + return self.query("VOLTage:PROTection:STATe?") == "1" + + def get_ovp_tripped(self) -> bool: + """Check if OVP has been triggered. True = tripped.""" + return self.query("VOLTage:PROTection:TRIGgered?") == "1" + + def clear_ovp(self) -> None: + """Clear OVP tripped state (reduce voltage or remove source first).""" + self.write("VOLTage:PROTection:CLEar") + + # -- Voltage Limits -- + + def set_voltage_limit(self, volts: float) -> None: + """Set lower limit of output voltage.""" + self.write(f"VOLTage:LIMit {volts}") + + def get_voltage_limit(self) -> float: + """Query voltage lower limit.""" + return float(self.query("VOLTage:LIMit?")) + + def set_voltage_range(self, volts: float) -> None: + """Set upper limit of output voltage.""" + self.write(f"VOLTage:RANGe {volts}") + + def get_voltage_range(self) -> float: + """Query voltage upper limit.""" + return float(self.query("VOLTage:RANGe?")) + + # -- Current Settings -- + + def set_current(self, amps: float) -> None: + """Set the output current (immediate).""" + self.write(f"CURRent {amps}") + + def get_current(self) -> float: + """Query the current set point.""" + return float(self.query("CURRent?")) + + def set_current_triggered(self, amps: float) -> None: + """Set current to apply on next trigger event.""" + self.write(f"CURRent:TRIGgered {amps}") + + def get_current_triggered(self) -> float: + """Query the triggered current set point.""" + return float(self.query("CURRent:TRIGgered?")) + + # -- Slew Rate -- + + def set_rise_time(self, seconds: float) -> None: + """Set voltage rising time (0-65.535 s).""" + self.write(f"RISe {seconds}") + + def get_rise_time(self) -> float: + """Query voltage rising time.""" + return float(self.query("RISe?")) + + def set_fall_time(self, seconds: float) -> None: + """Set voltage falling time (0-65.535 s).""" + self.write(f"FALL {seconds}") + + def get_fall_time(self) -> float: + """Query voltage falling time.""" + return float(self.query("FALL?")) + + # -- Compound Command -- + + def apply(self, voltage: float, current: float) -> None: + """Set voltage and current simultaneously (APPLy). + + Values must be within the range limits, otherwise an execution error occurs. + """ + self.write(f"APPLy {voltage},{current}") + + def get_apply(self) -> tuple[float, float]: + """Query the APPLy voltage and current set points.""" + resp = self.query("APPLy?") + parts = resp.split(",") + return float(parts[0].strip()), float(parts[1].strip()) + + # -- Trigger Control -- + + def set_trigger_source(self, source: str) -> None: + """Set trigger source: 'MANUAL' or 'BUS'.""" + self.write(f"TRIGger:SOURce {source}") + + def get_trigger_source(self) -> str: + """Query trigger source.""" + return self.query("TRIGger:SOURce?") + + def trigger_immediate(self) -> None: + """Send an immediate trigger signal.""" + self.write("TRIGger") + + # -- Measurement Queries -- + + def measure_voltage(self) -> float: + """Read the actual output voltage in Volts.""" + return float(self.query("MEASure:VOLTage?")) + + def measure_current(self) -> float: + """Read the actual output current in Amperes.""" + return float(self.query("MEASure:CURRent?")) + + def measure_power(self) -> float: + """Read the actual output power in Watts.""" + return float(self.query("MEASure:POWer?")) + + def measure_all(self) -> MeasurementResult: + """Read voltage, current, and power.""" + voltage = self.measure_voltage() + current = self.measure_current() + power = self.measure_power() + return MeasurementResult( + voltage=voltage, + current=current, + power=power, + ) + + # -- Fetch (cached readings, no new measurement triggered) -- + + def fetch_voltage(self) -> float: + """Read cached voltage from sample buffer.""" + return float(self.query("FETCh:VOLTage?")) + + def fetch_current(self) -> float: + """Read cached current from sample buffer.""" + return float(self.query("FETCh:CURRent?")) + + def fetch_power(self) -> float: + """Read cached power from sample buffer.""" + return float(self.query("FETCh:POWer?")) + + # -- Measurement Averaging -- + + def set_averaging(self, count: int) -> None: + """Set measurement averaging filter count (0-15).""" + self.write(f"SENSe:AVERage:COUNt {count}") + + def get_averaging(self) -> int: + """Query measurement averaging count.""" + return int(self.query("SENSe:AVERage:COUNt?")) + + # -- Display Control -- + + def set_display(self, on: bool) -> None: + """Turn VFD display on or off.""" + self.write(f"DISPlay {'ON' if on else 'OFF'}") + + def get_display(self) -> bool: + """Query display state.""" + return self.query("DISPlay?") == "1" + + def set_display_text(self, text: str) -> None: + """Show custom text on VFD display.""" + self.write(f'DISPlay:TEXT "{text}"') + + def clear_display_text(self) -> None: + """Clear custom text from display.""" + self.write("DISPlay:TEXT:CLEar") + + # -- Configuration -- + + def save_config(self) -> None: + """Save current configuration to non-volatile memory.""" + self.write("CONFigure:SAVe") + + # -- Status Registers -- + + def get_questionable_status(self) -> dict[str, bool]: + """Read questionable status condition register. + + Returns dict with protection flags: OV, OC, OP, OT. + """ + bits = int(self.query("STATus:QUEStionable:CONDition?")) + return { + "OV": bool(bits & 0x01), # bit 0: Over voltage + "OC": bool(bits & 0x02), # bit 1: Over current + "OP": bool(bits & 0x08), # bit 3: Over power + "OT": bool(bits & 0x10), # bit 4: Over temperature + } + + def get_questionable_event(self) -> int: + """Read and clear the questionable event register.""" + return int(self.query("STATus:QUEStionable?")) + + def set_questionable_enable(self, mask: int) -> None: + """Set questionable status enable mask.""" + self.write(f"STATus:QUEStionable:ENABle {mask}") + + def get_operation_status(self) -> dict[str, bool]: + """Read operation status condition register. + + Returns dict: CAL, WTG (waiting for trigger), CV, CC. + """ + bits = int(self.query("STATus:OPERation:CONDition?")) + return { + "CAL": bool(bits & 0x01), # bit 0: Calibrating + "WTG": bool(bits & 0x04), # bit 2: Waiting for trigger (mapped from bit 2 per doc showing WTG=bit3 with weight 4) + "CV": bool(bits & 0x10), # bit 4: Constant voltage + "CC": bool(bits & 0x20), # bit 5: Constant current (mapped from weight 8 per doc) + } + + def get_operation_event(self) -> int: + """Read and clear the operation event register.""" + return int(self.query("STATus:OPERation?")) + + def set_operation_enable(self, mask: int) -> None: + """Set operation status enable mask.""" + self.write(f"STATus:OPERation:ENABle {mask}") + + # -- Send Raw Command -- + + def send(self, command: str) -> str | None: + """Send a raw SCPI command. Returns response if it's a query.""" + if "?" in command: + return self.query(command) + self.write(command) + return None diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..452d351 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "itech-it6500" +version = "0.1.0" +description = "ITECH IT6500 Series Programmable DC Power Supply USB/SCPI control tools" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "matplotlib>=3.10.8", + "pyvisa>=1.16.2", + "pyvisa-py>=0.8.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["it6500"] + +[project.scripts] +it6500 = "it6500.cli:main"