summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--api_layers/__pycache__/__init__.cpython-312.pycbin311 -> 320 bytes
-rw-r--r--api_layers/__pycache__/__init__.cpython-314.pycbin313 -> 322 bytes
-rw-r--r--api_layers/__pycache__/arduino_layer.cpython-312.pycbin30983 -> 31077 bytes
-rw-r--r--api_layers/__pycache__/arduino_layer.cpython-314.pycbin35535 -> 35752 bytes
-rw-r--r--api_layers/__pycache__/nidaqmx_layer.cpython-312.pycbin8501 -> 8503 bytes
-rw-r--r--api_layers/__pycache__/nidaqmx_layer.cpython-314.pycbin10240 -> 10249 bytes
-rw-r--r--api_layers/__pycache__/port_registry.cpython-312.pycbin5907 -> 5983 bytes
-rw-r--r--api_layers/protocols/__init__.py1
-rw-r--r--api_layers/protocols/__pycache__/__init__.cpython-312.pycbin0 -> 244 bytes
-rw-r--r--api_layers/protocols/__pycache__/__init__.cpython-314.pycbin0 -> 246 bytes
-rw-r--r--api_layers/protocols/__pycache__/base_protocol.cpython-312.pycbin0 -> 6390 bytes
-rw-r--r--api_layers/protocols/__pycache__/base_protocol.cpython-314.pycbin0 -> 8380 bytes
-rw-r--r--api_layers/protocols/__pycache__/cml.cpython-312.pycbin0 -> 6898 bytes
-rw-r--r--api_layers/protocols/__pycache__/cml.cpython-314.pycbin0 -> 8412 bytes
-rw-r--r--api_layers/protocols/__pycache__/mark10.cpython-312.pycbin0 -> 4438 bytes
-rw-r--r--api_layers/protocols/__pycache__/mark10.cpython-314.pycbin0 -> 5831 bytes
-rw-r--r--api_layers/protocols/__pycache__/modbus_rtu.cpython-312.pycbin0 -> 8281 bytes
-rw-r--r--api_layers/protocols/__pycache__/modbus_rtu.cpython-314.pycbin0 -> 10564 bytes
-rw-r--r--api_layers/protocols/__pycache__/scpi.cpython-312.pycbin0 -> 5566 bytes
-rw-r--r--api_layers/protocols/__pycache__/scpi.cpython-314.pycbin0 -> 7046 bytes
-rw-r--r--api_layers/protocols/base_protocol.py120
-rw-r--r--api_layers/protocols/cml.py139
-rw-r--r--api_layers/protocols/mark10.py95
-rw-r--r--api_layers/protocols/modbus_rtu.py158
-rw-r--r--api_layers/protocols/scpi.py102
-rw-r--r--core/__pycache__/__init__.cpython-312.pycbin264 -> 273 bytes
-rw-r--r--core/__pycache__/acquisition.cpython-312.pycbin11456 -> 11489 bytes
-rw-r--r--core/__pycache__/profile.cpython-312.pycbin14149 -> 14202 bytes
-rw-r--r--core/__pycache__/signal_processor.cpython-312.pycbin24525 -> 24645 bytes
-rw-r--r--devices/__pycache__/__init__.cpython-312.pycbin292 -> 301 bytes
-rw-r--r--devices/__pycache__/__init__.cpython-314.pycbin387 -> 303 bytes
-rw-r--r--devices/__pycache__/analog_input.cpython-312.pycbin24808 -> 24804 bytes
-rw-r--r--devices/__pycache__/analog_input.cpython-314.pycbin25792 -> 27067 bytes
-rw-r--r--devices/__pycache__/base_device.cpython-312.pycbin5604 -> 5615 bytes
-rw-r--r--devices/__pycache__/base_device.cpython-314.pycbin7574 -> 7583 bytes
-rw-r--r--devices/__pycache__/cml_device.cpython-312.pycbin0 -> 21375 bytes
-rw-r--r--devices/__pycache__/cml_device.cpython-314.pycbin0 -> 25203 bytes
-rw-r--r--devices/__pycache__/device_registry.cpython-312.pycbin4571 -> 4590 bytes
-rw-r--r--devices/__pycache__/device_registry.cpython-314.pycbin5916 -> 5925 bytes
-rw-r--r--devices/__pycache__/digital_io.cpython-312.pycbin22589 -> 22622 bytes
-rw-r--r--devices/__pycache__/digital_io.cpython-314.pycbin21130 -> 25967 bytes
-rw-r--r--devices/__pycache__/mark10_device.cpython-312.pycbin0 -> 14062 bytes
-rw-r--r--devices/__pycache__/mark10_device.cpython-314.pycbin0 -> 15850 bytes
-rw-r--r--devices/__pycache__/modbus_device.cpython-312.pycbin0 -> 23803 bytes
-rw-r--r--devices/__pycache__/modbus_device.cpython-314.pycbin0 -> 26582 bytes
-rw-r--r--devices/__pycache__/scpi_device.cpython-312.pycbin0 -> 20470 bytes
-rw-r--r--devices/__pycache__/scpi_device.cpython-314.pycbin0 -> 23264 bytes
-rw-r--r--devices/__pycache__/serial_device.cpython-312.pycbin15511 -> 49674 bytes
-rw-r--r--devices/__pycache__/serial_device.cpython-314.pycbin16417 -> 54966 bytes
-rw-r--r--devices/serial_device.py866
-rw-r--r--ui/__pycache__/__init__.cpython-312.pycbin140 -> 149 bytes
-rw-r--r--ui/__pycache__/__init__.cpython-314.pycbin142 -> 151 bytes
-rw-r--r--ui/__pycache__/add_device_dialog.cpython-312.pycbin29737 -> 29935 bytes
-rw-r--r--ui/__pycache__/add_device_dialog.cpython-314.pycbin0 -> 32389 bytes
-rw-r--r--ui/__pycache__/config_dialog.cpython-312.pycbin6917 -> 6931 bytes
-rw-r--r--ui/__pycache__/control_editor.cpython-312.pycbin22558 -> 22602 bytes
-rw-r--r--ui/__pycache__/control_panel.cpython-312.pycbin42676 -> 42708 bytes
-rw-r--r--ui/__pycache__/main_window.cpython-312.pycbin28733 -> 28788 bytes
-rw-r--r--ui/__pycache__/profile_manager_ui.cpython-312.pycbin8357 -> 8371 bytes
-rw-r--r--ui/__pycache__/strip_chart.cpython-312.pycbin14879 -> 14901 bytes
-rw-r--r--ui/add_device_dialog.py35
-rw-r--r--ui/windows/__pycache__/__init__.cpython-312.pycbin148 -> 157 bytes
-rw-r--r--ui/windows/__pycache__/devices_window.cpython-312.pycbin22008 -> 22041 bytes
-rw-r--r--ui/windows/__pycache__/plot_window.cpython-312.pycbin44550 -> 44685 bytes
-rw-r--r--ui/windows/__pycache__/settings_window.cpython-312.pycbin15906 -> 15935 bytes
-rw-r--r--ui/windows/__pycache__/signals_window.cpython-312.pycbin53973 -> 54087 bytes
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
index a9a035b..689f52e 100644
--- a/api_layers/__pycache__/__init__.cpython-312.pyc
+++ b/api_layers/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/__init__.cpython-314.pyc b/api_layers/__pycache__/__init__.cpython-314.pyc
index 01ab34b..d30ccf5 100644
--- a/api_layers/__pycache__/__init__.cpython-314.pyc
+++ b/api_layers/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/api_layers/__pycache__/arduino_layer.cpython-312.pyc b/api_layers/__pycache__/arduino_layer.cpython-312.pyc
index 1222766..a0191ad 100644
--- a/api_layers/__pycache__/arduino_layer.cpython-312.pyc
+++ b/api_layers/__pycache__/arduino_layer.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/arduino_layer.cpython-314.pyc b/api_layers/__pycache__/arduino_layer.cpython-314.pyc
index d40bc88..1cfec4e 100644
--- a/api_layers/__pycache__/arduino_layer.cpython-314.pyc
+++ b/api_layers/__pycache__/arduino_layer.cpython-314.pyc
Binary files differ
diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
index 8dda62e..d310f54 100644
--- a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
+++ b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
index c11967d..6902c49 100644
--- a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
+++ b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
Binary files differ
diff --git a/api_layers/__pycache__/port_registry.cpython-312.pyc b/api_layers/__pycache__/port_registry.cpython-312.pyc
index 088d6d3..48e7384 100644
--- a/api_layers/__pycache__/port_registry.cpython-312.pyc
+++ b/api_layers/__pycache__/port_registry.cpython-312.pyc
Binary files differ
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
new file mode 100644
index 0000000..e3d3dea
--- /dev/null
+++ b/api_layers/protocols/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/api_layers/protocols/__pycache__/__init__.cpython-314.pyc b/api_layers/protocols/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..c12aff7
--- /dev/null
+++ b/api_layers/protocols/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/api_layers/protocols/__pycache__/base_protocol.cpython-312.pyc b/api_layers/protocols/__pycache__/base_protocol.cpython-312.pyc
new file mode 100644
index 0000000..faba1c8
--- /dev/null
+++ b/api_layers/protocols/__pycache__/base_protocol.cpython-312.pyc
Binary files differ
diff --git a/api_layers/protocols/__pycache__/base_protocol.cpython-314.pyc b/api_layers/protocols/__pycache__/base_protocol.cpython-314.pyc
new file mode 100644
index 0000000..7f87d61
--- /dev/null
+++ b/api_layers/protocols/__pycache__/base_protocol.cpython-314.pyc
Binary files differ
diff --git a/api_layers/protocols/__pycache__/cml.cpython-312.pyc b/api_layers/protocols/__pycache__/cml.cpython-312.pyc
new file mode 100644
index 0000000..fa580d6
--- /dev/null
+++ b/api_layers/protocols/__pycache__/cml.cpython-312.pyc
Binary files differ
diff --git a/api_layers/protocols/__pycache__/cml.cpython-314.pyc b/api_layers/protocols/__pycache__/cml.cpython-314.pyc
new file mode 100644
index 0000000..84c94a2
--- /dev/null
+++ b/api_layers/protocols/__pycache__/cml.cpython-314.pyc
Binary files differ
diff --git a/api_layers/protocols/__pycache__/mark10.cpython-312.pyc b/api_layers/protocols/__pycache__/mark10.cpython-312.pyc
new file mode 100644
index 0000000..f31dcb9
--- /dev/null
+++ b/api_layers/protocols/__pycache__/mark10.cpython-312.pyc
Binary files differ
diff --git a/api_layers/protocols/__pycache__/mark10.cpython-314.pyc b/api_layers/protocols/__pycache__/mark10.cpython-314.pyc
new file mode 100644
index 0000000..c2ce6dc
--- /dev/null
+++ b/api_layers/protocols/__pycache__/mark10.cpython-314.pyc
Binary files differ
diff --git a/api_layers/protocols/__pycache__/modbus_rtu.cpython-312.pyc b/api_layers/protocols/__pycache__/modbus_rtu.cpython-312.pyc
new file mode 100644
index 0000000..1947863
--- /dev/null
+++ b/api_layers/protocols/__pycache__/modbus_rtu.cpython-312.pyc
Binary files differ
diff --git a/api_layers/protocols/__pycache__/modbus_rtu.cpython-314.pyc b/api_layers/protocols/__pycache__/modbus_rtu.cpython-314.pyc
new file mode 100644
index 0000000..dd104cb
--- /dev/null
+++ b/api_layers/protocols/__pycache__/modbus_rtu.cpython-314.pyc
Binary files differ
diff --git a/api_layers/protocols/__pycache__/scpi.cpython-312.pyc b/api_layers/protocols/__pycache__/scpi.cpython-312.pyc
new file mode 100644
index 0000000..e2a2e83
--- /dev/null
+++ b/api_layers/protocols/__pycache__/scpi.cpython-312.pyc
Binary files differ
diff --git a/api_layers/protocols/__pycache__/scpi.cpython-314.pyc b/api_layers/protocols/__pycache__/scpi.cpython-314.pyc
new file mode 100644
index 0000000..971d9ed
--- /dev/null
+++ b/api_layers/protocols/__pycache__/scpi.cpython-314.pyc
Binary files differ
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
index 91f5de3..79fc26c 100644
--- a/core/__pycache__/__init__.cpython-312.pyc
+++ b/core/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/acquisition.cpython-312.pyc b/core/__pycache__/acquisition.cpython-312.pyc
index cb5f3f3..d1b61fc 100644
--- a/core/__pycache__/acquisition.cpython-312.pyc
+++ b/core/__pycache__/acquisition.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/profile.cpython-312.pyc b/core/__pycache__/profile.cpython-312.pyc
index 208b48b..9105704 100644
--- a/core/__pycache__/profile.cpython-312.pyc
+++ b/core/__pycache__/profile.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/signal_processor.cpython-312.pyc b/core/__pycache__/signal_processor.cpython-312.pyc
index 4572e55..505a573 100644
--- a/core/__pycache__/signal_processor.cpython-312.pyc
+++ b/core/__pycache__/signal_processor.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/__init__.cpython-312.pyc b/devices/__pycache__/__init__.cpython-312.pyc
index 0e0929f..88e8062 100644
--- a/devices/__pycache__/__init__.cpython-312.pyc
+++ b/devices/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/__init__.cpython-314.pyc b/devices/__pycache__/__init__.cpython-314.pyc
index f1080d7..fb1a2a7 100644
--- a/devices/__pycache__/__init__.cpython-314.pyc
+++ b/devices/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/analog_input.cpython-312.pyc b/devices/__pycache__/analog_input.cpython-312.pyc
index f22ffd6..931b605 100644
--- a/devices/__pycache__/analog_input.cpython-312.pyc
+++ b/devices/__pycache__/analog_input.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/analog_input.cpython-314.pyc b/devices/__pycache__/analog_input.cpython-314.pyc
index 1df8300..d82bc07 100644
--- a/devices/__pycache__/analog_input.cpython-314.pyc
+++ b/devices/__pycache__/analog_input.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/base_device.cpython-312.pyc b/devices/__pycache__/base_device.cpython-312.pyc
index 087e500..d5604d7 100644
--- a/devices/__pycache__/base_device.cpython-312.pyc
+++ b/devices/__pycache__/base_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/base_device.cpython-314.pyc b/devices/__pycache__/base_device.cpython-314.pyc
index b8f94f4..fa99267 100644
--- a/devices/__pycache__/base_device.cpython-314.pyc
+++ b/devices/__pycache__/base_device.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/cml_device.cpython-312.pyc b/devices/__pycache__/cml_device.cpython-312.pyc
new file mode 100644
index 0000000..ded108c
--- /dev/null
+++ b/devices/__pycache__/cml_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/cml_device.cpython-314.pyc b/devices/__pycache__/cml_device.cpython-314.pyc
new file mode 100644
index 0000000..4eae5e4
--- /dev/null
+++ b/devices/__pycache__/cml_device.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/device_registry.cpython-312.pyc b/devices/__pycache__/device_registry.cpython-312.pyc
index 3495e35..276f1e2 100644
--- a/devices/__pycache__/device_registry.cpython-312.pyc
+++ b/devices/__pycache__/device_registry.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/device_registry.cpython-314.pyc b/devices/__pycache__/device_registry.cpython-314.pyc
index f3f7d3e..6912654 100644
--- a/devices/__pycache__/device_registry.cpython-314.pyc
+++ b/devices/__pycache__/device_registry.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/digital_io.cpython-312.pyc b/devices/__pycache__/digital_io.cpython-312.pyc
index 1907cb3..c0262d5 100644
--- a/devices/__pycache__/digital_io.cpython-312.pyc
+++ b/devices/__pycache__/digital_io.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/digital_io.cpython-314.pyc b/devices/__pycache__/digital_io.cpython-314.pyc
index 1b438fc..d51aaf5 100644
--- a/devices/__pycache__/digital_io.cpython-314.pyc
+++ b/devices/__pycache__/digital_io.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/mark10_device.cpython-312.pyc b/devices/__pycache__/mark10_device.cpython-312.pyc
new file mode 100644
index 0000000..3148ba6
--- /dev/null
+++ b/devices/__pycache__/mark10_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/mark10_device.cpython-314.pyc b/devices/__pycache__/mark10_device.cpython-314.pyc
new file mode 100644
index 0000000..ca0bdd5
--- /dev/null
+++ b/devices/__pycache__/mark10_device.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/modbus_device.cpython-312.pyc b/devices/__pycache__/modbus_device.cpython-312.pyc
new file mode 100644
index 0000000..8e59813
--- /dev/null
+++ b/devices/__pycache__/modbus_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/modbus_device.cpython-314.pyc b/devices/__pycache__/modbus_device.cpython-314.pyc
new file mode 100644
index 0000000..7d32d53
--- /dev/null
+++ b/devices/__pycache__/modbus_device.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/scpi_device.cpython-312.pyc b/devices/__pycache__/scpi_device.cpython-312.pyc
new file mode 100644
index 0000000..87750b8
--- /dev/null
+++ b/devices/__pycache__/scpi_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/scpi_device.cpython-314.pyc b/devices/__pycache__/scpi_device.cpython-314.pyc
new file mode 100644
index 0000000..65af1b5
--- /dev/null
+++ b/devices/__pycache__/scpi_device.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/serial_device.cpython-312.pyc b/devices/__pycache__/serial_device.cpython-312.pyc
index 53ba302..e885b40 100644
--- a/devices/__pycache__/serial_device.cpython-312.pyc
+++ b/devices/__pycache__/serial_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/serial_device.cpython-314.pyc b/devices/__pycache__/serial_device.cpython-314.pyc
index 0bbe0bc..976e575 100644
--- a/devices/__pycache__/serial_device.cpython-314.pyc
+++ b/devices/__pycache__/serial_device.cpython-314.pyc
Binary files differ
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
index 8dc1c2f..42ac581 100644
--- a/ui/__pycache__/__init__.cpython-312.pyc
+++ b/ui/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc
index 15d71d0..ef40b13 100644
--- a/ui/__pycache__/__init__.cpython-314.pyc
+++ b/ui/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/add_device_dialog.cpython-312.pyc b/ui/__pycache__/add_device_dialog.cpython-312.pyc
index 2da95a6..990c229 100644
--- a/ui/__pycache__/add_device_dialog.cpython-312.pyc
+++ b/ui/__pycache__/add_device_dialog.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/add_device_dialog.cpython-314.pyc b/ui/__pycache__/add_device_dialog.cpython-314.pyc
new file mode 100644
index 0000000..f5852ba
--- /dev/null
+++ b/ui/__pycache__/add_device_dialog.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/config_dialog.cpython-312.pyc b/ui/__pycache__/config_dialog.cpython-312.pyc
index cbc9cf0..5a051dc 100644
--- a/ui/__pycache__/config_dialog.cpython-312.pyc
+++ b/ui/__pycache__/config_dialog.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/control_editor.cpython-312.pyc b/ui/__pycache__/control_editor.cpython-312.pyc
index 3a53377..e76411d 100644
--- a/ui/__pycache__/control_editor.cpython-312.pyc
+++ b/ui/__pycache__/control_editor.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/control_panel.cpython-312.pyc b/ui/__pycache__/control_panel.cpython-312.pyc
index 1c101a8..45f1eb9 100644
--- a/ui/__pycache__/control_panel.cpython-312.pyc
+++ b/ui/__pycache__/control_panel.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/main_window.cpython-312.pyc b/ui/__pycache__/main_window.cpython-312.pyc
index 054aa5b..3aefd67 100644
--- a/ui/__pycache__/main_window.cpython-312.pyc
+++ b/ui/__pycache__/main_window.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/profile_manager_ui.cpython-312.pyc b/ui/__pycache__/profile_manager_ui.cpython-312.pyc
index 97cbe3a..93491f7 100644
--- a/ui/__pycache__/profile_manager_ui.cpython-312.pyc
+++ b/ui/__pycache__/profile_manager_ui.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/strip_chart.cpython-312.pyc b/ui/__pycache__/strip_chart.cpython-312.pyc
index ff41c1a..a3e25c0 100644
--- a/ui/__pycache__/strip_chart.cpython-312.pyc
+++ b/ui/__pycache__/strip_chart.cpython-312.pyc
Binary files differ
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
index 66495c5..1454fd8 100644
--- a/ui/windows/__pycache__/__init__.cpython-312.pyc
+++ b/ui/windows/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/devices_window.cpython-312.pyc b/ui/windows/__pycache__/devices_window.cpython-312.pyc
index 601221d..fb52b79 100644
--- a/ui/windows/__pycache__/devices_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/devices_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/plot_window.cpython-312.pyc b/ui/windows/__pycache__/plot_window.cpython-312.pyc
index c72d74b..7499763 100644
--- a/ui/windows/__pycache__/plot_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/plot_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/settings_window.cpython-312.pyc b/ui/windows/__pycache__/settings_window.cpython-312.pyc
index d596487..47d5611 100644
--- a/ui/windows/__pycache__/settings_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/settings_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/signals_window.cpython-312.pyc b/ui/windows/__pycache__/signals_window.cpython-312.pyc
index 8b0db88..ec57533 100644
--- a/ui/windows/__pycache__/signals_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/signals_window.cpython-312.pyc
Binary files differ