diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-24 11:51:26 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-24 11:51:26 -0600 |
| commit | 67cfa0a514c7de4605ed7360e15a81aa781e510e (patch) | |
| tree | 297dfeca4adff1e65df4c9f2e5d7fb49a8b44ba0 | |
| parent | 4244e5d97da0273fed70f734a4cfe822bd15c9cb (diff) | |
Added difference serial protocols
66 files changed, 1374 insertions, 142 deletions
diff --git a/api_layers/__pycache__/__init__.cpython-312.pyc b/api_layers/__pycache__/__init__.cpython-312.pyc Binary files differindex a9a035b..689f52e 100644 --- a/api_layers/__pycache__/__init__.cpython-312.pyc +++ b/api_layers/__pycache__/__init__.cpython-312.pyc diff --git a/api_layers/__pycache__/__init__.cpython-314.pyc b/api_layers/__pycache__/__init__.cpython-314.pyc Binary files differindex 01ab34b..d30ccf5 100644 --- a/api_layers/__pycache__/__init__.cpython-314.pyc +++ b/api_layers/__pycache__/__init__.cpython-314.pyc diff --git a/api_layers/__pycache__/arduino_layer.cpython-312.pyc b/api_layers/__pycache__/arduino_layer.cpython-312.pyc Binary files differindex 1222766..a0191ad 100644 --- a/api_layers/__pycache__/arduino_layer.cpython-312.pyc +++ b/api_layers/__pycache__/arduino_layer.cpython-312.pyc diff --git a/api_layers/__pycache__/arduino_layer.cpython-314.pyc b/api_layers/__pycache__/arduino_layer.cpython-314.pyc Binary files differindex d40bc88..1cfec4e 100644 --- a/api_layers/__pycache__/arduino_layer.cpython-314.pyc +++ b/api_layers/__pycache__/arduino_layer.cpython-314.pyc diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc Binary files differindex 8dda62e..d310f54 100644 --- a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc +++ b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc Binary files differindex c11967d..6902c49 100644 --- a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc +++ b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc diff --git a/api_layers/__pycache__/port_registry.cpython-312.pyc b/api_layers/__pycache__/port_registry.cpython-312.pyc Binary files differindex 088d6d3..48e7384 100644 --- a/api_layers/__pycache__/port_registry.cpython-312.pyc +++ b/api_layers/__pycache__/port_registry.cpython-312.pyc diff --git a/api_layers/protocols/__init__.py b/api_layers/protocols/__init__.py new file mode 100644 index 0000000..6fda325 --- /dev/null +++ b/api_layers/protocols/__init__.py @@ -0,0 +1 @@ +"""api_layers/protocols — serial instrument protocol layers.""" diff --git a/api_layers/protocols/__pycache__/__init__.cpython-312.pyc b/api_layers/protocols/__pycache__/__init__.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..e3d3dea --- /dev/null +++ b/api_layers/protocols/__pycache__/__init__.cpython-312.pyc diff --git a/api_layers/protocols/__pycache__/__init__.cpython-314.pyc b/api_layers/protocols/__pycache__/__init__.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..c12aff7 --- /dev/null +++ b/api_layers/protocols/__pycache__/__init__.cpython-314.pyc diff --git a/api_layers/protocols/__pycache__/base_protocol.cpython-312.pyc b/api_layers/protocols/__pycache__/base_protocol.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..faba1c8 --- /dev/null +++ b/api_layers/protocols/__pycache__/base_protocol.cpython-312.pyc diff --git a/api_layers/protocols/__pycache__/base_protocol.cpython-314.pyc b/api_layers/protocols/__pycache__/base_protocol.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..7f87d61 --- /dev/null +++ b/api_layers/protocols/__pycache__/base_protocol.cpython-314.pyc diff --git a/api_layers/protocols/__pycache__/cml.cpython-312.pyc b/api_layers/protocols/__pycache__/cml.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..fa580d6 --- /dev/null +++ b/api_layers/protocols/__pycache__/cml.cpython-312.pyc diff --git a/api_layers/protocols/__pycache__/cml.cpython-314.pyc b/api_layers/protocols/__pycache__/cml.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..84c94a2 --- /dev/null +++ b/api_layers/protocols/__pycache__/cml.cpython-314.pyc diff --git a/api_layers/protocols/__pycache__/mark10.cpython-312.pyc b/api_layers/protocols/__pycache__/mark10.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..f31dcb9 --- /dev/null +++ b/api_layers/protocols/__pycache__/mark10.cpython-312.pyc diff --git a/api_layers/protocols/__pycache__/mark10.cpython-314.pyc b/api_layers/protocols/__pycache__/mark10.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..c2ce6dc --- /dev/null +++ b/api_layers/protocols/__pycache__/mark10.cpython-314.pyc diff --git a/api_layers/protocols/__pycache__/modbus_rtu.cpython-312.pyc b/api_layers/protocols/__pycache__/modbus_rtu.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..1947863 --- /dev/null +++ b/api_layers/protocols/__pycache__/modbus_rtu.cpython-312.pyc diff --git a/api_layers/protocols/__pycache__/modbus_rtu.cpython-314.pyc b/api_layers/protocols/__pycache__/modbus_rtu.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..dd104cb --- /dev/null +++ b/api_layers/protocols/__pycache__/modbus_rtu.cpython-314.pyc diff --git a/api_layers/protocols/__pycache__/scpi.cpython-312.pyc b/api_layers/protocols/__pycache__/scpi.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..e2a2e83 --- /dev/null +++ b/api_layers/protocols/__pycache__/scpi.cpython-312.pyc diff --git a/api_layers/protocols/__pycache__/scpi.cpython-314.pyc b/api_layers/protocols/__pycache__/scpi.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..971d9ed --- /dev/null +++ b/api_layers/protocols/__pycache__/scpi.cpython-314.pyc diff --git a/api_layers/protocols/base_protocol.py b/api_layers/protocols/base_protocol.py new file mode 100644 index 0000000..096151d --- /dev/null +++ b/api_layers/protocols/base_protocol.py @@ -0,0 +1,120 @@ +""" +api_layers/protocols/base_protocol.py + +Abstract base for serial instrument protocol layers. + +Background thread calls _poll() at poll_interval and stores results in +self._cache — same threading model as ArduinoLayer. +Simulation uses _simulate(t) instead. +""" + +import threading +import time +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Tuple + + +class BaseProtocol(ABC): + + def __init__( + self, + port: str, + baud: int, + poll_interval: float = 0.1, + simulate: bool = True, + ): + self.port = port + self.baud = baud + self.poll_interval = poll_interval + self.simulate = simulate + + self._cache: Dict[str, float] = {} + self._lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + self._ser = None + + # ── Public API ──────────────────────────────────────────────────────── + + def connect(self) -> bool: + if self.simulate: + self._running = True + self._thread = threading.Thread(target=self._sim_loop, daemon=True) + self._thread.start() + return True + try: + import serial + self._ser = serial.Serial( + self.port, self.baud, timeout=1.0, + **self._serial_kwargs(), + ) + time.sleep(0.05) + self._running = True + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + return True + except Exception as e: + print(f"[{type(self).__name__}] connect failed on {self.port}: {e}") + return False + + def disconnect(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=2.0) + self._thread = None + if self._ser: + try: + self._ser.close() + except Exception: + pass + self._ser = None + + def read(self) -> Dict[str, float]: + with self._lock: + return dict(self._cache) + + @classmethod + def list_ports(cls) -> List[Tuple[str, str]]: + try: + import serial.tools.list_ports + return [(p.device, p.description) for p in serial.tools.list_ports.comports()] + except ImportError: + return [] + + # ── Internal ────────────────────────────────────────────────────────── + + def _serial_kwargs(self) -> dict: + return {} + + def _poll_loop(self) -> None: + while self._running: + try: + result = self._poll() + if result: + with self._lock: + self._cache.update(result) + except Exception as e: + print(f"[{type(self).__name__}] poll error: {e}") + time.sleep(self.poll_interval) + + def _sim_loop(self) -> None: + t0 = time.time() + while self._running: + t = time.time() - t0 + with self._lock: + self._cache = self._simulate(t) + time.sleep(self.poll_interval) + + # ── Abstract ────────────────────────────────────────────────────────── + + @abstractmethod + def _poll(self) -> Dict[str, float]: + """Read instrument, return {channel_id: value}.""" + + @abstractmethod + def _simulate(self, t: float) -> Dict[str, float]: + """Return simulated values at elapsed time t (seconds).""" + + @abstractmethod + def write(self, channel_id: str, value) -> bool: + """Send command to instrument.""" diff --git a/api_layers/protocols/cml.py b/api_layers/protocols/cml.py new file mode 100644 index 0000000..dbee2fa --- /dev/null +++ b/api_layers/protocols/cml.py @@ -0,0 +1,139 @@ +""" +api_layers/protocols/cml.py + +CoolMuscle Language (CML) protocol layer. + +For CoolMuscle CM-series servo motors over RS-232 (single axis) +or RS-485 (multi-drop, up to 31 axes). + +Frame format +───────────── + RS-232 (address=0): + Command: <CMD>[<data>]\\r + Response: <CMD>[<data>]\\r (echo-back) + + RS-485 (address 1-31): + Command: #<addr><CMD>[<data>]\\r + Response: *<addr><CMD>[<data>]\\r + +Read commands (query current state): + TP — Tell absolute position (encoder counts, signed) + TV — Tell velocity (counts/second, signed) + TC — Tell current (% rated × 10, unsigned) + TS — Tell status word (hex flags) + +Write commands (control): + ME — Motor Enable + MD — Motor Disable + MA<value> — Move Absolute (encoder counts) + MR<value> — Move Relative (encoder counts) + VS<value> — Velocity Setpoint + +Channel IDs follow pattern: <motor_id>_<CMD> + e.g. "M1_TP", "M1_TV", "M2_TC" +""" + +import math +import re +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from api_layers.protocols.base_protocol import BaseProtocol + + +_READ_CMDS = ["TP", "TV", "TC", "TS"] +_RESP_RE = re.compile(r"[*@]?\d*([A-Z]{2})([\s\S]*)") +_NUM_RE = re.compile(r"[+-]?\d+") + + +@dataclass +class CMLMotor: + motor_id: str + address: int = 1 # 0 = RS-232 (no address prefix) + read_cmds: List[str] = field(default_factory=lambda: ["TP", "TV", "TC"]) + + +class CMLLayer(BaseProtocol): + + def __init__( + self, + port: str, + baud: int = 38400, + motors: List[CMLMotor] = None, + poll_interval: float = 0.1, + simulate: bool = True, + ): + super().__init__(port, baud, poll_interval, simulate) + self.motors = motors or [CMLMotor("M1", address=1)] + + # ── Protocol ────────────────────────────────────────────────────────── + + def _send_query(self, motor: CMLMotor, cmd: str) -> Optional[float]: + if motor.address == 0: + frame = f"{cmd}\r".encode() + else: + frame = f"#{motor.address}{cmd}\r".encode() + self._ser.write(frame) + self._ser.flush() + resp = self._ser.readline().decode(errors="replace").strip() + return _parse_cml_response(resp) + + def _poll(self) -> Dict[str, float]: + result: Dict[str, float] = {} + for motor in self.motors: + for cmd in motor.read_cmds: + try: + val = self._send_query(motor, cmd) + if val is not None: + result[f"{motor.motor_id}_{cmd}"] = val + except Exception: + pass + return result + + def _simulate(self, t: float) -> Dict[str, float]: + result: Dict[str, float] = {} + for i, motor in enumerate(self.motors): + phase = i * 1.0 + for cmd in motor.read_cmds: + key = f"{motor.motor_id}_{cmd}" + if cmd == "TP": + val = math.sin(t * 0.2 + phase) * 10000.0 + elif cmd == "TV": + val = math.cos(t * 0.2 + phase) * 2000.0 + elif cmd == "TC": + val = abs(math.sin(t * 0.4 + phase)) * 500.0 + elif cmd == "TS": + val = 0.0 + else: + val = math.sin(t + i) * 100.0 + result[key] = val + return result + + def write(self, channel_id: str, value) -> bool: + # channel_id: "<motor_id>_<CMD>[<data>]" + # e.g. "M1_ME", "M1_MA" (value carries position) + parts = channel_id.split("_", 1) + if len(parts) != 2: + return False + motor_id, cmd = parts + motor = next((m for m in self.motors if m.motor_id == motor_id), None) + if motor is None: + return False + try: + data = "" if cmd in ("ME", "MD") else str(int(value)) + if motor.address == 0: + frame = f"{cmd}{data}\r".encode() + else: + frame = f"#{motor.address}{cmd}{data}\r".encode() + self._ser.write(frame) + return True + except Exception: + return False + + +def _parse_cml_response(resp: str) -> Optional[float]: + """Extract numeric value from CML response like '*1TP+001234'.""" + m = _NUM_RE.search(resp[3:] if resp and resp[0] in "*@#" else resp) + if m: + return float(m.group()) + return None diff --git a/api_layers/protocols/mark10.py b/api_layers/protocols/mark10.py new file mode 100644 index 0000000..1b5bf1c --- /dev/null +++ b/api_layers/protocols/mark10.py @@ -0,0 +1,95 @@ +""" +api_layers/protocols/mark10.py + +Mark-10 ASCII protocol layer (Series 5 primary, compatible with Series 4/3). + +Commands (sent with CR terminator): + ? — request current reading + Z — zero the gauge + U — cycle units (lb → kgF → N → ozF → …) + +Response format: "+0.1234 kgF" (sign, value, space, unit suffix) + +Channels exposed: + force — current force reading (in instrument's selected unit) + unit_code — numeric index into UNITS list (lb=0, kgF=1, N=2, ozF=3) +""" + +import math +import re +from typing import Dict, Optional + +from api_layers.protocols.base_protocol import BaseProtocol + + +UNITS = ["lb", "kgF", "N", "ozF"] + +_RESP_RE = re.compile(r"([+-]?\d+\.?\d*(?:[eE][+-]?\d+)?)\s*([a-zA-Z]*)") + + +class Mark10Layer(BaseProtocol): + + def __init__( + self, + port: str, + baud: int = 115200, + poll_interval: float = 0.05, + simulate: bool = True, + ): + super().__init__(port, baud, poll_interval, simulate) + self._unit = "N" + + # ── Protocol ────────────────────────────────────────────────────────── + + def _poll(self) -> Dict[str, float]: + try: + self._ser.write(b"?\r") + resp = self._ser.readline().decode(errors="replace").strip() + return self._parse(resp) + except Exception: + return {} + + def _parse(self, resp: str) -> Dict[str, float]: + m = _RESP_RE.search(resp) + if not m: + return {} + val = float(m.group(1)) + unit = m.group(2).strip() or self._unit + if unit in UNITS: + self._unit = unit + return { + "force": val, + "unit_code": float(UNITS.index(self._unit) if self._unit in UNITS else 2), + } + + def _simulate(self, t: float) -> Dict[str, float]: + cycle = t % 30.0 + ramp = cycle * 6.67 if cycle < 15.0 else (30.0 - cycle) * 6.67 + return { + "force": ramp + math.sin(t * 10.0) * 0.3, + "unit_code": float(UNITS.index("N")), + } + + def write(self, channel_id: str, value) -> bool: + cmd_map = { + "zero": b"Z\r", + "cycle_units": b"U\r", + } + cmd = cmd_map.get(channel_id) + if cmd is None or not self._ser: + return False + try: + self._ser.write(cmd) + return True + except Exception: + return False + + # ── Convenience ─────────────────────────────────────────────────────── + + def zero(self) -> None: + if self._ser: + self._ser.write(b"Z\r") + + def cycle_units(self) -> None: + if self._ser: + self._ser.write(b"U\r") diff --git a/api_layers/protocols/modbus_rtu.py b/api_layers/protocols/modbus_rtu.py new file mode 100644 index 0000000..9d371b1 --- /dev/null +++ b/api_layers/protocols/modbus_rtu.py @@ -0,0 +1,158 @@ +""" +api_layers/protocols/modbus_rtu.py + +Modbus RTU over RS-232 / RS-485. Pure Python — no external library. + +Supported function codes: + FC03 Read Holding Registers + FC04 Read Input Registers + FC06 Write Single Register + +Register data types: + uint16 — unsigned 16-bit (1 register) + int16 — signed 16-bit (1 register) + float32 — IEEE-754 float (2 registers, big-endian) + int32 — signed 32-bit (2 registers, big-endian) +""" + +import math +import struct +import time +from dataclasses import dataclass +from typing import Dict, List, Optional + +from api_layers.protocols.base_protocol import BaseProtocol + + +# ── CRC-16 (Modbus polynomial 0xA001) ──────────────────────────────────────── + +def _crc16(data: bytes) -> int: + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 + return crc + + +def _frame(payload: bytes) -> bytes: + return payload + struct.pack("<H", _crc16(payload)) + + +# ── Channel definition ──────────────────────────────────────────────────────── + +@dataclass +class ModbusChannel: + channel_id: str + name: str + register: int # 0-based register address + function_code: int = 0x03 # FC03=holding, FC04=input + data_type: str = "uint16" # uint16 | int16 | float32 | int32 + scale: float = 1.0 + offset: float = 0.0 + unit: str = "" + + +# ── Protocol layer ──────────────────────────────────────────────────────────── + +class ModbusRTULayer(BaseProtocol): + + def __init__( + self, + port: str, + baud: int = 9600, + slave_addr: int = 1, + channels: List[ModbusChannel] = None, + parity: str = "N", # N / E / O + stopbits: int = 1, + poll_interval: float = 0.1, + simulate: bool = True, + ): + super().__init__(port, baud, poll_interval, simulate) + self.slave_addr = slave_addr + self.channels = channels or [] + self._parity = parity + self._stopbits = stopbits + + def _serial_kwargs(self) -> dict: + import serial + parity_map = { + "N": serial.PARITY_NONE, + "E": serial.PARITY_EVEN, + "O": serial.PARITY_ODD, + } + return { + "parity": parity_map.get(self._parity, serial.PARITY_NONE), + "stopbits": self._stopbits, + "bytesize": 8, + } + + # ── Read ────────────────────────────────────────────────────────────── + + def _read_registers(self, fc: int, start: int, count: int) -> List[int]: + req = _frame(struct.pack(">BBHH", self.slave_addr, fc, start, count)) + self._ser.write(req) + time.sleep(0.005) + n_bytes = 5 + 2 * count + resp = self._ser.read(n_bytes) + if len(resp) < n_bytes: + raise IOError(f"Short response {len(resp)}/{n_bytes} bytes") + crc_recv = struct.unpack("<H", resp[-2:])[0] + if crc_recv != _crc16(resp[:-2]): + raise ValueError("CRC mismatch") + n_data = resp[2] + return list(struct.unpack(f">{n_data // 2}H", resp[3:3 + n_data])) + + def _decode(self, ch: ModbusChannel, regs: List[int]) -> float: + dt = ch.data_type + if dt == "uint16": + raw = regs[0] + elif dt == "int16": + raw = regs[0] if regs[0] < 0x8000 else regs[0] - 0x10000 + elif dt in ("float32", "int32"): + combined = (regs[0] << 16) | regs[1] + if dt == "float32": + raw = struct.unpack(">f", struct.pack(">I", combined))[0] + else: + raw = combined if combined < 0x80000000 else combined - 0x100000000 + else: + raw = regs[0] + return float(raw) * ch.scale + ch.offset + + def _poll(self) -> Dict[str, float]: + result: Dict[str, float] = {} + for ch in self.channels: + count = 2 if ch.data_type in ("float32", "int32") else 1 + try: + regs = self._read_registers(ch.function_code, ch.register, count) + result[ch.channel_id] = self._decode(ch, regs) + except Exception: + pass + return result + + def _simulate(self, t: float) -> Dict[str, float]: + return { + ch.channel_id: ( + (math.sin(t * (0.5 + i * 0.3)) * 100 + 100) * ch.scale + ch.offset + ) + for i, ch in enumerate(self.channels) + } + + # ── Write ───────────────────────────────────────────────────────────── + + def _write_register(self, register: int, value: int) -> bool: + req = _frame(struct.pack(">BBHH", self.slave_addr, 0x06, register, value & 0xFFFF)) + self._ser.write(req) + time.sleep(0.005) + resp = self._ser.read(8) + return len(resp) == 8 + + def write(self, channel_id: str, value) -> bool: + ch = next((c for c in self.channels if c.channel_id == channel_id), None) + if ch is None: + return False + try: + int_val = round((float(value) - ch.offset) / ch.scale) + return self._write_register(ch.register, int_val) + except Exception: + return False diff --git a/api_layers/protocols/scpi.py b/api_layers/protocols/scpi.py new file mode 100644 index 0000000..730f8e0 --- /dev/null +++ b/api_layers/protocols/scpi.py @@ -0,0 +1,102 @@ +""" +api_layers/protocols/scpi.py + +SCPI (Standard Commands for Programmable Instruments) protocol layer. + +Each channel maps to a query string. The layer sends each query in +sequence and parses the numeric response. + +Terminator: LF (\\n) by default; instruments also accept CR+LF. +Response parsing strips unit suffixes — "+3.14159 V" → 3.14159. + +Example channel setup: + SCPIChannel("V1", "Voltage", query="MEAS:VOLT?", unit="V") + SCPIChannel("I1", "Current", query="MEAS:CURR?", unit="A") + SCPIChannel("T1", "Temp", query="SENS:TEMP:DATA?", unit="°C") +""" + +import math +import re +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from api_layers.protocols.base_protocol import BaseProtocol + + +@dataclass +class SCPIChannel: + channel_id: str + name: str + query: str # e.g. "MEAS:VOLT?" or "MEAS:VOLT? (@1)" + unit: str = "" + scale: float = 1.0 + write_cmd: str = "" # e.g. "VOLT {value}" — set to send writes + + +class SCPILayer(BaseProtocol): + + def __init__( + self, + port: str, + baud: int = 9600, + channels: List[SCPIChannel] = None, + poll_interval: float = 0.2, + simulate: bool = True, + ): + super().__init__(port, baud, poll_interval, simulate) + self.channels = channels or [] + + # ── Protocol ────────────────────────────────────────────────────────── + + def _poll(self) -> Dict[str, float]: + result: Dict[str, float] = {} + for ch in self.channels: + if not ch.query.strip(): + continue + try: + self._ser.write(f"{ch.query.strip()}\n".encode()) + resp = self._ser.readline().decode(errors="replace").strip() + val = _parse_numeric(resp) + if val is not None: + result[ch.channel_id] = val * ch.scale + except Exception: + pass + return result + + def _simulate(self, t: float) -> Dict[str, float]: + return { + ch.channel_id: math.sin(t * (0.3 + i * 0.2)) * 5.0 + i * 2.0 + for i, ch in enumerate(self.channels) + } + + def write(self, channel_id: str, value) -> bool: + ch = next((c for c in self.channels if c.channel_id == channel_id), None) + if ch is None or not ch.write_cmd: + return False + try: + cmd = ch.write_cmd.format(value=value) + self._ser.write(f"{cmd}\n".encode()) + return True + except Exception: + return False + + def query_idn(self) -> str: + """Send *IDN? and return instrument identification string.""" + if not self._ser: + return "(not connected)" + try: + self._ser.write(b"*IDN?\n") + return self._ser.readline().decode(errors="replace").strip() + except Exception as e: + return f"(error: {e})" + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +_NUM_RE = re.compile(r"[+-]?\d+\.?\d*(?:[eE][+-]?\d+)?") + + +def _parse_numeric(resp: str) -> Optional[float]: + """Extract first float from SCPI response, ignoring unit suffixes.""" + m = _NUM_RE.search(resp) + return float(m.group()) if m else None diff --git a/core/__pycache__/__init__.cpython-312.pyc b/core/__pycache__/__init__.cpython-312.pyc Binary files differindex 91f5de3..79fc26c 100644 --- a/core/__pycache__/__init__.cpython-312.pyc +++ b/core/__pycache__/__init__.cpython-312.pyc diff --git a/core/__pycache__/acquisition.cpython-312.pyc b/core/__pycache__/acquisition.cpython-312.pyc Binary files differindex cb5f3f3..d1b61fc 100644 --- a/core/__pycache__/acquisition.cpython-312.pyc +++ b/core/__pycache__/acquisition.cpython-312.pyc diff --git a/core/__pycache__/profile.cpython-312.pyc b/core/__pycache__/profile.cpython-312.pyc Binary files differindex 208b48b..9105704 100644 --- a/core/__pycache__/profile.cpython-312.pyc +++ b/core/__pycache__/profile.cpython-312.pyc diff --git a/core/__pycache__/signal_processor.cpython-312.pyc b/core/__pycache__/signal_processor.cpython-312.pyc Binary files differindex 4572e55..505a573 100644 --- a/core/__pycache__/signal_processor.cpython-312.pyc +++ b/core/__pycache__/signal_processor.cpython-312.pyc diff --git a/devices/__pycache__/__init__.cpython-312.pyc b/devices/__pycache__/__init__.cpython-312.pyc Binary files differindex 0e0929f..88e8062 100644 --- a/devices/__pycache__/__init__.cpython-312.pyc +++ b/devices/__pycache__/__init__.cpython-312.pyc diff --git a/devices/__pycache__/__init__.cpython-314.pyc b/devices/__pycache__/__init__.cpython-314.pyc Binary files differindex f1080d7..fb1a2a7 100644 --- a/devices/__pycache__/__init__.cpython-314.pyc +++ b/devices/__pycache__/__init__.cpython-314.pyc diff --git a/devices/__pycache__/analog_input.cpython-312.pyc b/devices/__pycache__/analog_input.cpython-312.pyc Binary files differindex f22ffd6..931b605 100644 --- a/devices/__pycache__/analog_input.cpython-312.pyc +++ b/devices/__pycache__/analog_input.cpython-312.pyc diff --git a/devices/__pycache__/analog_input.cpython-314.pyc b/devices/__pycache__/analog_input.cpython-314.pyc Binary files differindex 1df8300..d82bc07 100644 --- a/devices/__pycache__/analog_input.cpython-314.pyc +++ b/devices/__pycache__/analog_input.cpython-314.pyc diff --git a/devices/__pycache__/base_device.cpython-312.pyc b/devices/__pycache__/base_device.cpython-312.pyc Binary files differindex 087e500..d5604d7 100644 --- a/devices/__pycache__/base_device.cpython-312.pyc +++ b/devices/__pycache__/base_device.cpython-312.pyc diff --git a/devices/__pycache__/base_device.cpython-314.pyc b/devices/__pycache__/base_device.cpython-314.pyc Binary files differindex b8f94f4..fa99267 100644 --- a/devices/__pycache__/base_device.cpython-314.pyc +++ b/devices/__pycache__/base_device.cpython-314.pyc diff --git a/devices/__pycache__/cml_device.cpython-312.pyc b/devices/__pycache__/cml_device.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..ded108c --- /dev/null +++ b/devices/__pycache__/cml_device.cpython-312.pyc diff --git a/devices/__pycache__/cml_device.cpython-314.pyc b/devices/__pycache__/cml_device.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..4eae5e4 --- /dev/null +++ b/devices/__pycache__/cml_device.cpython-314.pyc diff --git a/devices/__pycache__/device_registry.cpython-312.pyc b/devices/__pycache__/device_registry.cpython-312.pyc Binary files differindex 3495e35..276f1e2 100644 --- a/devices/__pycache__/device_registry.cpython-312.pyc +++ b/devices/__pycache__/device_registry.cpython-312.pyc diff --git a/devices/__pycache__/device_registry.cpython-314.pyc b/devices/__pycache__/device_registry.cpython-314.pyc Binary files differindex f3f7d3e..6912654 100644 --- a/devices/__pycache__/device_registry.cpython-314.pyc +++ b/devices/__pycache__/device_registry.cpython-314.pyc diff --git a/devices/__pycache__/digital_io.cpython-312.pyc b/devices/__pycache__/digital_io.cpython-312.pyc Binary files differindex 1907cb3..c0262d5 100644 --- a/devices/__pycache__/digital_io.cpython-312.pyc +++ b/devices/__pycache__/digital_io.cpython-312.pyc diff --git a/devices/__pycache__/digital_io.cpython-314.pyc b/devices/__pycache__/digital_io.cpython-314.pyc Binary files differindex 1b438fc..d51aaf5 100644 --- a/devices/__pycache__/digital_io.cpython-314.pyc +++ b/devices/__pycache__/digital_io.cpython-314.pyc diff --git a/devices/__pycache__/mark10_device.cpython-312.pyc b/devices/__pycache__/mark10_device.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..3148ba6 --- /dev/null +++ b/devices/__pycache__/mark10_device.cpython-312.pyc diff --git a/devices/__pycache__/mark10_device.cpython-314.pyc b/devices/__pycache__/mark10_device.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..ca0bdd5 --- /dev/null +++ b/devices/__pycache__/mark10_device.cpython-314.pyc diff --git a/devices/__pycache__/modbus_device.cpython-312.pyc b/devices/__pycache__/modbus_device.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..8e59813 --- /dev/null +++ b/devices/__pycache__/modbus_device.cpython-312.pyc diff --git a/devices/__pycache__/modbus_device.cpython-314.pyc b/devices/__pycache__/modbus_device.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..7d32d53 --- /dev/null +++ b/devices/__pycache__/modbus_device.cpython-314.pyc diff --git a/devices/__pycache__/scpi_device.cpython-312.pyc b/devices/__pycache__/scpi_device.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..87750b8 --- /dev/null +++ b/devices/__pycache__/scpi_device.cpython-312.pyc diff --git a/devices/__pycache__/scpi_device.cpython-314.pyc b/devices/__pycache__/scpi_device.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..65af1b5 --- /dev/null +++ b/devices/__pycache__/scpi_device.cpython-314.pyc diff --git a/devices/__pycache__/serial_device.cpython-312.pyc b/devices/__pycache__/serial_device.cpython-312.pyc Binary files differindex 53ba302..e885b40 100644 --- a/devices/__pycache__/serial_device.cpython-312.pyc +++ b/devices/__pycache__/serial_device.cpython-312.pyc diff --git a/devices/__pycache__/serial_device.cpython-314.pyc b/devices/__pycache__/serial_device.cpython-314.pyc Binary files differindex 0bbe0bc..976e575 100644 --- a/devices/__pycache__/serial_device.cpython-314.pyc +++ b/devices/__pycache__/serial_device.cpython-314.pyc diff --git a/devices/serial_device.py b/devices/serial_device.py index 6f54cb5..78f74a2 100644 --- a/devices/serial_device.py +++ b/devices/serial_device.py @@ -1,27 +1,34 @@ """ devices/serial_device.py -Generic Serial / UART device module. - -Uses ArduinoLayer for communication, but works with ANY instrument -that sends newline-terminated data. Configurable parse formats: - • "csv" – plain comma-separated values mapped to channels in order - • "key:val" – "CH0:1.23,CH1:4.56" key-colon-value pairs - • "json" – {"CH0":1.23,"CH1":4.56} - -Switch format in the config widget without restarting. +Generic Serial / UART device. + +Parse formats (select in config widget): + Generic stream (uses ArduinoLayer): + key:val — "CH0:1.23,CH1:4.56" + csv — "1.23,4.56" (positional) + json — {"CH0":1.23,"CH1":4.56} + + Instrument protocols (uses api_layers/protocols/): + scpi — SCPI query/response (configurable per-channel queries) + modbus_rtu — Modbus RTU binary (configurable register map) + mark10 — ASCII Mark-10 (Series 5 tensile tester) + cml — ASCII CML (CoolMuscle servo motors) """ -import json +from __future__ import annotations + import math -import random import threading import time -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional +from PyQt6.QtCore import Qt, QThread, pyqtSignal from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QFormLayout, QGroupBox, - QComboBox, QLineEdit, QSpinBox, QLabel, QPushButton, + QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QGroupBox, + QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, + QListWidgetItem, QPushButton, QScrollArea, QSpinBox, + QStackedWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus @@ -30,6 +37,34 @@ from api_layers.arduino_layer import ArduinoLayer _COLORS = ["#7fff6e", "#4cc9f0", "#f72585", "#00d4ff", "#ffcc00", "#c77dff", "#ff6b35", "#38b000"] +# Human-readable label → internal format key +_FORMAT_LABELS: Dict[str, str] = { + "Key:Value (Arduino)": "key:val", + "CSV": "csv", + "JSON": "json", + "SCPI": "scpi", + "Modbus RTU": "modbus_rtu", + "ASCII Mark-10": "mark10", + "ASCII CML": "cml", +} +_LABEL_FOR: Dict[str, str] = {v: k for k, v in _FORMAT_LABELS.items()} + +_GENERIC_FORMATS = {"key:val", "csv", "json"} +_PROTOCOL_FORMATS = {"scpi", "modbus_rtu", "mark10", "cml"} + +# Index into the config-widget stacked panel +_STACK_IDX = { + "key:val": 0, + "csv": 0, + "json": 0, + "scpi": 1, + "modbus_rtu": 2, + "mark10": 3, + "cml": 4, +} + + +# ── SerialDevice ────────────────────────────────────────────────────────────── class SerialDevice(BaseDevice): DEVICE_TYPE = "serial" @@ -37,51 +72,62 @@ class SerialDevice(BaseDevice): def __init__( self, - device_id: str = "ser_0", - port: str = "COM3", - baud_rate: int = 115200, - num_channels: int = 4, - channel_names: List[str] = None, - units: List[str] = None, - parse_format: str = "key:val", # "csv" | "key:val" | "json" - simulate: bool = True, + device_id: str = "ser_0", + port: str = "COM3", + baud_rate: int = 115200, + parse_format: str = "key:val", + simulate: bool = True, + # ── Generic stream options ────────────────────────────────────── + num_channels: int = 4, + channel_names: List[str] = None, + units: List[str] = None, + # ── SCPI ─────────────────────────────────────────────────────── + scpi_channels: List[Dict] = None, + # ── Modbus RTU ───────────────────────────────────────────────── + slave_addr: int = 1, + parity: str = "N", + stopbits: int = 1, + modbus_channels: List[Dict] = None, + # ── CML ──────────────────────────────────────────────────────── + motors: List[Dict] = None, ): self._port = port self._baud = baud_rate - self._parse_format = parse_format + self._fmt = parse_format self.simulate = simulate - names = channel_names or [f"CH{i}" for i in range(num_channels)] - _units = units or ["" for _ in range(num_channels)] + # Generic + self._num_channels = num_channels + self._chan_names = channel_names + self._units = units - channels = [ - ChannelConfig( - channel_id=names[i], name=names[i], unit=_units[i], - min_value=0.0, max_value=1023.0, - color=_COLORS[i % len(_COLORS)], - ) - for i in range(num_channels) - ] + # SCPI + self._scpi_channels: List[Dict] = scpi_channels or _default_scpi() + + # Modbus + self._slave_addr = slave_addr + self._parity = parity + self._stopbits = stopbits + self._mb_channels: List[Dict] = modbus_channels or _default_modbus() + # CML + self._motors: List[Dict] = motors or _default_motors() + + channels = self._build_channel_configs() info = DeviceInfo( - device_id=device_id, name="Serial / UART", + device_id=device_id, + name="Serial / UART", device_type=self.DEVICE_TYPE, - description=f"{port} @ {baud_rate}", - icon=self.ICON, channels=channels, + description=self._description(), + icon=self.ICON, + channels=channels, ) super().__init__(info) + self._layer = self._make_layer() - self._layer = ArduinoLayer( - port=port, baud=baud_rate, - analog_pins=[ch.channel_id for ch in channels], - simulate=simulate, - ) - self._t0 = 0.0 - - # ── BaseDevice ────────────────────────────────────────────────────── + # ── BaseDevice ──────────────────────────────────────────────────────── def connect(self) -> bool: - self._t0 = time.time() ok = self._layer.connect() self.status = DeviceStatus.SIMULATED if self.simulate else ( DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR @@ -94,53 +140,224 @@ class SerialDevice(BaseDevice): def read_channels(self) -> Dict[str, float]: raw = self._layer.read() - # Map by order if keys don't match channel IDs - if raw: - mapped: Dict[str, float] = {} - raw_vals = list(raw.values()) - for i, ch in enumerate(self.info.channels): - if ch.channel_id in raw: - mapped[ch.channel_id] = raw[ch.channel_id] - elif i < len(raw_vals): - mapped[ch.channel_id] = raw_vals[i] - return mapped - return {} + if not raw: + return {} + # Protocol layers already use channel_id keys — pass through. + # Generic (ArduinoLayer) may use arbitrary names — remap by position. + mapped: Dict[str, float] = {} + raw_vals = list(raw.values()) + for i, ch in enumerate(self.info.channels): + if ch.channel_id in raw: + mapped[ch.channel_id] = raw[ch.channel_id] + elif i < len(raw_vals): + mapped[ch.channel_id] = raw_vals[i] + return mapped def write_channel(self, channel_id: str, value: Any) -> bool: return self._layer.write(channel_id, int(value)) - def get_save_config(self) -> dict: - return { - "device_type": self.DEVICE_TYPE, - "device_id": self.info.device_id, - "port": self._port, - "baud_rate": self._baud, - "num_channels": len(self.info.channels), - "channel_names": [c.channel_id for c in self.info.channels], - "units": [c.unit for c in self.info.channels], - "parse_format": self._parse_format, - "simulate": self.simulate, - } - def get_config_widget(self) -> QWidget: return SerialConfigWidget(self) - def reconfigure(self, port: str, baud: int, fmt: str, simulate: bool): + def get_save_config(self) -> dict: + cfg: Dict[str, Any] = { + "device_type": self.DEVICE_TYPE, + "device_id": self.info.device_id, + "port": self._port, + "baud_rate": self._baud, + "parse_format": self._fmt, + "simulate": self.simulate, + } + if self._fmt in _GENERIC_FORMATS: + cfg.update({ + "num_channels": len(self.info.channels), + "channel_names": [c.channel_id for c in self.info.channels], + "units": [c.unit for c in self.info.channels], + }) + elif self._fmt == "scpi": + cfg["scpi_channels"] = self._scpi_channels + elif self._fmt == "modbus_rtu": + cfg.update({ + "slave_addr": self._slave_addr, + "parity": self._parity, + "stopbits": self._stopbits, + "modbus_channels": self._mb_channels, + }) + elif self._fmt == "mark10": + pass # no extra config + elif self._fmt == "cml": + cfg["motors"] = self._motors + return cfg + + # ── Reconfigure ─────────────────────────────────────────────────────── + + def reconfigure( + self, + port: str, + baud: int, + fmt: str, + simulate: bool, + num_channels: int = 4, + channel_names: List[str] = None, + units: List[str] = None, + scpi_channels: List[Dict] = None, + slave_addr: int = 1, + parity: str = "N", + stopbits: int = 1, + modbus_channels: List[Dict] = None, + motors: List[Dict] = None, + ): was_on = self.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED) if was_on: self.disconnect() + self._port = port self._baud = baud - self._parse_format = fmt + self._fmt = fmt self.simulate = simulate - self._layer = ArduinoLayer( - port=port, baud=baud, - analog_pins=[ch.channel_id for ch in self.info.channels], - simulate=simulate, - ) + self._num_channels = num_channels + self._chan_names = channel_names + self._units = units + self._scpi_channels = scpi_channels or self._scpi_channels + self._slave_addr = slave_addr + self._parity = parity + self._stopbits = stopbits + self._mb_channels = modbus_channels or self._mb_channels + self._motors = motors or self._motors + + self.info.channels = self._build_channel_configs() + self.info.description = self._description() + self._layer = self._make_layer() + if was_on: self.connect() + # ── Internal ────────────────────────────────────────────────────────── + + def _description(self) -> str: + label = _LABEL_FOR.get(self._fmt, self._fmt) + return f"{self._port} @ {self._baud} [{label}]" + + def _build_channel_configs(self) -> List[ChannelConfig]: + fmt = self._fmt + if fmt in _GENERIC_FORMATS: + names = self._chan_names or [f"CH{i}" for i in range(self._num_channels)] + _units = self._units or [""] * len(names) + return [ + ChannelConfig( + channel_id=names[i], name=names[i], unit=_units[i], + min_value=0.0, max_value=1023.0, + color=_COLORS[i % len(_COLORS)], + ) + for i in range(len(names)) + ] + elif fmt == "scpi": + return [ + ChannelConfig( + channel_id=sc["channel_id"], + name=sc.get("name", sc["channel_id"]), + unit=sc.get("unit", ""), + min_value=-1e9, max_value=1e9, + color=_COLORS[i % len(_COLORS)], + ) + for i, sc in enumerate(self._scpi_channels) + ] + elif fmt == "modbus_rtu": + return [ + ChannelConfig( + channel_id=mc["channel_id"], + name=mc.get("name", mc["channel_id"]), + unit=mc.get("unit", ""), + min_value=-1e9, max_value=1e9, + color=_COLORS[i % len(_COLORS)], + ) + for i, mc in enumerate(self._mb_channels) + ] + elif fmt == "mark10": + from api_layers.protocols.mark10 import UNITS as _MARK10_UNITS + return [ + ChannelConfig("force", "Force", "N", -5000.0, 5000.0, color=_COLORS[0]), + ChannelConfig("unit_code", "Unit Code", "", 0.0, float(len(_MARK10_UNITS) - 1), color=_COLORS[1]), + ] + elif fmt == "cml": + _CMD_UNITS = {"TP": "counts", "TV": "counts/s", "TC": "%×10", "TS": "flags"} + channels = [] + color_idx = 0 + for motor in self._motors: + for cmd in motor.get("read_cmds", ["TP", "TV", "TC"]): + mid = motor.get("motor_id", "M1") + channels.append(ChannelConfig( + channel_id=f"{mid}_{cmd}", + name=f"{mid} {cmd}", + unit=_CMD_UNITS.get(cmd, ""), + min_value=-1e6, max_value=1e6, + color=_COLORS[color_idx % len(_COLORS)], + )) + color_idx += 1 + return channels or [ChannelConfig("M1_TP", "M1 TP", "counts", color=_COLORS[0])] + return [] + + def _make_layer(self): + fmt = self._fmt + if fmt in _GENERIC_FORMATS: + names = [ch.channel_id for ch in self.info.channels] + return ArduinoLayer( + port=self._port, baud=self._baud, + analog_pins=names, simulate=self.simulate, + ) + elif fmt == "scpi": + from api_layers.protocols.scpi import SCPILayer, SCPIChannel + chs = [ + SCPIChannel( + channel_id=sc["channel_id"], + name=sc.get("name", sc["channel_id"]), + query=sc.get("query", ""), + unit=sc.get("unit", ""), + scale=sc.get("scale", 1.0), + write_cmd=sc.get("write_cmd", ""), + ) + for sc in self._scpi_channels + ] + return SCPILayer(self._port, self._baud, chs, simulate=self.simulate) + elif fmt == "modbus_rtu": + from api_layers.protocols.modbus_rtu import ModbusRTULayer, ModbusChannel + chs = [ + ModbusChannel( + channel_id=mc["channel_id"], + name=mc.get("name", mc["channel_id"]), + register=mc.get("register", 0), + function_code=mc.get("function_code", 0x03), + data_type=mc.get("data_type", "uint16"), + scale=mc.get("scale", 1.0), + offset=mc.get("offset", 0.0), + unit=mc.get("unit", ""), + ) + for mc in self._mb_channels + ] + return ModbusRTULayer( + self._port, self._baud, + slave_addr=self._slave_addr, channels=chs, + parity=self._parity, stopbits=self._stopbits, + simulate=self.simulate, + ) + elif fmt == "mark10": + from api_layers.protocols.mark10 import Mark10Layer + return Mark10Layer(self._port, self._baud, simulate=self.simulate) + elif fmt == "cml": + from api_layers.protocols.cml import CMLLayer, CMLMotor + cml_motors = [ + CMLMotor( + motor_id=m.get("motor_id", "M1"), + address=m.get("address", 1), + read_cmds=m.get("read_cmds", ["TP", "TV", "TC"]), + ) + for m in self._motors + ] + return CMLLayer(self._port, self._baud, cml_motors, simulate=self.simulate) + raise ValueError(f"Unknown parse_format: {fmt!r}") + + +# ── Config widget ───────────────────────────────────────────────────────────── class SerialConfigWidget(QWidget): def __init__(self, device: SerialDevice): @@ -150,44 +367,54 @@ class SerialConfigWidget(QWidget): self._build() def _build(self): - from PyQt6.QtWidgets import QListWidget, QListWidgetItem - from PyQt6.QtCore import Qt as _Qt - root = QVBoxLayout(self) root.setContentsMargins(8, 8, 8, 8) root.setSpacing(8) - self.setMinimumWidth(420) - - # ── Port settings ───────────────────────────────────────────── - grp = QGroupBox("Port Settings") - form = QFormLayout(grp) - - self.port_edit = QLineEdit(self.device._port) - self.port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") - form.addRow("Port:", self.port_edit) - - self.baud_cb = QComboBox() - self.baud_cb.addItems(["9600", "19200", "38400", "57600", "115200", "230400", "460800"]) - self.baud_cb.setCurrentText(str(self.device._baud)) - form.addRow("Baud Rate:", self.baud_cb) - - self.fmt_cb = QComboBox() - self.fmt_cb.addItems(["key:val", "csv", "json"]) - self.fmt_cb.setCurrentText(self.device._parse_format) - form.addRow("Parse Format:", self.fmt_cb) - - self.sim_cb = QComboBox() - self.sim_cb.addItems(["Simulate", "Real Hardware"]) - self.sim_cb.setCurrentIndex(0 if self.device.simulate else 1) - form.addRow("Mode:", self.sim_cb) - - root.addWidget(grp) - - # ── Port scanner ────────────────────────────────────────────── + self.setMinimumWidth(480) + + # ── Common connection settings ───────────────────────────────────── + conn_grp = QGroupBox("Port Settings") + conn_form = QFormLayout(conn_grp) + + self._port_edit = QLineEdit(self.device._port) + self._port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") + conn_form.addRow("Port:", self._port_edit) + + self._baud_cb = QComboBox() + self._baud_cb.addItems(["1200", "2400", "4800", "9600", "19200", "38400", + "57600", "115200", "230400", "460800"]) + self._baud_cb.setCurrentText(str(self.device._baud)) + conn_form.addRow("Baud Rate:", self._baud_cb) + + self._fmt_cb = QComboBox() + self._fmt_cb.addItems(list(_FORMAT_LABELS.keys())) + self._fmt_cb.setCurrentText(_LABEL_FOR.get(self.device._fmt, "Key:Value (Arduino)")) + self._fmt_cb.currentIndexChanged.connect(self._on_fmt_changed) + conn_form.addRow("Protocol / Format:", self._fmt_cb) + + self._sim_cb = QComboBox() + self._sim_cb.addItems(["Simulate", "Real Hardware"]) + self._sim_cb.setCurrentIndex(0 if self.device.simulate else 1) + conn_form.addRow("Mode:", self._sim_cb) + + root.addWidget(conn_grp) + + # ── Protocol-specific settings (stacked) ────────────────────────── + self._proto_stack = QStackedWidget() + self._generic_panel = _GenericPanel(device=self.device) + self._scpi_panel = _SCPIPanel(device=self.device) + self._modbus_panel = _ModbusPanel(device=self.device) + self._mark10_panel = _Mark10Panel() + self._cml_panel = _CMLPanel(device=self.device) + for panel in (self._generic_panel, self._scpi_panel, + self._modbus_panel, self._mark10_panel, self._cml_panel): + self._proto_stack.addWidget(panel) + self._proto_stack.setCurrentIndex(_STACK_IDX.get(self.device._fmt, 0)) + root.addWidget(self._proto_stack) + + # ── Port scanner ─────────────────────────────────────────────────── scan_grp = QGroupBox("Available Serial Ports") scan_lay = QVBoxLayout(scan_grp) - scan_lay.setSpacing(4) - scan_top = QHBoxLayout() self._scan_btn = QPushButton("🔍 Scan Ports") self._scan_btn.setObjectName("addTraceBtn") @@ -197,14 +424,12 @@ class SerialConfigWidget(QWidget): scan_top.addWidget(self._scan_btn) scan_top.addWidget(self._scan_lbl, 1) scan_lay.addLayout(scan_top) - self._port_list = QListWidget() self._port_list.setObjectName("portList") - self._port_list.setMaximumHeight(110) + self._port_list.setMaximumHeight(100) self._port_list.itemClicked.connect( - lambda item: self.port_edit.setText( - item.data(_Qt.ItemDataRole.UserRole) or "" - ) if item.data(_Qt.ItemDataRole.UserRole) else None + lambda item: self._port_edit.setText(item.data(Qt.ItemDataRole.UserRole) or "") + if item.data(Qt.ItemDataRole.UserRole) else None ) hint = QLabel("↑ Click a port above to select it") hint.setObjectName("traceSource") @@ -212,33 +437,55 @@ class SerialConfigWidget(QWidget): scan_lay.addWidget(hint) root.addWidget(scan_grp) - # ── Apply ───────────────────────────────────────────────────── + # ── Apply ────────────────────────────────────────────────────────── apply_btn = QPushButton("Apply & Reconnect") apply_btn.setObjectName("applyButton") apply_btn.clicked.connect(self._apply) root.addWidget(apply_btn) root.addStretch() + def _on_fmt_changed(self, _): + fmt = _FORMAT_LABELS.get(self._fmt_cb.currentText(), "key:val") + self._proto_stack.setCurrentIndex(_STACK_IDX.get(fmt, 0)) + + def _current_fmt(self) -> str: + return _FORMAT_LABELS.get(self._fmt_cb.currentText(), "key:val") + + def _apply(self): + fmt = self._current_fmt() + kwargs: Dict[str, Any] = dict( + port=self._port_edit.text().strip() or "COM3", + baud=int(self._baud_cb.currentText()), + fmt=fmt, + simulate=(self._sim_cb.currentIndex() == 0), + ) + if fmt in _GENERIC_FORMATS: + kwargs.update(self._generic_panel.collect()) + elif fmt == "scpi": + kwargs["scpi_channels"] = self._scpi_panel.collect() + elif fmt == "modbus_rtu": + kwargs.update(self._modbus_panel.collect()) + elif fmt == "cml": + kwargs["motors"] = self._cml_panel.collect() + self.device.reconfigure(**kwargs) + def _scan(self): self._scan_btn.setEnabled(False) self._scan_lbl.setText("Scanning…") self._port_list.clear() - from PyQt6.QtCore import QThread, pyqtSignal as _sig class _Scanner(QThread): - done = _sig(list) - def run(self): - self.done.emit(ArduinoLayer.list_ports()) + done = pyqtSignal(list) + def run(self): self.done.emit(ArduinoLayer.list_ports()) self._scanner = _Scanner() self._scanner.done.connect(self._on_scan_done) self._scanner.start() def _on_scan_done(self, ports): - from PyQt6.QtWidgets import QListWidgetItem self._scan_btn.setEnabled(True) self._port_list.clear() if not ports: self._scan_lbl.setText("No ports found") - item = QListWidgetItem(" No serial ports detected — check USB / permissions") + item = QListWidgetItem(" No serial ports detected") item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) self._port_list.addItem(item) else: @@ -251,10 +498,375 @@ class SerialConfigWidget(QWidget): item.setData(Qt.ItemDataRole.UserRole, device) self._port_list.addItem(item) - def _apply(self): - self.device.reconfigure( - port=self.port_edit.text().strip() or "COM3", - baud=int(self.baud_cb.currentText()), - fmt=self.fmt_cb.currentText(), - simulate=(self.sim_cb.currentIndex() == 0), + +# ── Protocol-specific panels ────────────────────────────────────────────────── + +class _GenericPanel(QWidget): + """Settings for key:val / csv / json formats.""" + + def __init__(self, device: SerialDevice): + super().__init__() + self.device = device + lay = QFormLayout(self) + lay.setContentsMargins(0, 4, 0, 4) + + self._ch_spin = QSpinBox() + self._ch_spin.setRange(1, 32) + self._ch_spin.setValue(len(device.info.channels) if device._fmt in _GENERIC_FORMATS else 4) + lay.addRow("Channels:", self._ch_spin) + + def collect(self) -> dict: + return {"num_channels": self._ch_spin.value()} + + +class _SCPIPanel(QWidget): + """Per-channel query table for SCPI format.""" + + def __init__(self, device: SerialDevice): + super().__init__() + self.device = device + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 4, 0, 4) + lay.setSpacing(6) + + grp = QGroupBox("Channel Queries") + grp_lay = QVBoxLayout(grp) + + self._table = QTableWidget() + self._table.setColumnCount(5) + self._table.setHorizontalHeaderLabels(["ID", "Name", "Query", "Unit", "Scale"]) + self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self._table.setMinimumHeight(150) + grp_lay.addWidget(self._table) + + btn_row = QHBoxLayout() + add_btn = QPushButton("+ Add") + add_btn.setObjectName("addTraceBtn") + add_btn.clicked.connect(self._add_row) + del_btn = QPushButton("Remove") + del_btn.setObjectName("removeTraceBtn") + del_btn.clicked.connect(self._del_row) + + idn_btn = QPushButton("Query *IDN?") + idn_btn.setObjectName("addTraceBtn") + idn_btn.clicked.connect(self._query_idn) + self._idn_lbl = QLabel("—") + self._idn_lbl.setObjectName("traceSource") + + btn_row.addWidget(add_btn) + btn_row.addWidget(del_btn) + btn_row.addSpacing(12) + btn_row.addWidget(idn_btn) + btn_row.addWidget(self._idn_lbl, 1) + grp_lay.addLayout(btn_row) + lay.addWidget(grp) + + for sc in device._scpi_channels: + self._add_row(sc) + + def _add_row(self, sc: dict = None): + row = self._table.rowCount() + self._table.insertRow(row) + n = row + self._table.setItem(row, 0, QTableWidgetItem(sc["channel_id"] if sc else f"CH{n}")) + self._table.setItem(row, 1, QTableWidgetItem(sc.get("name", f"CH{n}") if sc else f"CH{n}")) + self._table.setItem(row, 2, QTableWidgetItem(sc.get("query", "MEAS:VOLT?") if sc else "MEAS:VOLT?")) + self._table.setItem(row, 3, QTableWidgetItem(sc.get("unit", "") if sc else "")) + self._table.setItem(row, 4, QTableWidgetItem(str(sc.get("scale", 1.0)) if sc else "1.0")) + + def _del_row(self): + row = self._table.currentRow() + if row >= 0: + self._table.removeRow(row) + + def collect(self) -> List[Dict]: + result = [] + for row in range(self._table.rowCount()): + def cell(c): return (self._table.item(row, c) or QTableWidgetItem("")).text().strip() + try: + scale = float(cell(4)) + except ValueError: + scale = 1.0 + result.append({ + "channel_id": cell(0) or f"CH{row}", + "name": cell(1) or f"CH{row}", + "query": cell(2), + "unit": cell(3), + "scale": scale, + "write_cmd": "", + }) + return result + + def _query_idn(self): + layer = self.device._layer + idn = getattr(layer, "query_idn", lambda: "—")() + self._idn_lbl.setText(idn or "—") + + +_FC_OPTIONS = {"Holding (FC03)": 0x03, "Input (FC04)": 0x04} +_TYPE_OPTIONS = ["uint16", "int16", "float32", "int32"] + + +class _ModbusPanel(QWidget): + """Slave address + register map table for Modbus RTU.""" + + def __init__(self, device: SerialDevice): + super().__init__() + self.device = device + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 4, 0, 4) + lay.setSpacing(6) + + bus_form = QFormLayout() + self._slave_spin = QSpinBox() + self._slave_spin.setRange(1, 247) + self._slave_spin.setValue(device._slave_addr) + bus_form.addRow("Slave Address:", self._slave_spin) + + self._parity_cb = QComboBox() + self._parity_cb.addItems(["N — None", "E — Even", "O — Odd"]) + self._parity_cb.setCurrentIndex({"N": 0, "E": 1, "O": 2}.get(device._parity, 0)) + bus_form.addRow("Parity:", self._parity_cb) + + self._stop_cb = QComboBox() + self._stop_cb.addItems(["1", "2"]) + self._stop_cb.setCurrentText(str(device._stopbits)) + bus_form.addRow("Stop Bits:", self._stop_cb) + lay.addLayout(bus_form) + + grp = QGroupBox("Register Map") + grp_lay = QVBoxLayout(grp) + + self._table = QTableWidget() + self._table.setColumnCount(7) + self._table.setHorizontalHeaderLabels( + ["ID", "Name", "Register", "FC", "Type", "Scale", "Unit"] ) + self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self._table.setMinimumHeight(140) + grp_lay.addWidget(self._table) + + btn_row = QHBoxLayout() + add_btn = QPushButton("+ Add") + add_btn.setObjectName("addTraceBtn") + add_btn.clicked.connect(self._add_row) + del_btn = QPushButton("Remove") + del_btn.setObjectName("removeTraceBtn") + del_btn.clicked.connect(self._del_row) + btn_row.addWidget(add_btn) + btn_row.addWidget(del_btn) + btn_row.addStretch() + grp_lay.addLayout(btn_row) + lay.addWidget(grp) + + for mc in device._mb_channels: + self._add_row(mc) + + def _add_row(self, mc: dict = None): + row = self._table.rowCount() + self._table.insertRow(row) + self._table.setItem(row, 0, QTableWidgetItem(mc["channel_id"] if mc else f"R{row}")) + self._table.setItem(row, 1, QTableWidgetItem(mc.get("name", f"R{row}") if mc else f"R{row}")) + self._table.setItem(row, 2, QTableWidgetItem(str(mc.get("register", row * 2)) if mc else str(row * 2))) + + fc_cb = QComboBox() + fc_cb.addItems(list(_FC_OPTIONS.keys())) + if mc: + for label, code in _FC_OPTIONS.items(): + if code == mc.get("function_code", 0x03): + fc_cb.setCurrentText(label) + self._table.setCellWidget(row, 3, fc_cb) + + type_cb = QComboBox() + type_cb.addItems(_TYPE_OPTIONS) + if mc: + type_cb.setCurrentText(mc.get("data_type", "uint16")) + self._table.setCellWidget(row, 4, type_cb) + + self._table.setItem(row, 5, QTableWidgetItem(str(mc.get("scale", 1.0)) if mc else "1.0")) + self._table.setItem(row, 6, QTableWidgetItem(mc.get("unit", "") if mc else "")) + + def _del_row(self): + row = self._table.currentRow() + if row >= 0: + self._table.removeRow(row) + + def collect(self) -> dict: + parity_map = {"N — None": "N", "E — Even": "E", "O — Odd": "O"} + channels = [] + for row in range(self._table.rowCount()): + def cell(c): return (self._table.item(row, c) or QTableWidgetItem("")).text().strip() + def wtext(c): + w = self._table.cellWidget(row, c) + return w.currentText() if w else "" + try: + reg = int(cell(2)) + except ValueError: + reg = 0 + try: + scale = float(cell(5)) + except ValueError: + scale = 1.0 + channels.append({ + "channel_id": cell(0) or f"R{row}", + "name": cell(1) or f"R{row}", + "register": reg, + "function_code": _FC_OPTIONS.get(wtext(3), 0x03), + "data_type": wtext(4) or "uint16", + "scale": scale, + "offset": 0.0, + "unit": cell(6), + }) + return { + "slave_addr": self._slave_spin.value(), + "parity": parity_map.get(self._parity_cb.currentText(), "N"), + "stopbits": int(self._stop_cb.currentText()), + "modbus_channels": channels, + } + + +class _Mark10Panel(QWidget): + """Info panel for Mark-10 — no extra user config needed.""" + + def __init__(self): + super().__init__() + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 4, 0, 4) + grp = QGroupBox("Mark-10 Series 5 Protocol") + grp_lay = QVBoxLayout(grp) + lbl = QLabel( + "Polls '?' command, parses '+X.XXX kgF' response.\n\n" + "Fixed channels:\n" + " force — current reading in active unit\n" + " unit_code — 0=lb 1=kgF 2=N 3=ozF\n\n" + "Serial: 8N1, no flow control.\n" + "Typical baud: 115200 (Series 5), 9600 (Series 3/4)." + ) + lbl.setObjectName("traceSource") + lbl.setWordWrap(True) + grp_lay.addWidget(lbl) + lay.addWidget(grp) + lay.addStretch() + + +class _CMLPanel(QWidget): + """Motor list for CoolMuscle CML.""" + + _READ_CMDS = ["TP", "TV", "TC", "TS"] + + def __init__(self, device: SerialDevice): + super().__init__() + self.device = device + self._rows: List[_MotorRow] = [] + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 4, 0, 4) + lay.setSpacing(6) + + grp = QGroupBox("Motors (RS-232: addr=0 / RS-485: addr 1-31)") + grp_lay = QVBoxLayout(grp) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setMaximumHeight(200) + container = QWidget() + self._container_lay = QVBoxLayout(container) + self._container_lay.setSpacing(2) + self._container_lay.addStretch() + scroll.setWidget(container) + grp_lay.addWidget(scroll) + + btn_row = QHBoxLayout() + add_btn = QPushButton("+ Add Motor") + add_btn.setObjectName("addTraceBtn") + add_btn.clicked.connect(self._add_row) + del_btn = QPushButton("Remove Last") + del_btn.setObjectName("removeTraceBtn") + del_btn.clicked.connect(self._del_last) + btn_row.addWidget(add_btn) + btn_row.addWidget(del_btn) + btn_row.addStretch() + grp_lay.addLayout(btn_row) + lay.addWidget(grp) + + for m in device._motors: + self._add_row(m) + + def _add_row(self, m: dict = None): + n = len(self._rows) + row = _MotorRow(m or {"motor_id": f"M{n+1}", "address": n+1, "read_cmds": ["TP", "TV", "TC"]}) + self._rows.append(row) + stretch = self._container_lay.takeAt(self._container_lay.count() - 1) + self._container_lay.addWidget(row) + self._container_lay.addStretch() + + def _del_last(self): + if not self._rows: + return + row = self._rows.pop() + self._container_lay.removeWidget(row) + row.deleteLater() + + def collect(self) -> List[Dict]: + return [r.to_dict() for r in self._rows] + + +class _MotorRow(QWidget): + _READ_CMDS = ["TP", "TV", "TC", "TS"] + + def __init__(self, m: dict): + super().__init__() + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 1, 0, 1) + lay.setSpacing(6) + + self._id_edit = QLineEdit(m.get("motor_id", "M1")) + self._id_edit.setMaximumWidth(52) + lay.addWidget(QLabel("ID:")) + lay.addWidget(self._id_edit) + + self._addr_spin = QSpinBox() + self._addr_spin.setRange(0, 31) + self._addr_spin.setValue(m.get("address", 1)) + self._addr_spin.setToolTip("0 = RS-232 (no prefix), 1-31 = RS-485") + lay.addWidget(QLabel("Addr:")) + lay.addWidget(self._addr_spin) + + lay.addWidget(QLabel("Read:")) + self._checks: Dict[str, QCheckBox] = {} + for cmd in self._READ_CMDS: + cb = QCheckBox(cmd) + cb.setChecked(cmd in m.get("read_cmds", ["TP", "TV", "TC"])) + lay.addWidget(cb) + self._checks[cmd] = cb + lay.addStretch() + + def to_dict(self) -> dict: + return { + "motor_id": self._id_edit.text().strip() or "M1", + "address": self._addr_spin.value(), + "read_cmds": [cmd for cmd, cb in self._checks.items() if cb.isChecked()], + } + + +# ── Default protocol configs ─────────────────────────────────────────────────── + +def _default_scpi() -> List[Dict]: + return [ + {"channel_id": "CH1", "name": "Channel 1", "query": "MEAS:VOLT?", + "unit": "V", "scale": 1.0, "write_cmd": ""}, + {"channel_id": "CH2", "name": "Channel 2", "query": "MEAS:CURR?", + "unit": "A", "scale": 1.0, "write_cmd": ""}, + ] + + +def _default_modbus() -> List[Dict]: + return [ + {"channel_id": "R0", "name": "Register 0", "register": 0, + "function_code": 0x03, "data_type": "uint16", "scale": 1.0, "offset": 0.0, "unit": ""}, + {"channel_id": "R1", "name": "Register 1", "register": 2, + "function_code": 0x03, "data_type": "uint16", "scale": 1.0, "offset": 0.0, "unit": ""}, + ] + + +def _default_motors() -> List[Dict]: + return [{"motor_id": "M1", "address": 1, "read_cmds": ["TP", "TV", "TC"]}] diff --git a/ui/__pycache__/__init__.cpython-312.pyc b/ui/__pycache__/__init__.cpython-312.pyc Binary files differindex 8dc1c2f..42ac581 100644 --- a/ui/__pycache__/__init__.cpython-312.pyc +++ b/ui/__pycache__/__init__.cpython-312.pyc diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc Binary files differindex 15d71d0..ef40b13 100644 --- a/ui/__pycache__/__init__.cpython-314.pyc +++ b/ui/__pycache__/__init__.cpython-314.pyc diff --git a/ui/__pycache__/add_device_dialog.cpython-312.pyc b/ui/__pycache__/add_device_dialog.cpython-312.pyc Binary files differindex 2da95a6..990c229 100644 --- a/ui/__pycache__/add_device_dialog.cpython-312.pyc +++ b/ui/__pycache__/add_device_dialog.cpython-312.pyc diff --git a/ui/__pycache__/add_device_dialog.cpython-314.pyc b/ui/__pycache__/add_device_dialog.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..f5852ba --- /dev/null +++ b/ui/__pycache__/add_device_dialog.cpython-314.pyc diff --git a/ui/__pycache__/config_dialog.cpython-312.pyc b/ui/__pycache__/config_dialog.cpython-312.pyc Binary files differindex cbc9cf0..5a051dc 100644 --- a/ui/__pycache__/config_dialog.cpython-312.pyc +++ b/ui/__pycache__/config_dialog.cpython-312.pyc diff --git a/ui/__pycache__/control_editor.cpython-312.pyc b/ui/__pycache__/control_editor.cpython-312.pyc Binary files differindex 3a53377..e76411d 100644 --- a/ui/__pycache__/control_editor.cpython-312.pyc +++ b/ui/__pycache__/control_editor.cpython-312.pyc diff --git a/ui/__pycache__/control_panel.cpython-312.pyc b/ui/__pycache__/control_panel.cpython-312.pyc Binary files differindex 1c101a8..45f1eb9 100644 --- a/ui/__pycache__/control_panel.cpython-312.pyc +++ b/ui/__pycache__/control_panel.cpython-312.pyc diff --git a/ui/__pycache__/main_window.cpython-312.pyc b/ui/__pycache__/main_window.cpython-312.pyc Binary files differindex 054aa5b..3aefd67 100644 --- a/ui/__pycache__/main_window.cpython-312.pyc +++ b/ui/__pycache__/main_window.cpython-312.pyc diff --git a/ui/__pycache__/profile_manager_ui.cpython-312.pyc b/ui/__pycache__/profile_manager_ui.cpython-312.pyc Binary files differindex 97cbe3a..93491f7 100644 --- a/ui/__pycache__/profile_manager_ui.cpython-312.pyc +++ b/ui/__pycache__/profile_manager_ui.cpython-312.pyc diff --git a/ui/__pycache__/strip_chart.cpython-312.pyc b/ui/__pycache__/strip_chart.cpython-312.pyc Binary files differindex ff41c1a..a3e25c0 100644 --- a/ui/__pycache__/strip_chart.cpython-312.pyc +++ b/ui/__pycache__/strip_chart.cpython-312.pyc diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py index 7e8e935..4df71a3 100644 --- a/ui/add_device_dialog.py +++ b/ui/add_device_dialog.py @@ -23,7 +23,7 @@ from PyQt6.QtGui import QFont from devices.device_registry import DeviceRegistry from devices.analog_input import AnalogInputDevice from devices.digital_io import DigitalIODevice -from devices.serial_device import SerialDevice +from devices.serial_device import SerialDevice, _FORMAT_LABELS # ── Port scanner thread ─────────────────────────────────────────────────────── @@ -339,7 +339,7 @@ class DigitalIOPanel(QWidget): class SerialPanel(QWidget): - """Config fields for SerialDevice with live port scanner.""" + """Config fields for SerialDevice — port, baud, protocol/format, simulate.""" def __init__(self): super().__init__() @@ -354,33 +354,34 @@ class SerialPanel(QWidget): form.addRow("Port:", self.port_edit) self.baud_cb = QComboBox() - self.baud_cb.addItems(["9600", "19200", "38400", "57600", "115200", "230400", "460800"]) + self.baud_cb.addItems(["1200", "2400", "4800", "9600", "19200", "38400", + "57600", "115200", "230400", "460800"]) self.baud_cb.setCurrentText("115200") form.addRow("Baud Rate:", self.baud_cb) - self.ch_spin = QSpinBox() - self.ch_spin.setRange(1, 32) - self.ch_spin.setValue(4) - form.addRow("Channels:", self.ch_spin) - self.fmt_cb = QComboBox() - self.fmt_cb.addItems(["key:val", "csv", "json"]) - form.addRow("Parse Format:", self.fmt_cb) + self.fmt_cb.addItems(list(_FORMAT_LABELS.keys())) + form.addRow("Protocol / Format:", self.fmt_cb) self.sim_chk = QCheckBox("Simulation mode") self.sim_chk.setChecked(False) form.addRow(self.sim_chk) + note = QLabel("Protocol-specific settings (queries, registers, motors) available in device config dialog.") + note.setObjectName("traceSource") + note.setWordWrap(True) + lay.addLayout(form) + lay.addWidget(note) lay.addWidget(PortScanGroup(self.port_edit)) def build_device(self, device_id: str) -> SerialDevice: + fmt = _FORMAT_LABELS.get(self.fmt_cb.currentText(), "key:val") return SerialDevice( device_id=device_id, port=self.port_edit.text().strip() or "COM3", baud_rate=int(self.baud_cb.currentText()), - num_channels=self.ch_spin.value(), - parse_format=self.fmt_cb.currentText(), + parse_format=fmt, simulate=self.sim_chk.isChecked(), ) @@ -388,8 +389,8 @@ class SerialPanel(QWidget): # ── Main dialog ─────────────────────────────────────────────────────────────── _PANELS = { - "Analog Input": AnalogInputPanel, - "Digital I/O": DigitalIOPanel, + "Analog Input": AnalogInputPanel, + "Digital I/O": DigitalIOPanel, "Serial / UART": SerialPanel, } @@ -480,7 +481,11 @@ class AddDeviceDialog(QDialog): # Auto-generate ID if blank if not dev_id: - prefixes = {"Analog Input": "ai", "Digital I/O": "dio", "Serial / UART": "ser"} + prefixes = { + "Analog Input": "ai", + "Digital I/O": "dio", + "Serial / UART": "ser", + } prefix = prefixes.get(type_name, "dev") existing = {d.info.device_id for d in self.registry.all_instances()} for i in range(100): diff --git a/ui/windows/__pycache__/__init__.cpython-312.pyc b/ui/windows/__pycache__/__init__.cpython-312.pyc Binary files differindex 66495c5..1454fd8 100644 --- a/ui/windows/__pycache__/__init__.cpython-312.pyc +++ b/ui/windows/__pycache__/__init__.cpython-312.pyc diff --git a/ui/windows/__pycache__/devices_window.cpython-312.pyc b/ui/windows/__pycache__/devices_window.cpython-312.pyc Binary files differindex 601221d..fb52b79 100644 --- a/ui/windows/__pycache__/devices_window.cpython-312.pyc +++ b/ui/windows/__pycache__/devices_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/plot_window.cpython-312.pyc b/ui/windows/__pycache__/plot_window.cpython-312.pyc Binary files differindex c72d74b..7499763 100644 --- a/ui/windows/__pycache__/plot_window.cpython-312.pyc +++ b/ui/windows/__pycache__/plot_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/settings_window.cpython-312.pyc b/ui/windows/__pycache__/settings_window.cpython-312.pyc Binary files differindex d596487..47d5611 100644 --- a/ui/windows/__pycache__/settings_window.cpython-312.pyc +++ b/ui/windows/__pycache__/settings_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/signals_window.cpython-312.pyc b/ui/windows/__pycache__/signals_window.cpython-312.pyc Binary files differindex 8b0db88..ec57533 100644 --- a/ui/windows/__pycache__/signals_window.cpython-312.pyc +++ b/ui/windows/__pycache__/signals_window.cpython-312.pyc |
