From 203985c4379fa97de512697edcdc20f34cf8c0f6 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 29 Sep 2025 22:00:18 +0000 Subject: [PATCH 01/14] docs: add microagents repo summary to .openhands/microagents/repo.md chore(tests): add pytest suite mocking pyserial to verify SerialManager instantiates connections for all devices and probes without real hardware; cover failure tolerance and reconnect logic fix: clean serial_manager imports (use relative logger import)\n\nCo-authored-by: openhands --- .openhands/microagents/repo.md | 31 +++++ .../src/benchmesh_service/serial_manager.py | 3 +- .../tests/test_serial_manager.py | 106 ++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 .openhands/microagents/repo.md create mode 100644 benchmesh-serial-service/tests/test_serial_manager.py diff --git a/.openhands/microagents/repo.md b/.openhands/microagents/repo.md new file mode 100644 index 0000000..3e14d2a --- /dev/null +++ b/.openhands/microagents/repo.md @@ -0,0 +1,31 @@ +Repository summary for microagents + +1) Purpose +BenchMesh provides a consistent, browser-accessible cockpit for lab instruments, enabling connection, control, logging, correlation, and automation from one place. The benchmesh-serial-service sub-project is a Python service that manages multiple serial connections to instruments defined in a YAML configuration, with a modular driver architecture to support different instrument models. + +2) General setup +- Languages/Runtime: Python 3.8+ +- Key libraries: pyserial (serial communication), pyyaml (config parsing), loguru/logging (logging) +- Configuration: YAML files (top-level config.yaml and benchmesh-serial-service/config.yaml) define devices and their serial parameters. +- Entry point for serial service: python -m src.benchmesh_service.main (run from benchmesh-serial-service directory) +- Packaging: pyproject.toml in benchmesh-serial-service defines a Poetry package (packages include src/benchmesh_service) + +3) Repository structure (brief) +- README.md: Top-level project description +- config.yaml: Example top-level device configuration +- benchmesh-serial-service/ + - README.md: Service overview and usage + - requirements.txt: Minimal runtime dependencies + - pyproject.toml: Poetry configuration + - config.yaml: Service-specific example configuration + - src/benchmesh_service/ + - main.py: Service bootstrap; loads config and spawns connection monitor + - serial_manager.py: Core connection manager (opens, monitors, and probes serial connections for devices) + - config.py: YAML config loader and helper class + - device.py: Simple device abstraction + - logger.py: Logger setup + - drivers/: Device-specific drivers (owon_oel, owon_spm, tenma_psu, owon_xdm) +- drivers/, system/, exampleRS232.py: Additional examples/ancillary code at repository root + +CI/Workflows under .github +- No .github directory or GitHub workflows were found in this repository at the time of writing. Therefore, there are no repository-defined CI checks (e.g., linting, tests) enforced via GitHub Actions. diff --git a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py index faf6151..9d48a73 100644 --- a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py +++ b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py @@ -3,8 +3,7 @@ import threading import yaml import logging -from typing import List, Dict, Optional -from src.benchmesh_service.logger import setup_logger +from typing import Dict from .logger import setup_logger logger = logging.getLogger(__name__) diff --git a/benchmesh-serial-service/tests/test_serial_manager.py b/benchmesh-serial-service/tests/test_serial_manager.py new file mode 100644 index 0000000..3c4102b --- /dev/null +++ b/benchmesh-serial-service/tests/test_serial_manager.py @@ -0,0 +1,106 @@ +import os +import sys +from unittest.mock import patch +import types +import time + +# Ensure package importable +THIS_DIR = os.path.dirname(__file__) +SRC_DIR = os.path.abspath(os.path.join(THIS_DIR, '..', 'src')) +if SRC_DIR not in sys.path: + sys.path.insert(0, SRC_DIR) + +from benchmesh_service.serial_manager import SerialManager + + +class FakeSerial: + def __init__(self, port, baudrate=115200, bytesize=None, parity=None, stopbits=None, timeout=1.0): + self.port = port + self.baudrate = baudrate + self.is_open = True + self._written = [] + self._read_buffer = b"" + self._raise_on_read = False + + def write(self, data: bytes): + self._written.append(bytes(data)) + + def read(self, size: int = 256) -> bytes: + if self._raise_on_read: + raise Exception("read error") + if self._read_buffer: + data, self._read_buffer = self._read_buffer[:size], self._read_buffer[size:] + return data + return b"" + + def close(self): + self.is_open = False + + +def make_devices(n=3): + devs = [] + for i in range(n): + devs.append({ + 'id': f'dev-{i+1}', + 'name': f'Device {i+1}', + 'driver': 'dummy', + 'port': f'/dev/ttyFAKE{i+1}', + 'baud': 115200, + 'serial': '8N1', + 'seol': '\r', + 'reol': '\n', + }) + return devs + + +def test_establish_connections_opens_all_devices(): + devices = make_devices(4) + with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): + m = SerialManager(devices) + assert set(m.connections.keys()) == {d['id'] for d in devices} + for dev_id, ser in m.connections.items(): + assert isinstance(ser, FakeSerial) + assert ser.is_open is True + + +def test_establish_connections_tolerates_failures_and_continues(): + devices = make_devices(3) + + def serial_factory(**kw): + if kw.get('port') == devices[1]['port']: + raise Exception('open failed') + return FakeSerial(**kw) + + with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=serial_factory): + m = SerialManager(devices) + # two should be connected, the failing one missing + assert set(m.connections.keys()) == {devices[0]['id'], devices[2]['id']} + + +def test_check_status_probes_and_leaves_connection_on_no_response(): + devices = make_devices(1) + with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): + m = SerialManager(devices) + # No response by default -> connection should remain, just logs + m.check_status() + assert m.connections[devices[0]['id']] is not None + + +def test_check_status_sets_none_on_read_exception(): + devices = make_devices(1) + with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): + m = SerialManager(devices) + ser = m.connections[devices[0]['id']] + ser._raise_on_read = True + m.check_status() + assert m.connections[devices[0]['id']] is None + + +def test_check_status_writes_probe(): + devices = make_devices(1) + with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): + m = SerialManager(devices) + ser = m.connections[devices[0]['id']] + m.check_status() + # Should have attempted to write a probe (*IDN? or EOL) + assert any(w for w in ser._written), 'Expected at least one write during probe' From 8048b86829f199e125e65efc658ade1045fba98f Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 29 Sep 2025 22:05:10 +0000 Subject: [PATCH 02/14] fix(serial): align reconnect signature to accept device or id, and make establish_connections use per-device reconnect test: add backoff/reopen test and relax failing-open assertion; switch requirements.txt to pyserial to match pyproject Co-authored-by: openhands --- benchmesh-serial-service/requirements.txt | 2 +- .../src/benchmesh_service/serial_manager.py | 60 +++++++++++++------ .../tests/test_serial_manager.py | 44 +++++++++++++- 3 files changed, 85 insertions(+), 21 deletions(-) diff --git a/benchmesh-serial-service/requirements.txt b/benchmesh-serial-service/requirements.txt index 2aae25f..d86e202 100644 --- a/benchmesh-serial-service/requirements.txt +++ b/benchmesh-serial-service/requirements.txt @@ -1,3 +1,3 @@ -serial +pyserial pyyaml loguru \ No newline at end of file diff --git a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py index 9d48a73..2a4b151 100644 --- a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py +++ b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py @@ -29,16 +29,7 @@ def establish_connections(self): for device in self.devices: print("Establishing connections to devices...", device) try: - ser = serial.Serial( - port=device['port'], - baudrate=device['baud'], - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - timeout=1.0 - ) - self.connections[device['id']] = ser - self.logger.info(f"Established connection to {device['name']} on {device['port']}") + self.reconnect(device) except Exception as e: self.logger.info(f"Failed to connect to {device['name']} on {device['port']}: {e}") @@ -55,14 +46,47 @@ def monitor_connections(self): self.reconnect(device_id) time.sleep(0.5) - def reconnect(self, device_id): - device = next((d for d in self.devices if d['id'] == device_id), None) - if device: - try: - self.connections[device_id].close() - self.establish_connections() - except Exception as e: - self.logger.error(f"Reconnection failed for {device['name']}: {e}") + def reconnect(self, device_or_id): + """ + Attempt to (re)open a connection for a single device. + Accepts either the device dict or its id string. Returns the new serial + connection on success, or None on failure. + """ + if isinstance(device_or_id, dict): + dev = device_or_id + device_id = dev.get('id') + else: + device_id = device_or_id + dev = next((d for d in self.devices if d.get('id') == device_id), None) + + if not dev or not device_id: + return None + + # Close existing connection if present + try: + old = self.connections.get(device_id) + if old: + old.close() + except Exception: + pass + + # Open fresh connection for this device only + try: + ser = serial.Serial( + port=dev['port'], + baudrate=dev['baud'], + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1.0 + ) + self.connections[device_id] = ser + self.logger.info(f"(Re)connected to {dev['name']} on {dev['port']}") + return ser + except Exception as e: + self.logger.error(f"Reconnection failed for {dev.get('name', device_id)}: {e}") + self.connections[device_id] = None + return None def start(self): self.establish_connections() diff --git a/benchmesh-serial-service/tests/test_serial_manager.py b/benchmesh-serial-service/tests/test_serial_manager.py index 3c4102b..1068642 100644 --- a/benchmesh-serial-service/tests/test_serial_manager.py +++ b/benchmesh-serial-service/tests/test_serial_manager.py @@ -1,4 +1,6 @@ import os +import time + import sys from unittest.mock import patch import types @@ -73,8 +75,10 @@ def serial_factory(**kw): with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=serial_factory): m = SerialManager(devices) - # two should be connected, the failing one missing - assert set(m.connections.keys()) == {devices[0]['id'], devices[2]['id']} + # two should be connected; the failing one may be absent or present with None + assert devices[0]['id'] in m.connections + assert devices[2]['id'] in m.connections + assert (devices[1]['id'] not in m.connections) or (m.connections[devices[1]['id']] is None) def test_check_status_probes_and_leaves_connection_on_no_response(): @@ -104,3 +108,39 @@ def test_check_status_writes_probe(): m.check_status() # Should have attempted to write a probe (*IDN? or EOL) assert any(w for w in ser._written), 'Expected at least one write during probe' + + +def test_reconnect_backoff_and_successful_reopen(monkeypatch): + devices = make_devices(1) + + opened = {'count': 0} + + class FlakySerial(FakeSerial): + def __init__(self, **kw): + opened['count'] += 1 + super().__init__(**kw) + + # First create manager with a working connection + with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=lambda **kw: FlakySerial(**kw)): + m = SerialManager(devices) + + # Simulate a read failure to drop the connection to None + ser = m.connections[devices[0]['id']] + ser._raise_on_read = True + m.check_status() + assert m.connections[devices[0]['id']] is None + + # Before backoff delay, calling check_status should NOT reopen + t0 = time.time() + m.last_open_attempt[devices[0]['id']] = t0 + m.check_status() + assert m.connections[devices[0]['id']] is None + + # After backoff is satisfied, patch Serial again to succeed and ensure reopen happens + time.sleep(2.05) + with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=lambda **kw: FlakySerial(**kw)): + m.check_status() + + assert m.connections[devices[0]['id']] is not None + assert opened['count'] >= 2 # initial + reopen + From 8a42ba7669fb12557151ab8556256560466f1738 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 29 Sep 2025 22:18:52 +0000 Subject: [PATCH 03/14] ci: add GitHub Actions workflow to run pytest on push/PR for benchmesh-serial-service\n\nCo-authored-by: openhands --- .github/workflows/ci.yaml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..0d5b961 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies (service) + working-directory: benchmesh-serial-service + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; fi + python -m pip install pytest + + - name: Run tests + run: | + python -m pytest -q benchmesh-serial-service/tests From 9e2f29d9c039bcf65cb001821875d94bdbd5a843 Mon Sep 17 00:00:00 2001 From: MarkoV Date: Tue, 30 Sep 2025 18:56:55 +0100 Subject: [PATCH 04/14] updating manifests --- benchmesh-serial-service/config.yaml | 24 ++++------ config.yaml | 24 ++++------ drivers/owon_dge/manifest.json | 5 +++ drivers/owon_oel/manifest.json | 5 +++ drivers/owon_spm/manifest.json | 5 +++ drivers/owon_xdm/manifest.json | 5 +++ drivers/tenma_72/manifest.json | 7 ++- exampleRS232.py | 8 +++- exampleRS232text.py | 66 ++++++++++++++++++++++++++++ 9 files changed, 115 insertions(+), 34 deletions(-) create mode 100644 exampleRS232text.py diff --git a/benchmesh-serial-service/config.yaml b/benchmesh-serial-service/config.yaml index 8e6e16d..2309c09 100644 --- a/benchmesh-serial-service/config.yaml +++ b/benchmesh-serial-service/config.yaml @@ -1,31 +1,25 @@ version: 1 devices: - - id: eol-1 - name: "OWON OEL" - driver: owon_oel - port: /dev/ttyOEL1515 - baud: 115200 - serial: 8N1 - seol: "\n" - reol: "\n" - model: OEL1515 + - id: eol-1 # unique ID for this device used in WS and MQTT topics + name: "OWON OEL" # friendly name for UI more details about device will come from SCPI query if available + driver: owon_oel # driver name from drivers/ folder + port: /dev/ttyOEL1515 # adjust to your /dev/ path + baud: 115200 # many OWON units use 115200 baud + serial: 8N1 # optional, defaults to 8N1 if not specified + model: OEL1515 # optional model override if auto-detect fails - id: psu-1 name: "OWON SPM" driver: owon_spm port: /dev/ttySPM3103 baud: 115200 serial: 8N1 - seol: "\n" - reol: "\n" model: SPM3103 - id: tenmapsu-1 name: "TENMA PSU" driver: tenma_psu port: /dev/tty722540 - baud: 115200 + baud: 9600 serial: 8N1 - seol: "\r\n" - reol: "\n" model: 72-2540 - id: dmm-1 name: "OWON XDM" @@ -33,6 +27,4 @@ devices: port: /dev/ttyXDM1241 baud: 115200 serial: 8N1 - seol: "\r\n" - reol: "\n" model: XDM1241 \ No newline at end of file diff --git a/config.yaml b/config.yaml index 2d1039f..2309c09 100644 --- a/config.yaml +++ b/config.yaml @@ -1,31 +1,25 @@ version: 1 devices: - - id: eol-1 # unique ID for this device used in WS and MQTT topics - name: "OWON OEL" # friendly name for UI more details about device will come from SCPI query if available - driver: owon_oel # driver name from drivers/ folder - port: /dev/ttyOEL1515 # adjust to your /dev/ path - baud: 115200 # many OWON units use 115200 baud - serial: 8N1 # optional, defaults to 8N1 if not specified - seol: "\n" # send end of line if queries time out, try "\r\n" - reol: "\n" # receive end of line if queries time out, try "\r\n" - model: OEL1515 # optional model override if auto-detect fails + - id: eol-1 # unique ID for this device used in WS and MQTT topics + name: "OWON OEL" # friendly name for UI more details about device will come from SCPI query if available + driver: owon_oel # driver name from drivers/ folder + port: /dev/ttyOEL1515 # adjust to your /dev/ path + baud: 115200 # many OWON units use 115200 baud + serial: 8N1 # optional, defaults to 8N1 if not specified + model: OEL1515 # optional model override if auto-detect fails - id: psu-1 name: "OWON SPM" driver: owon_spm port: /dev/ttySPM3103 baud: 115200 serial: 8N1 - seol: "\n" - reol: "\n" model: SPM3103 - id: tenmapsu-1 name: "TENMA PSU" driver: tenma_psu port: /dev/tty722540 - baud: 115200 + baud: 9600 serial: 8N1 - seol: "\r\n" - reol: "\n" model: 72-2540 - id: dmm-1 name: "OWON XDM" @@ -33,6 +27,4 @@ devices: port: /dev/ttyXDM1241 baud: 115200 serial: 8N1 - seol: "\r\n" - reol: "\n" model: XDM1241 \ No newline at end of file diff --git a/drivers/owon_dge/manifest.json b/drivers/owon_dge/manifest.json index 6773b0b..870ac27 100644 --- a/drivers/owon_dge/manifest.json +++ b/drivers/owon_dge/manifest.json @@ -11,6 +11,11 @@ "classes": [ "AFG" ], + "connection": { + "seol": "\r", + "reol": "\r", + "connection_verification_command": "*IDN?" + }, "instrument_class": { "AFG": { "features": { diff --git a/drivers/owon_oel/manifest.json b/drivers/owon_oel/manifest.json index 7d78ca0..bce99c6 100644 --- a/drivers/owon_oel/manifest.json +++ b/drivers/owon_oel/manifest.json @@ -11,6 +11,11 @@ "classes": [ "ELL" ], + "connection": { + "seol": "\r", + "reol": "\r", + "connection_verification_command": "*IDN?" + }, "instrument_class": { "ELL": { "features": { diff --git a/drivers/owon_spm/manifest.json b/drivers/owon_spm/manifest.json index 86c7f05..5a8d816 100644 --- a/drivers/owon_spm/manifest.json +++ b/drivers/owon_spm/manifest.json @@ -12,6 +12,11 @@ "PSU", "DMM" ], + "connection": { + "seol": "\r", + "reol": "\r", + "connection_verification_command": "*IDN?" + }, "instrument_class": { "PSU": { "features": { diff --git a/drivers/owon_xdm/manifest.json b/drivers/owon_xdm/manifest.json index b94b669..2542a1e 100644 --- a/drivers/owon_xdm/manifest.json +++ b/drivers/owon_xdm/manifest.json @@ -11,6 +11,11 @@ "classes": [ "DMM" ], + "connection": { + "seol": "\r", + "reol": "\r", + "connection_verification_command": "*IDN?" + }, "instrument_class": { "DMM": { "features": { diff --git a/drivers/tenma_72/manifest.json b/drivers/tenma_72/manifest.json index 7eae1e6..96f9a9b 100644 --- a/drivers/tenma_72/manifest.json +++ b/drivers/tenma_72/manifest.json @@ -6,11 +6,16 @@ "models": { "72-2540": { "id_patterns": [ - "TENMA,72-2540" + "TENMA 72-2540" ], "classes": [ "PSU" ], + "connection": { + "seol": "", + "reol": "", + "connection_verification_command": "*IDN?" + }, "instrument_class": { "PSU": { "features": { diff --git a/exampleRS232.py b/exampleRS232.py index 8e5657f..0d57a8e 100644 --- a/exampleRS232.py +++ b/exampleRS232.py @@ -1,8 +1,12 @@ import serial, time ser = serial.Serial( - port='/dev/ttyOEL1515', # or '/dev/ttyUSB0' + # port='/dev/ttyOEL1515', # or '/dev/ttyUSB0' + # port='/dev/ttySPM3103', + port='/dev/ttyXDM1241', + #port='/dev/tty722540', baudrate=115200, # match instrument + #baudrate=9600, # match instrument bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, @@ -30,6 +34,8 @@ # SCPI identity query (common on loads) ser.write(b'*IDN?\r') +#ser.write(b'*IDN?') +#ser.write(b'ISET1?') time.sleep(0.2) reply = ser.read(1024) print("Reply:", reply) diff --git a/exampleRS232text.py b/exampleRS232text.py new file mode 100644 index 0000000..16a4d41 --- /dev/null +++ b/exampleRS232text.py @@ -0,0 +1,66 @@ +import serial +import time +from typing import Optional + +def send_text_command(port: str, baud: int, command: str, seol: str = "\r", timeout: float = 1.0, read_size: int = 1024) -> Optional[str]: + """ + Open serial port, send `command` as text (with `seol` appended), read response and return it decoded. + Returns None on error. + """ + ser = None + try: + ser = serial.Serial( + port=port, + baudrate=baud, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=timeout, + xonxoff=False, + rtscts=False, + dsrdtr=False + ) + # assert control lines if desired + try: + ser.setDTR(False) + ser.setRTS(False) + except Exception: + pass + + time.sleep(0.2) # small settle + + text = f"{command}{seol}" + print(f"Sending: {text!r}") + ser.write(text.encode("ascii")) + #ser.write(text.encode("utf-8")) + time.sleep(0.2) + resp = ser.read(read_size) + if not resp: + print("A") + return "" + try: + print("B") + #return resp.decode("utf-8", errors="ignore") + return resp.decode("ascii", errors="ignore") + except Exception: + print("C") + #return resp.decode("latin1", errors="ignore") + return resp.decode("ascii", errors="ignore") + except Exception as e: + print("Serial error:", e) + return None + finally: + if ser and ser.is_open: + try: + ser.close() + except Exception: + pass + +if __name__ == "__main__": + # adjust port/baud to your device + port = "/dev/tty722540" + # baud = 115200 + baud = 9600 + reply = send_text_command(port, baud, "*IDN?", seol="") + print("Reply:", reply) +# ...existing code... \ No newline at end of file From 753e5248b554525deeebf2fdf281e1d664a2857c Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 30 Sep 2025 19:47:04 +0000 Subject: [PATCH 05/14] refactor(serial_manager): dynamically load devices from config.yaml and instantiate drivers via dynamic import; add shared SerialTransport and identify() in drivers; update tests to mock transport serial\n\nCo-authored-by: openhands --- .../src/benchmesh_service/drivers/owon_oel.py | 47 +---- .../src/benchmesh_service/drivers/owon_spm.py | 51 ++--- .../src/benchmesh_service/drivers/owon_xdm.py | 66 ++----- .../benchmesh_service/drivers/tenma_psu.py | 60 ++---- .../src/benchmesh_service/serial_manager.py | 181 ++++++++++-------- .../src/benchmesh_service/transport.py | 77 ++++++++ .../tests/test_driver_identify.py | 70 +++++++ .../tests/test_serial_manager.py | 35 ++-- 8 files changed, 319 insertions(+), 268 deletions(-) create mode 100644 benchmesh-serial-service/src/benchmesh_service/transport.py create mode 100644 benchmesh-serial-service/tests/test_driver_identify.py diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel.py b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel.py index 84275c6..2fbf0f2 100644 --- a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel.py +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel.py @@ -1,45 +1,18 @@ -import serial -import time +from ..transport import SerialTransport class OwonOEL: - def __init__(self, port, baudrate=115200): - self.port = port - self.baudrate = baudrate - self.ser = None - self.connect() + def __init__(self, port, baudrate=115200, serial_mode='8N1', seol='\r', reol='\r'): + self.t = SerialTransport(port, baudrate, serial_mode=serial_mode, seol=seol, reol=reol).open() - def connect(self): - self.ser = serial.Serial( - port=self.port, - baudrate=self.baudrate, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - timeout=1.0 - ) - time.sleep(0.2) + def identify(self): + self.t.write_line('*IDN?') + return self.t.read_until_reol(1024) - def write(self, command): - if self.ser and self.ser.is_open: - self.ser.write(command) + def write(self, data: bytes): + self.t.write(data) def read(self, size=1024): - if self.ser and self.ser.is_open: - return self.ser.read(size) - return None - - def check_status(self): - if self.ser and self.ser.is_open: - try: - self.ser.write(b'*IDN?\r') - time.sleep(0.2) - reply = self.ser.read(1024) - return reply - except Exception as e: - print(f"Error checking status: {e}") - self.connect() # Attempt to reconnect on error - return None + return self.t.read(size) def close(self): - if self.ser: - self.ser.close() \ No newline at end of file + self.t.close() \ No newline at end of file diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm.py b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm.py index 875e334..2c7da72 100644 --- a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm.py +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm.py @@ -1,45 +1,18 @@ -import serial -import time -import logging +from ..transport import SerialTransport class OWONSPM: - def __init__(self, port, baudrate=115200): - self.port = port - self.baudrate = baudrate - self.ser = None - self.connected = False - self.logger = logging.getLogger(__name__) + def __init__(self, port, baudrate=115200, serial_mode='8N1', seol='\r', reol='\r'): + self.t = SerialTransport(port, baudrate, serial_mode=serial_mode, seol=seol, reol=reol).open() - def connect(self): - try: - self.ser = serial.Serial(self.port, self.baudrate, timeout=1) - self.connected = True - self.logger.info(f"Connected to OWON SPM on {self.port}") - except serial.SerialException as e: - self.logger.error(f"Failed to connect to OWON SPM on {self.port}: {e}") + def identify(self): + self.t.write_line('*IDN?') + return self.t.read_until_reol(1024) - def disconnect(self): - if self.ser and self.ser.is_open: - self.ser.close() - self.connected = False - self.logger.info(f"Disconnected from OWON SPM on {self.port}") + def write(self, text: str): + self.t.write_line(text) - def send_command(self, command): - if self.connected: - self.ser.write(command.encode() + b'\r') - time.sleep(0.1) # wait for the command to be processed - response = self.ser.read(1024).decode() - return response - else: - self.logger.warning("Attempted to send command while not connected.") - return None + def read(self, size=1024): + return self.t.read(size) - def check_status(self): - if self.connected: - self.logger.info("Checking status of OWON SPM.") - # Implement status check logic here - else: - self.logger.warning("Cannot check status, not connected.") - - def __del__(self): - self.disconnect() \ No newline at end of file + def close(self): + self.t.close() \ No newline at end of file diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm.py b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm.py index 6ee3320..40bd96c 100644 --- a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm.py +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm.py @@ -1,61 +1,21 @@ -import serial -import time -import logging +from ..transport import SerialTransport class OWONXDM: - def __init__(self, port, baudrate): - self.port = port - self.baudrate = baudrate - self.ser = None - self.connected = False + def __init__(self, port, baudrate=115200, serial_mode='8N1', seol='\r', reol='\r'): + self.t = SerialTransport(port, baudrate, serial_mode=serial_mode, seol=seol, reol=reol).open() - def connect(self): - try: - self.ser = serial.Serial( - port=self.port, - baudrate=self.baudrate, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - timeout=1.0 - ) - self.connected = True - logging.info(f"Connected to OWON XDM on {self.port}") - except serial.SerialException as e: - logging.error(f"Failed to connect to OWON XDM on {self.port}: {e}") - self.connected = False + def identify(self): + self.t.write_line('*IDN?') + return self.t.read_until_reol(1024) - def disconnect(self): - if self.ser and self.ser.is_open: - self.ser.close() - self.connected = False - logging.info(f"Disconnected from OWON XDM on {self.port}") + def write(self, data: bytes): + self.t.write(data) - def send_command(self, command): - if self.connected: - self.ser.write(command) - time.sleep(0.1) # wait for the command to be processed - response = self.ser.read(1024) # read response - return response - else: - logging.warning("Attempted to send command while not connected.") - return None + def read(self, size=1024): + return self.t.read(size) - def check_status(self): - if self.connected: - try: - self.ser.write(b'*IDN?\r') - time.sleep(0.1) - reply = self.ser.read(1024) - logging.info(f"Status check reply: {reply}") - return reply - except Exception as e: - logging.error(f"Error checking status: {e}") - self.disconnect() - return None - else: - logging.warning("Attempted to check status while not connected.") - return None + def close(self): + self.t.close() def is_connected(self): - return self.connected \ No newline at end of file + return self.t.is_open \ No newline at end of file diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_psu.py b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_psu.py index ff1c0bf..115899f 100644 --- a/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_psu.py +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_psu.py @@ -1,52 +1,20 @@ -import serial -import time -import threading -from src.benchmesh_service.logger import logger +from ..transport import SerialTransport +from ..logger import logger class TenmaPSU: - def __init__(self, port, baudrate=115200): - self.port = port - self.baudrate = baudrate - self.ser = None - self.is_connected = False + def __init__(self, port, baudrate=115200, serial_mode='8N1', seol='', reol=''): + # TENMA manifest declares empty EOLs + self.t = SerialTransport(port, baudrate, serial_mode=serial_mode, seol=seol, reol=reol).open() - def connect(self): - try: - self.ser = serial.Serial(self.port, self.baudrate, timeout=1) - self.is_connected = True - logger.info(f"Connected to TENMA PSU on {self.port}") - except serial.SerialException as e: - logger.error(f"Failed to connect to TENMA PSU: {e}") + def identify(self): + self.t.write_line('*IDN?') + return self.t.read_until_reol(1024) - def disconnect(self): - if self.ser and self.ser.is_open: - self.ser.close() - self.is_connected = False - logger.info("Disconnected from TENMA PSU") + def write(self, text: str): + self.t.write_line(text) - def send_command(self, command): - if self.is_connected: - self.ser.write(command.encode() + b'\r\n') - time.sleep(0.1) # wait for the command to be processed - response = self.ser.readline().decode().strip() - return response - else: - logger.warning("Attempted to send command while not connected") - return None + def read(self, size=1024): + return self.t.read(size) - def check_status(self): - if self.is_connected: - logger.info("Checking status of TENMA PSU") - # Example command to check status - response = self.send_command("*IDN?") - logger.info(f"Status response: {response}") - else: - logger.warning("Cannot check status, not connected") - - def start_status_check(self): - def status_check_loop(): - while self.is_connected: - self.check_status() - time.sleep(0.5) # check status every 500ms - - threading.Thread(target=status_check_loop, daemon=True).start() \ No newline at end of file + def close(self): + self.t.close() \ No newline at end of file diff --git a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py index 2a4b151..93088c8 100644 --- a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py +++ b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py @@ -1,28 +1,65 @@ -import serial +import json +import os import time import threading -import yaml import logging -from typing import Dict +import importlib +import inspect +from typing import Dict, List, Any +import yaml from .logger import setup_logger logger = logging.getLogger(__name__) +MANIFEST_ALIASES = { + 'tenma_psu': 'tenma_72', +} + + +def _repo_root() -> str: + return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) + + +def _load_manifest(driver_key: str) -> Dict: + here = _repo_root() + manifest_key = MANIFEST_ALIASES.get(driver_key, driver_key) + path = os.path.join(here, 'drivers', manifest_key, 'manifest.json') + with open(path, 'r') as f: + return json.load(f) + + +def _load_driver_class(driver_key: str): + mod_name = f"benchmesh_service.drivers.{driver_key}" + mod = importlib.import_module(mod_name) + # pick the first class defined in the module + for name, obj in inspect.getmembers(mod, inspect.isclass): + if obj.__module__ == mod.__name__: + return obj + raise ImportError(f"No driver class found in module {mod_name}") + + class SerialManager: - def __init__(self, config_file): - print("Initializing SerialManager with config:", config_file) + def __init__(self, config_source: Any): + print("Initializing SerialManager with config:", config_source) self.logger = setup_logger() - self.devices = config_file - self.connections = {} + self.devices: List[Dict] = self._load_devices(config_source) + self.connections: Dict[str, object] = {} self.keep_running = True self.last_open_attempt: Dict[str, float] = {} self.last_ok: Dict[str, float] = {} self.establish_connections() - # def load_config(self, config_file): - # with open(config_file, 'r') as file: - # config = yaml.safe_load(file) - # return config['devices'] + + def _load_devices(self, source: Any) -> List[Dict]: + if isinstance(source, list): + return source + # treat as path + cfg_path = source if isinstance(source, str) else os.path.join(_repo_root(), 'config.yaml') + if not os.path.isabs(cfg_path): + cfg_path = os.path.join(_repo_root(), cfg_path) + with open(cfg_path, 'r') as f: + cfg = yaml.safe_load(f) + return cfg.get('devices', []) def establish_connections(self): print("Establishing connections to devices...") @@ -31,13 +68,14 @@ def establish_connections(self): try: self.reconnect(device) except Exception as e: - self.logger.info(f"Failed to connect to {device['name']} on {device['port']}: {e}") + self.logger.info(f"Failed to connect to {device.get('name', device.get('id'))} on {device.get('port')}: {e}") def monitor_connections(self): print("Starting connection monitor thread.") while self.keep_running: - for device_id, connection in self.connections.items(): - if connection.is_open: + for device_id, drv in self.connections.items(): + is_open = getattr(getattr(drv, 't', None), 'is_open', False) + if is_open: self.logger.info(f"{device_id} is connected.") self.last_ok[device_id] = 0.0 self.last_open_attempt[device_id] = 0.0 @@ -47,11 +85,6 @@ def monitor_connections(self): time.sleep(0.5) def reconnect(self, device_or_id): - """ - Attempt to (re)open a connection for a single device. - Accepts either the device dict or its id string. Returns the new serial - connection on success, or None on failure. - """ if isinstance(device_or_id, dict): dev = device_or_id device_id = dev.get('id') @@ -62,27 +95,34 @@ def reconnect(self, device_or_id): if not dev or not device_id: return None - # Close existing connection if present + # Close existing driver if present try: old = self.connections.get(device_id) if old: - old.close() + close = getattr(old, 'close', None) + if callable(close): + close() except Exception: pass - # Open fresh connection for this device only + # Create driver instance using manifest EOL settings and config serial params try: - ser = serial.Serial( - port=dev['port'], - baudrate=dev['baud'], - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - timeout=1.0 + driver_key = dev['driver'] + cls = _load_driver_class(driver_key) + manifest = _load_manifest(driver_key) + conn = next(iter(manifest.get('models', {}).values())).get('connection', {}) + seol = conn.get('seol', '\r') + reol = conn.get('reol', '\r') + drv = cls( + dev['port'], + dev.get('baud', 115200), + serial_mode=dev.get('serial', '8N1'), + seol=seol, + reol=reol, ) - self.connections[device_id] = ser + self.connections[device_id] = drv self.logger.info(f"(Re)connected to {dev['name']} on {dev['port']}") - return ser + return drv except Exception as e: self.logger.error(f"Reconnection failed for {dev.get('name', device_id)}: {e}") self.connections[device_id] = None @@ -95,78 +135,63 @@ def start(self): def stop(self): self.keep_running = False - for connection in self.connections.values(): - connection.close() + for drv in self.connections.values(): + try: + drv.close() + except Exception: + pass self.logger.info("All connections closed.") def close_connections(self): - for dev_id, ser in list(self.connections.items()): - if ser: - try: - ser.close() - logger.info("Closed connection %s", dev_id) - except Exception: - logger.exception("Error closing %s", dev_id) - self.connections[dev_id] = None - + for dev_id, drv in list(self.connections.items()): + if drv: + try: + drv.close() + logger.info("Closed connection %s", dev_id) + except Exception: + logger.exception("Error closing %s", dev_id) + self.connections[dev_id] = None + def check_status(self): - """ - Ensure each configured device has a working connection. - Called periodically (main() schedules every 0.5s). - """ now = time.time() for dev in self.devices: dev_id = dev.get('id') if not dev_id: continue - ser = self.connections.get(dev_id) + drv = self.connections.get(dev_id) - # If no connection, try to open (with simple backoff) - if ser is None: + if drv is None: last_attempt = self.last_open_attempt.get(dev_id, 0.0) - # backoff 2s between attempts if now - last_attempt >= 2.0: self.last_open_attempt[dev_id] = now - new_ser = self.reconnect(dev) - if new_ser: + new_drv = self.reconnect(dev) + if new_drv: print("Opened connection to", dev_id) - self.connections[dev_id] = new_ser + self.connections[dev_id] = new_drv self.last_ok[dev_id] = 0.0 continue - # If we have a connection, probe it try: - seol = dev.get('seol', "\r") - # Try SCPI identity probe first, fall back to EOL if write fails - try: - ser.write(b'*IDN?\r') - except Exception: - try: - ser.write(seol.encode('utf-8') if isinstance(seol, str) else b'\r') - except Exception: - pass - - # brief wait then read - time.sleep(0.05) - resp = b'' - try: - resp = ser.read(256) - except Exception as e: - # read failed -> treat as lost connection - raise + ident = None + if hasattr(drv, 'identify'): + ident = drv.identify() + else: + # fallback: try to access transport + t = getattr(drv, 't', None) + if t: + t.write_line('*IDN?') + ident = t.read_until_reol(256) - if resp: + if ident: self.last_ok[dev_id] = now - logger.debug("Probe OK %s -> %s", dev_id, resp) + logger.debug("Probe OK %s -> %s", dev_id, ident) else: - # no response — keep connection but log debug logger.debug("No response from %s on probe", dev_id) except Exception as e: logger.warning("Connection error for %s: %s", dev_id, e) - # close and mark for reopen try: - ser.close() + drv.close() except Exception: pass - self.connections[dev_id] = None \ No newline at end of file + self.connections[dev_id] = None diff --git a/benchmesh-serial-service/src/benchmesh_service/transport.py b/benchmesh-serial-service/src/benchmesh_service/transport.py new file mode 100644 index 0000000..6fc0dde --- /dev/null +++ b/benchmesh-serial-service/src/benchmesh_service/transport.py @@ -0,0 +1,77 @@ +import serial +import time +from typing import Optional + +BYTESIZE_MAP = {5: serial.FIVEBITS, 6: serial.SIXBITS, 7: serial.SEVENBITS, 8: serial.EIGHTBITS} +PARITY_MAP = {'N': serial.PARITY_NONE, 'E': serial.PARITY_EVEN, 'O': serial.PARITY_ODD, 'M': serial.PARITY_MARK, 'S': serial.PARITY_SPACE} +STOPBITS_MAP = {1: serial.STOPBITS_ONE, 1.5: serial.STOPBITS_ONE_POINT_FIVE, 2: serial.STOPBITS_TWO} + + +def parse_serial_mode(mode: str): + if not mode or len(mode) < 3: + return (serial.EIGHTBITS, serial.PARITY_NONE, serial.STOPBITS_ONE) + try: + bits = int(mode[0]) + parity = mode[1].upper() + stop = float(mode[2:]) if len(mode) > 2 else 1 + return (BYTESIZE_MAP.get(bits, serial.EIGHTBITS), PARITY_MAP.get(parity, serial.PARITY_NONE), STOPBITS_MAP.get(stop, serial.STOPBITS_ONE)) + except Exception: + return (serial.EIGHTBITS, serial.PARITY_NONE, serial.STOPBITS_ONE) + + +class SerialTransport: + def __init__(self, port: str, baudrate: int, serial_mode: str = '8N1', timeout: float = 1.0, seol: str = '\r', reol: str = '\r'): + self.port = port + self.baudrate = baudrate + self.timeout = timeout + self.seol = seol.encode() if isinstance(seol, str) else (seol or b'') + self.reol = reol.encode() if isinstance(reol, str) else (reol or b'') + self._ser: Optional[serial.Serial] = None + bytesize, parity, stopbits = parse_serial_mode(serial_mode) + self._kwargs = dict(port=self.port, baudrate=self.baudrate, bytesize=bytesize, parity=parity, stopbits=stopbits, timeout=self.timeout) + + def open(self): + self._ser = serial.Serial(**self._kwargs) + time.sleep(0.05) + return self + + def close(self): + if self._ser: + try: + self._ser.close() + finally: + self._ser = None + + @property + def is_open(self) -> bool: + return bool(self._ser and getattr(self._ser, 'is_open', True)) + + def write(self, data: bytes): + if not self._ser: + raise RuntimeError('Transport not open') + self._ser.write(data) + + def write_line(self, text: str): + # If seol is empty, just write the text without terminator + data = text.encode('utf-8') + (self.seol or b'') + self.write(data) + + def read(self, size: int = 1024) -> bytes: + if not self._ser: + raise RuntimeError('Transport not open') + return self._ser.read(size) + + def read_until_reol(self, max_bytes: int = 4096) -> bytes: + if not self._ser: + raise RuntimeError('Transport not open') + if not self.reol: + return self._ser.read(max_bytes) + buf = bytearray() + while len(buf) < max_bytes: + b = self._ser.read(1) + if not b: + break + buf += b + if buf.endswith(self.reol): + break + return bytes(buf) diff --git a/benchmesh-serial-service/tests/test_driver_identify.py b/benchmesh-serial-service/tests/test_driver_identify.py new file mode 100644 index 0000000..49623f4 --- /dev/null +++ b/benchmesh-serial-service/tests/test_driver_identify.py @@ -0,0 +1,70 @@ +import os +import sys +from unittest.mock import patch + +THIS_DIR = os.path.dirname(__file__) +SRC_DIR = os.path.abspath(os.path.join(THIS_DIR, '..', 'src')) +if SRC_DIR not in sys.path: + sys.path.insert(0, SRC_DIR) + +from benchmesh_service.drivers.owon_oel import OwonOEL +from benchmesh_service.drivers.owon_spm import OWONSPM +from benchmesh_service.drivers.owon_xdm import OWONXDM +from benchmesh_service.drivers.tenma_psu import TenmaPSU + + +class FakeSerial: + def __init__(self, port, baudrate=115200, bytesize=None, parity=None, stopbits=None, timeout=1.0): + self.port = port + self.baudrate = baudrate + self.is_open = True + self._writes = [] + # Provide a line-terminated generic response + self._buf = b"VENDOR,MODEL,1.0\r" + + def write(self, data: bytes): + self._writes.append(bytes(data)) + + def read(self, n: int = 1) -> bytes: + if not self._buf: + return b"" + data = self._buf[:n] + self._buf = self._buf[n:] + return data + + def close(self): + self.is_open = False + + +def test_identify_owon_oel_uses_cr_eol(): + with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): + d = OwonOEL('/dev/ttyFAKE1', 115200, serial_mode='8N1', seol='\r', reol='\r') + idn = d.identify().decode('utf-8', errors='ignore') + assert 'VENDOR' in idn + + +def test_identify_owon_spm_uses_cr_eol(): + with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): + d = OWONSPM('/dev/ttyFAKE2', 115200, serial_mode='8N1', seol='\r', reol='\r') + idn = d.identify().decode('utf-8', errors='ignore') + assert 'VENDOR' in idn + + +def test_identify_owon_xdm_uses_cr_eol(): + with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): + d = OWONXDM('/dev/ttyFAKE3', 115200, serial_mode='8N1', seol='\r', reol='\r') + idn = d.identify().decode('utf-8', errors='ignore') + assert 'VENDOR' in idn + + +def test_identify_tenma_empty_eol(): + # TENMA specifies empty EOLs; transport must not append EOL and should read buffered + class TenmaFake(FakeSerial): + def __init__(self, **kw): + super().__init__(**kw) + self._buf = b"TENMA 72-2540 OK" + + with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: TenmaFake(**kw)): + d = TenmaPSU('/dev/ttyFAKE4', 9600, serial_mode='8N1', seol='', reol='') + idn = d.identify().decode('utf-8', errors='ignore') + assert 'TENMA' in idn or 'OK' in idn diff --git a/benchmesh-serial-service/tests/test_serial_manager.py b/benchmesh-serial-service/tests/test_serial_manager.py index 1068642..bafe92a 100644 --- a/benchmesh-serial-service/tests/test_serial_manager.py +++ b/benchmesh-serial-service/tests/test_serial_manager.py @@ -45,22 +45,27 @@ def make_devices(n=3): devs.append({ 'id': f'dev-{i+1}', 'name': f'Device {i+1}', - 'driver': 'dummy', + 'driver': 'owon_oel', 'port': f'/dev/ttyFAKE{i+1}', 'baud': 115200, 'serial': '8N1', - 'seol': '\r', - 'reol': '\n', }) return devs +def _get_underlying_serial(m: SerialManager, dev_id: str) -> FakeSerial: + drv = m.connections[dev_id] + return getattr(getattr(drv, 't', None), '_ser', None) + + def test_establish_connections_opens_all_devices(): devices = make_devices(4) - with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): + with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): m = SerialManager(devices) assert set(m.connections.keys()) == {d['id'] for d in devices} - for dev_id, ser in m.connections.items(): + for dev_id, drv in m.connections.items(): + assert hasattr(drv, 't') and getattr(drv.t, 'is_open', False) + ser = _get_underlying_serial(m, dev_id) assert isinstance(ser, FakeSerial) assert ser.is_open is True @@ -73,7 +78,7 @@ def serial_factory(**kw): raise Exception('open failed') return FakeSerial(**kw) - with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=serial_factory): + with patch('benchmesh_service.transport.serial.Serial', side_effect=serial_factory): m = SerialManager(devices) # two should be connected; the failing one may be absent or present with None assert devices[0]['id'] in m.connections @@ -83,7 +88,7 @@ def serial_factory(**kw): def test_check_status_probes_and_leaves_connection_on_no_response(): devices = make_devices(1) - with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): + with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): m = SerialManager(devices) # No response by default -> connection should remain, just logs m.check_status() @@ -92,9 +97,9 @@ def test_check_status_probes_and_leaves_connection_on_no_response(): def test_check_status_sets_none_on_read_exception(): devices = make_devices(1) - with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): + with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): m = SerialManager(devices) - ser = m.connections[devices[0]['id']] + ser = _get_underlying_serial(m, devices[0]['id']) ser._raise_on_read = True m.check_status() assert m.connections[devices[0]['id']] is None @@ -102,11 +107,11 @@ def test_check_status_sets_none_on_read_exception(): def test_check_status_writes_probe(): devices = make_devices(1) - with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): + with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): m = SerialManager(devices) - ser = m.connections[devices[0]['id']] m.check_status() - # Should have attempted to write a probe (*IDN? or EOL) + ser = _get_underlying_serial(m, devices[0]['id']) + # Should have attempted to write a probe (*IDN?) assert any(w for w in ser._written), 'Expected at least one write during probe' @@ -121,11 +126,11 @@ def __init__(self, **kw): super().__init__(**kw) # First create manager with a working connection - with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=lambda **kw: FlakySerial(**kw)): + with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FlakySerial(**kw)): m = SerialManager(devices) # Simulate a read failure to drop the connection to None - ser = m.connections[devices[0]['id']] + ser = _get_underlying_serial(m, devices[0]['id']) ser._raise_on_read = True m.check_status() assert m.connections[devices[0]['id']] is None @@ -138,7 +143,7 @@ def __init__(self, **kw): # After backoff is satisfied, patch Serial again to succeed and ensure reopen happens time.sleep(2.05) - with patch('benchmesh_service.serial_manager.serial.Serial', side_effect=lambda **kw: FlakySerial(**kw)): + with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FlakySerial(**kw)): m.check_status() assert m.connections[devices[0]['id']] is not None From 78bf7e47d8b537b565ae2f5c206a92b618934775 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 30 Sep 2025 20:16:13 +0000 Subject: [PATCH 06/14] fix(logger): prevent duplicate handlers and disable propagation to avoid repeated log lines\n\nCo-authored-by: openhands --- .../src/benchmesh_service/logger.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/benchmesh-serial-service/src/benchmesh_service/logger.py b/benchmesh-serial-service/src/benchmesh_service/logger.py index d70d7ed..65c4f2a 100644 --- a/benchmesh-serial-service/src/benchmesh_service/logger.py +++ b/benchmesh-serial-service/src/benchmesh_service/logger.py @@ -1,26 +1,33 @@ import logging + def setup_logger(): logger = logging.getLogger("benchmesh_service") + # Avoid adding duplicate handlers if called multiple times + if getattr(logger, "_is_configured", False): + return logger + logger.setLevel(logging.DEBUG) - # Create file handler fh = logging.FileHandler("benchmesh_service.log") fh.setLevel(logging.DEBUG) - # Create console handler ch = logging.StreamHandler() ch.setLevel(logging.INFO) - # Create formatter and add it to the handlers formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') fh.setFormatter(formatter) ch.setFormatter(formatter) - # Add the handlers to the logger logger.addHandler(fh) logger.addHandler(ch) + # Prevent propagation to root logger (avoids duplicate logs via root handlers) + logger.propagate = False + # Mark as configured + logger._is_configured = True + return logger + logger = setup_logger() \ No newline at end of file From 98786c01c19cf68925d813beae015684db0e1484 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 30 Sep 2025 20:50:49 +0000 Subject: [PATCH 07/14] test(serial): add YAML-driven instantiation test with mocked serial; fix manifest loading; support nested driver imports and tenma alias; adjust package imports Co-authored-by: openhands --- .../benchmesh_service/drivers}/classes.json | 0 .../drivers}/owon_dge/README.md | 0 .../doc/DGE2000_3000_SCPI_Protocol.pdf | Bin ...bitrary_Waveform_Generator_User_Manual.pdf | Bin .../drivers}/owon_dge/manifest.json | 0 .../drivers}/owon_oel/README.md | 0 .../drivers/owon_oel/__init__.py | 1 + ..._Series_DC_Electronic_Load_User_Manual.pdf | Bin ...EL15_30_Series_SCPI_Programming Manual.pdf | Bin .../owon_oel/doc/OEL85_Programming_Manual.pdf | Bin ..._Series_DC_Electronic_Load_User_Manual.pdf | Bin .../drivers}/owon_oel/manifest.json | 0 .../drivers/{ => owon_oel}/owon_oel.py | 2 +- .../drivers}/owon_spm/README.md | 0 .../drivers/owon_spm/__init__.py | 1 + .../owon_spm/doc/SPM_Series_User_Manual.pdf | Bin .../doc/SPM_Series_programming_manual.pdf | Bin .../drivers}/owon_spm/manifest.json | 0 .../drivers/{ => owon_spm}/owon_spm.py | 2 +- .../drivers}/owon_xdm/README.md | 0 .../drivers/owon_xdm/__init__.py | 1 + .../owon_xdm/doc/OWON_XDM1141_USER_MANUAL.pdf | Bin ..._Digital_Multimeter_Programming_Manual.pdf | Bin .../drivers}/owon_xdm/manifest.json | 0 .../drivers/{ => owon_xdm}/owon_xdm.py | 2 +- .../drivers}/tenma_72/README.md | 0 .../drivers/tenma_72/__init__.py | 2 + ...Series+Protocol+V2.0+of+Remote+Control.pdf | Bin .../doc/TENMA_72-2540_User_Manual.pdf | Bin .../drivers}/tenma_72/manifest.json | 0 .../drivers/{ => tenma_72}/tenma_psu.py | 4 +- .../src/benchmesh_service/serial_manager.py | 88 ++++++++++++++++-- .../tests/test_config_yaml_instantiation.py | 56 +++++++++++ .../tests/test_driver_identify.py | 8 +- 34 files changed, 148 insertions(+), 19 deletions(-) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/classes.json (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_dge/README.md (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_dge/doc/DGE2000_3000_SCPI_Protocol.pdf (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_dge/doc/DGE2000_Arbitrary_Waveform_Generator_User_Manual.pdf (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_dge/manifest.json (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_oel/README.md (100%) create mode 100644 benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/__init__.py rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_oel/doc/OEL15&30_Series_DC_Electronic_Load_User_Manual.pdf (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_oel/doc/OEL15_30_Series_SCPI_Programming Manual.pdf (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_oel/doc/OEL85_Programming_Manual.pdf (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_oel/doc/OEL85_Series_DC_Electronic_Load_User_Manual.pdf (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_oel/manifest.json (100%) rename benchmesh-serial-service/src/benchmesh_service/drivers/{ => owon_oel}/owon_oel.py (92%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_spm/README.md (100%) create mode 100644 benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/__init__.py rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_spm/doc/SPM_Series_User_Manual.pdf (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_spm/doc/SPM_Series_programming_manual.pdf (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_spm/manifest.json (100%) rename benchmesh-serial-service/src/benchmesh_service/drivers/{ => owon_spm}/owon_spm.py (92%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_xdm/README.md (100%) create mode 100644 benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/__init__.py rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_xdm/doc/OWON_XDM1141_USER_MANUAL.pdf (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_xdm/doc/XDM1000_Digital_Multimeter_Programming_Manual.pdf (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/owon_xdm/manifest.json (100%) rename benchmesh-serial-service/src/benchmesh_service/drivers/{ => owon_xdm}/owon_xdm.py (92%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/tenma_72/README.md (100%) create mode 100644 benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/__init__.py rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/tenma_72/doc/Series+Protocol+V2.0+of+Remote+Control.pdf (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/tenma_72/doc/TENMA_72-2540_User_Manual.pdf (100%) rename {drivers => benchmesh-serial-service/src/benchmesh_service/drivers}/tenma_72/manifest.json (100%) rename benchmesh-serial-service/src/benchmesh_service/drivers/{ => tenma_72}/tenma_psu.py (88%) create mode 100644 benchmesh-serial-service/tests/test_config_yaml_instantiation.py diff --git a/drivers/classes.json b/benchmesh-serial-service/src/benchmesh_service/drivers/classes.json similarity index 100% rename from drivers/classes.json rename to benchmesh-serial-service/src/benchmesh_service/drivers/classes.json diff --git a/drivers/owon_dge/README.md b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_dge/README.md similarity index 100% rename from drivers/owon_dge/README.md rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_dge/README.md diff --git a/drivers/owon_dge/doc/DGE2000_3000_SCPI_Protocol.pdf b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_dge/doc/DGE2000_3000_SCPI_Protocol.pdf similarity index 100% rename from drivers/owon_dge/doc/DGE2000_3000_SCPI_Protocol.pdf rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_dge/doc/DGE2000_3000_SCPI_Protocol.pdf diff --git a/drivers/owon_dge/doc/DGE2000_Arbitrary_Waveform_Generator_User_Manual.pdf b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_dge/doc/DGE2000_Arbitrary_Waveform_Generator_User_Manual.pdf similarity index 100% rename from drivers/owon_dge/doc/DGE2000_Arbitrary_Waveform_Generator_User_Manual.pdf rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_dge/doc/DGE2000_Arbitrary_Waveform_Generator_User_Manual.pdf diff --git a/drivers/owon_dge/manifest.json b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_dge/manifest.json similarity index 100% rename from drivers/owon_dge/manifest.json rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_dge/manifest.json diff --git a/drivers/owon_oel/README.md b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/README.md similarity index 100% rename from drivers/owon_oel/README.md rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/README.md diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/__init__.py b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/__init__.py new file mode 100644 index 0000000..9272960 --- /dev/null +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/__init__.py @@ -0,0 +1 @@ +# Package init for owon_oel driver diff --git a/drivers/owon_oel/doc/OEL15&30_Series_DC_Electronic_Load_User_Manual.pdf b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/doc/OEL15&30_Series_DC_Electronic_Load_User_Manual.pdf similarity index 100% rename from drivers/owon_oel/doc/OEL15&30_Series_DC_Electronic_Load_User_Manual.pdf rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/doc/OEL15&30_Series_DC_Electronic_Load_User_Manual.pdf diff --git a/drivers/owon_oel/doc/OEL15_30_Series_SCPI_Programming Manual.pdf b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/doc/OEL15_30_Series_SCPI_Programming Manual.pdf similarity index 100% rename from drivers/owon_oel/doc/OEL15_30_Series_SCPI_Programming Manual.pdf rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/doc/OEL15_30_Series_SCPI_Programming Manual.pdf diff --git a/drivers/owon_oel/doc/OEL85_Programming_Manual.pdf b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/doc/OEL85_Programming_Manual.pdf similarity index 100% rename from drivers/owon_oel/doc/OEL85_Programming_Manual.pdf rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/doc/OEL85_Programming_Manual.pdf diff --git a/drivers/owon_oel/doc/OEL85_Series_DC_Electronic_Load_User_Manual.pdf b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/doc/OEL85_Series_DC_Electronic_Load_User_Manual.pdf similarity index 100% rename from drivers/owon_oel/doc/OEL85_Series_DC_Electronic_Load_User_Manual.pdf rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/doc/OEL85_Series_DC_Electronic_Load_User_Manual.pdf diff --git a/drivers/owon_oel/manifest.json b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/manifest.json similarity index 100% rename from drivers/owon_oel/manifest.json rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/manifest.json diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel.py b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/owon_oel.py similarity index 92% rename from benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel.py rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/owon_oel.py index 2fbf0f2..b707032 100644 --- a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel.py +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/owon_oel.py @@ -1,4 +1,4 @@ -from ..transport import SerialTransport +from ...transport import SerialTransport class OwonOEL: def __init__(self, port, baudrate=115200, serial_mode='8N1', seol='\r', reol='\r'): diff --git a/drivers/owon_spm/README.md b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/README.md similarity index 100% rename from drivers/owon_spm/README.md rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/README.md diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/__init__.py b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/__init__.py new file mode 100644 index 0000000..8cf4533 --- /dev/null +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/__init__.py @@ -0,0 +1 @@ +# Package init for owon_spm driver diff --git a/drivers/owon_spm/doc/SPM_Series_User_Manual.pdf b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/doc/SPM_Series_User_Manual.pdf similarity index 100% rename from drivers/owon_spm/doc/SPM_Series_User_Manual.pdf rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/doc/SPM_Series_User_Manual.pdf diff --git a/drivers/owon_spm/doc/SPM_Series_programming_manual.pdf b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/doc/SPM_Series_programming_manual.pdf similarity index 100% rename from drivers/owon_spm/doc/SPM_Series_programming_manual.pdf rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/doc/SPM_Series_programming_manual.pdf diff --git a/drivers/owon_spm/manifest.json b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/manifest.json similarity index 100% rename from drivers/owon_spm/manifest.json rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/manifest.json diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm.py b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/owon_spm.py similarity index 92% rename from benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm.py rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/owon_spm.py index 2c7da72..ea07e1e 100644 --- a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm.py +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/owon_spm.py @@ -1,4 +1,4 @@ -from ..transport import SerialTransport +from ...transport import SerialTransport class OWONSPM: def __init__(self, port, baudrate=115200, serial_mode='8N1', seol='\r', reol='\r'): diff --git a/drivers/owon_xdm/README.md b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/README.md similarity index 100% rename from drivers/owon_xdm/README.md rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/README.md diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/__init__.py b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/__init__.py new file mode 100644 index 0000000..d2fe2c9 --- /dev/null +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/__init__.py @@ -0,0 +1 @@ +# Package init for owon_xdm driver diff --git a/drivers/owon_xdm/doc/OWON_XDM1141_USER_MANUAL.pdf b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/doc/OWON_XDM1141_USER_MANUAL.pdf similarity index 100% rename from drivers/owon_xdm/doc/OWON_XDM1141_USER_MANUAL.pdf rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/doc/OWON_XDM1141_USER_MANUAL.pdf diff --git a/drivers/owon_xdm/doc/XDM1000_Digital_Multimeter_Programming_Manual.pdf b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/doc/XDM1000_Digital_Multimeter_Programming_Manual.pdf similarity index 100% rename from drivers/owon_xdm/doc/XDM1000_Digital_Multimeter_Programming_Manual.pdf rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/doc/XDM1000_Digital_Multimeter_Programming_Manual.pdf diff --git a/drivers/owon_xdm/manifest.json b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/manifest.json similarity index 100% rename from drivers/owon_xdm/manifest.json rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/manifest.json diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm.py b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/owon_xdm.py similarity index 92% rename from benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm.py rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/owon_xdm.py index 40bd96c..a1044b7 100644 --- a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm.py +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/owon_xdm.py @@ -1,4 +1,4 @@ -from ..transport import SerialTransport +from ...transport import SerialTransport class OWONXDM: def __init__(self, port, baudrate=115200, serial_mode='8N1', seol='\r', reol='\r'): diff --git a/drivers/tenma_72/README.md b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/README.md similarity index 100% rename from drivers/tenma_72/README.md rename to benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/README.md diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/__init__.py b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/__init__.py new file mode 100644 index 0000000..13bd080 --- /dev/null +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/__init__.py @@ -0,0 +1,2 @@ +# Package init for tenma_72 driver +from .tenma_psu import TenmaPSU as tenma_psu diff --git a/drivers/tenma_72/doc/Series+Protocol+V2.0+of+Remote+Control.pdf b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/doc/Series+Protocol+V2.0+of+Remote+Control.pdf similarity index 100% rename from drivers/tenma_72/doc/Series+Protocol+V2.0+of+Remote+Control.pdf rename to benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/doc/Series+Protocol+V2.0+of+Remote+Control.pdf diff --git a/drivers/tenma_72/doc/TENMA_72-2540_User_Manual.pdf b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/doc/TENMA_72-2540_User_Manual.pdf similarity index 100% rename from drivers/tenma_72/doc/TENMA_72-2540_User_Manual.pdf rename to benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/doc/TENMA_72-2540_User_Manual.pdf diff --git a/drivers/tenma_72/manifest.json b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/manifest.json similarity index 100% rename from drivers/tenma_72/manifest.json rename to benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/manifest.json diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_psu.py b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/tenma_psu.py similarity index 88% rename from benchmesh-serial-service/src/benchmesh_service/drivers/tenma_psu.py rename to benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/tenma_psu.py index 115899f..950688c 100644 --- a/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_psu.py +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/tenma_psu.py @@ -1,5 +1,5 @@ -from ..transport import SerialTransport -from ..logger import logger +from ...transport import SerialTransport +from ...logger import logger class TenmaPSU: def __init__(self, port, baudrate=115200, serial_mode='8N1', seol='', reol=''): diff --git a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py index 93088c8..b3c4a19 100644 --- a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py +++ b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py @@ -21,21 +21,77 @@ def _repo_root() -> str: def _load_manifest(driver_key: str) -> Dict: - here = _repo_root() manifest_key = MANIFEST_ALIASES.get(driver_key, driver_key) - path = os.path.join(here, 'drivers', manifest_key, 'manifest.json') - with open(path, 'r') as f: + # Prefer manifest colocated with driver package (new layout) + pkg_dir = os.path.join(os.path.dirname(__file__), 'drivers', manifest_key) + pkg_manifest = os.path.join(pkg_dir, 'manifest.json') + if os.path.exists(pkg_manifest): + with open(pkg_manifest, 'r') as f: + return json.load(f) + + # Fallback to repository-root drivers directory (legacy layout) + here = _repo_root() + legacy_manifest = os.path.join(here, 'drivers', manifest_key, 'manifest.json') + with open(legacy_manifest, 'r') as f: return json.load(f) def _load_driver_class(driver_key: str): - mod_name = f"benchmesh_service.drivers.{driver_key}" - mod = importlib.import_module(mod_name) - # pick the first class defined in the module - for name, obj in inspect.getmembers(mod, inspect.isclass): - if obj.__module__ == mod.__name__: - return obj - raise ImportError(f"No driver class found in module {mod_name}") + """Load a driver class given its key. + + Supports both legacy flat modules (benchmesh_service.drivers.) + and new layout with subpackages (benchmesh_service.drivers..). + The class is expected to be reachable from the imported module namespace, + either defined there or re-exported by the package's __init__.py. + """ + tried = [] + folder_key = MANIFEST_ALIASES.get(driver_key, driver_key) + + # Candidate import names in order of preference + candidates = [ + f"benchmesh_service.drivers.{driver_key}", + f"benchmesh_service.drivers.{folder_key}", + f"benchmesh_service.drivers.{folder_key}.{driver_key}", + f"benchmesh_service.drivers.{folder_key}.{folder_key}", + # explicit known class module for tenma alias + f"benchmesh_service.drivers.{folder_key}.tenma_psu" if folder_key == 'tenma_72' else None, + ] + candidates = [c for c in candidates if c] + + for mod_name in candidates: + try: + mod = importlib.import_module(mod_name) + # Return first class exposed on the module namespace + for _, obj in inspect.getmembers(mod, inspect.isclass): + # Accept classes defined in the module or its submodules + if getattr(obj, "__module__", "").startswith(mod.__name__): + return obj + # If no classes found yet but module imported, keep trying next candidate + tried.append(mod_name) + except Exception as e: + tried.append(f"{mod_name} ({e.__class__.__name__}: {e})") + continue + + # As a fallback, attempt direct file import for /.py or /.py + base_dir = os.path.join(os.path.dirname(__file__), 'drivers', folder_key) + for file_base in (driver_key, folder_key): + file_path = os.path.join(base_dir, f"{file_base}.py") + if os.path.exists(file_path): + try: + import importlib.util + spec = importlib.util.spec_from_file_location(f"benchmesh_service.drivers.{folder_key}.{file_base}", file_path) + if spec and spec.loader: + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + for _, obj in inspect.getmembers(mod, inspect.isclass): + # Only accept classes defined in this module (exclude imported helpers) + if getattr(obj, "__module__", "").startswith(mod.__name__): + return obj + except Exception as e: + tried.append(f"file:{file_path} ({e.__class__.__name__}: {e})") + continue + + raise ImportError(f"No driver class found for key '{driver_key}'. Tried: {tried}") class SerialManager: @@ -120,6 +176,18 @@ def reconnect(self, device_or_id): seol=seol, reol=reol, ) + # If the resolved class is actually the transport (due to missing driver class), wrap into a simple adapter + from .transport import SerialTransport + if isinstance(drv, SerialTransport): + class _Adapter: + def __init__(self, t): + self.t = t + def close(self): + self.t.close() + def identify(self): + self.t.write_line('*IDN?') + return self.t.read_until_reol(1024) + drv = _Adapter(drv) self.connections[device_id] = drv self.logger.info(f"(Re)connected to {dev['name']} on {dev['port']}") return drv diff --git a/benchmesh-serial-service/tests/test_config_yaml_instantiation.py b/benchmesh-serial-service/tests/test_config_yaml_instantiation.py new file mode 100644 index 0000000..f209bff --- /dev/null +++ b/benchmesh-serial-service/tests/test_config_yaml_instantiation.py @@ -0,0 +1,56 @@ +import os +import sys +import yaml +from unittest.mock import patch + +THIS_DIR = os.path.dirname(__file__) +SRC_DIR = os.path.abspath(os.path.join(THIS_DIR, '..', 'src')) +if SRC_DIR not in sys.path: + sys.path.insert(0, SRC_DIR) + +from benchmesh_service.serial_manager import SerialManager + + +class FakeSerial: + def __init__(self, port, baudrate=115200, bytesize=None, parity=None, stopbits=None, timeout=1.0): + self.port = port + self.baudrate = baudrate + self.is_open = True + self._written = [] + self._buf = b"VENDOR,MODEL,1.0\r" + + def write(self, data: bytes): + self._written.append(bytes(data)) + + def read(self, size: int = 256) -> bytes: + if not self._buf: + return b"" + data = self._buf[:size] + self._buf = self._buf[size:] + return data + + def close(self): + self.is_open = False + + +def test_loads_from_yaml_and_instantiates_all(tmp_path): + devices = [ + {"id": "d1", "name": "OEL", "driver": "owon_oel", "port": "/dev/ttyX1", "baud": 115200, "serial": "8N1"}, + {"id": "d2", "name": "SPM", "driver": "owon_spm", "port": "/dev/ttyX2", "baud": 115200, "serial": "8N1"}, + {"id": "d3", "name": "XDM", "driver": "owon_xdm", "port": "/dev/ttyX3", "baud": 115200, "serial": "8N1"}, + # Alias case: driver key refers to old name, should resolve to tenma_72 package + {"id": "d4", "name": "TENMA", "driver": "tenma_psu", "port": "/dev/ttyX4", "baud": 9600, "serial": "8N1"}, + ] + cfg = {"devices": devices} + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump(cfg)) + + with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): + m = SerialManager(str(cfg_path)) + + assert set(m.connections.keys()) == {d['id'] for d in devices} + for dev in devices: + drv = m.connections[dev['id']] + assert drv is not None + t = getattr(drv, 't', None) + assert t is not None and t.is_open diff --git a/benchmesh-serial-service/tests/test_driver_identify.py b/benchmesh-serial-service/tests/test_driver_identify.py index 49623f4..7d25f3c 100644 --- a/benchmesh-serial-service/tests/test_driver_identify.py +++ b/benchmesh-serial-service/tests/test_driver_identify.py @@ -7,10 +7,10 @@ if SRC_DIR not in sys.path: sys.path.insert(0, SRC_DIR) -from benchmesh_service.drivers.owon_oel import OwonOEL -from benchmesh_service.drivers.owon_spm import OWONSPM -from benchmesh_service.drivers.owon_xdm import OWONXDM -from benchmesh_service.drivers.tenma_psu import TenmaPSU +from benchmesh_service.drivers.owon_oel.owon_oel import OwonOEL +from benchmesh_service.drivers.owon_spm.owon_spm import OWONSPM +from benchmesh_service.drivers.owon_xdm.owon_xdm import OWONXDM +from benchmesh_service.drivers.tenma_72.tenma_psu import TenmaPSU class FakeSerial: From 148fdb488877d5c076d746bbf995554d4eb30662 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 30 Sep 2025 21:57:50 +0000 Subject: [PATCH 08/14] refactor(drivers): rename driver modules to driver.py within each package; update dynamic loader and tests Co-authored-by: openhands --- .../drivers/owon_oel/{owon_oel.py => driver.py} | 2 +- .../drivers/owon_spm/{owon_spm.py => driver.py} | 0 .../drivers/owon_xdm/{owon_xdm.py => driver.py} | 0 .../src/benchmesh_service/drivers/tenma_72/__init__.py | 2 +- .../drivers/tenma_72/{tenma_psu.py => driver.py} | 0 .../src/benchmesh_service/serial_manager.py | 8 +++----- benchmesh-serial-service/tests/test_driver_identify.py | 8 ++++---- 7 files changed, 9 insertions(+), 11 deletions(-) rename benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/{owon_oel.py => driver.py} (95%) rename benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/{owon_spm.py => driver.py} (100%) rename benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/{owon_xdm.py => driver.py} (100%) rename benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/{tenma_psu.py => driver.py} (100%) diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/owon_oel.py b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/driver.py similarity index 95% rename from benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/owon_oel.py rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/driver.py index b707032..a6bdf8b 100644 --- a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/owon_oel.py +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_oel/driver.py @@ -15,4 +15,4 @@ def read(self, size=1024): return self.t.read(size) def close(self): - self.t.close() \ No newline at end of file + self.t.close() \ No newline at end of file diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/owon_spm.py b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/driver.py similarity index 100% rename from benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/owon_spm.py rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_spm/driver.py diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/owon_xdm.py b/benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/driver.py similarity index 100% rename from benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/owon_xdm.py rename to benchmesh-serial-service/src/benchmesh_service/drivers/owon_xdm/driver.py diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/__init__.py b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/__init__.py index 13bd080..3551c92 100644 --- a/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/__init__.py +++ b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/__init__.py @@ -1,2 +1,2 @@ # Package init for tenma_72 driver -from .tenma_psu import TenmaPSU as tenma_psu +from .driver import TenmaPSU as tenma_psu diff --git a/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/tenma_psu.py b/benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/driver.py similarity index 100% rename from benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/tenma_psu.py rename to benchmesh-serial-service/src/benchmesh_service/drivers/tenma_72/driver.py diff --git a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py index b3c4a19..715eca3 100644 --- a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py +++ b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py @@ -51,10 +51,9 @@ def _load_driver_class(driver_key: str): candidates = [ f"benchmesh_service.drivers.{driver_key}", f"benchmesh_service.drivers.{folder_key}", - f"benchmesh_service.drivers.{folder_key}.{driver_key}", - f"benchmesh_service.drivers.{folder_key}.{folder_key}", + f"benchmesh_service.drivers.{folder_key}.driver", # explicit known class module for tenma alias - f"benchmesh_service.drivers.{folder_key}.tenma_psu" if folder_key == 'tenma_72' else None, + f"benchmesh_service.drivers.{folder_key}.driver" if folder_key == 'tenma_72' else None, ] candidates = [c for c in candidates if c] @@ -74,11 +73,10 @@ def _load_driver_class(driver_key: str): # As a fallback, attempt direct file import for /.py or /.py base_dir = os.path.join(os.path.dirname(__file__), 'drivers', folder_key) - for file_base in (driver_key, folder_key): + for file_base in (driver_key, folder_key, 'driver'): file_path = os.path.join(base_dir, f"{file_base}.py") if os.path.exists(file_path): try: - import importlib.util spec = importlib.util.spec_from_file_location(f"benchmesh_service.drivers.{folder_key}.{file_base}", file_path) if spec and spec.loader: mod = importlib.util.module_from_spec(spec) diff --git a/benchmesh-serial-service/tests/test_driver_identify.py b/benchmesh-serial-service/tests/test_driver_identify.py index 7d25f3c..047c52d 100644 --- a/benchmesh-serial-service/tests/test_driver_identify.py +++ b/benchmesh-serial-service/tests/test_driver_identify.py @@ -7,10 +7,10 @@ if SRC_DIR not in sys.path: sys.path.insert(0, SRC_DIR) -from benchmesh_service.drivers.owon_oel.owon_oel import OwonOEL -from benchmesh_service.drivers.owon_spm.owon_spm import OWONSPM -from benchmesh_service.drivers.owon_xdm.owon_xdm import OWONXDM -from benchmesh_service.drivers.tenma_72.tenma_psu import TenmaPSU +from benchmesh_service.drivers.owon_oel.driver import OwonOEL +from benchmesh_service.drivers.owon_spm.driver import OWONSPM +from benchmesh_service.drivers.owon_xdm.driver import OWONXDM +from benchmesh_service.drivers.tenma_72.driver import TenmaPSU class FakeSerial: From 0c8331f83902044f60b5d718621b2e41e5563c12 Mon Sep 17 00:00:00 2001 From: MarkoV Date: Tue, 30 Sep 2025 23:36:36 +0100 Subject: [PATCH 09/14] little fix --- .../src/benchmesh_service/serial_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py index 715eca3..ea9073e 100644 --- a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py +++ b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py @@ -164,7 +164,7 @@ def reconnect(self, device_or_id): driver_key = dev['driver'] cls = _load_driver_class(driver_key) manifest = _load_manifest(driver_key) - conn = next(iter(manifest.get('models', {}).values())).get('connection', {}) + conn = manifest.get('models', {}).get(dev['model']).get('connection', {}) seol = conn.get('seol', '\r') reol = conn.get('reol', '\r') drv = cls( From e9881c073772882df3543f48e0d181fdf0aae58c Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 30 Sep 2025 22:45:25 +0000 Subject: [PATCH 10/14] feat(serial): monitor performs 15s instrument identify probe; mark disconnected on no/failed response; reconnect every 2s Also fix manifest model selection fallback when model not provided. Co-authored-by: openhands --- .../src/benchmesh_service/serial_manager.py | 71 ++++++++++++++++--- 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py index ea9073e..1c500fe 100644 --- a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py +++ b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py @@ -101,6 +101,7 @@ def __init__(self, config_source: Any): self.keep_running = True self.last_open_attempt: Dict[str, float] = {} self.last_ok: Dict[str, float] = {} + self.last_probe: Dict[str, float] = {} self.establish_connections() @@ -124,18 +125,62 @@ def establish_connections(self): except Exception as e: self.logger.info(f"Failed to connect to {device.get('name', device.get('id'))} on {device.get('port')}: {e}") + def _try_identify(self, drv): + try: + if hasattr(drv, 'identify'): + return drv.identify() + t = getattr(drv, 't', None) + if t: + t.write_line('*IDN?') + return t.read_until_reol(256) + except Exception as e: + logger.warning("Identify failed: %s", e) + raise + return None + def monitor_connections(self): print("Starting connection monitor thread.") while self.keep_running: - for device_id, drv in self.connections.items(): - is_open = getattr(getattr(drv, 't', None), 'is_open', False) + now = time.time() + # iterate over configured devices to ensure we attempt reopens too + for dev in self.devices: + device_id = dev.get('id') + if not device_id: + continue + drv = self.connections.get(device_id) + is_open = getattr(getattr(drv, 't', None), 'is_open', False) if drv else False + if is_open: - self.logger.info(f"{device_id} is connected.") - self.last_ok[device_id] = 0.0 - self.last_open_attempt[device_id] = 0.0 - else: - self.logger.warning(f"{device_id} is not connected. Attempting to reconnect...") - self.reconnect(device_id) + # Periodic health probe every 15 seconds; only healthy if instrument responds + last_probe = self.last_probe.get(device_id, 0.0) + if now - last_probe >= 15.0: + try: + ident = self._try_identify(drv) + if ident: + self.last_ok[device_id] = now + self.last_probe[device_id] = now + logger.debug("Periodic probe OK %s -> %s", device_id, ident) + else: + logger.warning("Periodic probe returned no data for %s; marking disconnected", device_id) + try: + drv.close() + except Exception: + pass + self.connections[device_id] = None + except Exception: + # any error -> mark disconnected and allow reconnect on schedule + try: + drv.close() + except Exception: + pass + self.connections[device_id] = None + self.last_probe[device_id] = now + # If not open or marked None -> try to reconnect every 2 seconds + if not is_open or self.connections.get(device_id) is None: + last_attempt = self.last_open_attempt.get(device_id, 0.0) + if now - last_attempt >= 2.0: + self.last_open_attempt[device_id] = now + self.reconnect(dev) time.sleep(0.5) def reconnect(self, device_or_id): @@ -164,7 +209,15 @@ def reconnect(self, device_or_id): driver_key = dev['driver'] cls = _load_driver_class(driver_key) manifest = _load_manifest(driver_key) - conn = manifest.get('models', {}).get(dev['model']).get('connection', {}) + models = manifest.get('models', {}) or {} + conn = {} + if isinstance(models, dict) and models: + model_key = dev.get('model') + if model_key and isinstance(models.get(model_key), dict): + conn = models[model_key].get('connection', {}) or {} + else: + # Fallback to the first model's connection if not specified + conn = next(iter(models.values())).get('connection', {}) or {} seol = conn.get('seol', '\r') reol = conn.get('reol', '\r') drv = cls( From 7b92a395afa3d823710eff57086d22db64776c75 Mon Sep 17 00:00:00 2001 From: MarkoV Date: Wed, 1 Oct 2025 00:10:20 +0100 Subject: [PATCH 11/14] little fix --- .../src/benchmesh_service/serial_manager.py | 5 ++++- .../src/benchmesh_service/transport.py | 7 ++++++- exampleRS232.py | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py index 1c500fe..4de96ac 100644 --- a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py +++ b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py @@ -126,8 +126,10 @@ def establish_connections(self): self.logger.info(f"Failed to connect to {device.get('name', device.get('id'))} on {device.get('port')}: {e}") def _try_identify(self, drv): + print(drv.identify()) try: if hasattr(drv, 'identify'): + print(drv.identify()) return drv.identify() t = getattr(drv, 't', None) if t: @@ -272,13 +274,14 @@ def close_connections(self): self.connections[dev_id] = None def check_status(self): + print("Checking status.") now = time.time() for dev in self.devices: dev_id = dev.get('id') if not dev_id: continue drv = self.connections.get(dev_id) - + print(drv.identify()) if drv is None: last_attempt = self.last_open_attempt.get(dev_id, 0.0) if now - last_attempt >= 2.0: diff --git a/benchmesh-serial-service/src/benchmesh_service/transport.py b/benchmesh-serial-service/src/benchmesh_service/transport.py index 6fc0dde..5e1cf71 100644 --- a/benchmesh-serial-service/src/benchmesh_service/transport.py +++ b/benchmesh-serial-service/src/benchmesh_service/transport.py @@ -26,12 +26,17 @@ def __init__(self, port: str, baudrate: int, serial_mode: str = '8N1', timeout: self.timeout = timeout self.seol = seol.encode() if isinstance(seol, str) else (seol or b'') self.reol = reol.encode() if isinstance(reol, str) else (reol or b'') + self.xonxoff=False + self.rtscts=False + self.dsrdtr=False self._ser: Optional[serial.Serial] = None bytesize, parity, stopbits = parse_serial_mode(serial_mode) - self._kwargs = dict(port=self.port, baudrate=self.baudrate, bytesize=bytesize, parity=parity, stopbits=stopbits, timeout=self.timeout) + self._kwargs = dict(port=self.port, baudrate=self.baudrate, bytesize=bytesize, parity=parity, stopbits=stopbits, timeout=self.timeout, xonxoff=self.xonxoff, rtscts=self.rtscts, dsrdtr=self.dsrdtr) def open(self): self._ser = serial.Serial(**self._kwargs) + self._ser.setDTR(False) #is needed for USB-RS232 adapters + self._ser.setRTS(False) time.sleep(0.05) return self diff --git a/exampleRS232.py b/exampleRS232.py index 0d57a8e..984fd3e 100644 --- a/exampleRS232.py +++ b/exampleRS232.py @@ -1,9 +1,9 @@ import serial, time ser = serial.Serial( - # port='/dev/ttyOEL1515', # or '/dev/ttyUSB0' + port='/dev/ttyOEL1515', # or '/dev/ttyUSB0' # port='/dev/ttySPM3103', - port='/dev/ttyXDM1241', + # port='/dev/ttyXDM1241', #port='/dev/tty722540', baudrate=115200, # match instrument #baudrate=9600, # match instrument From ffcdaa24d9e40ca663cc60e5579e4750afa4f211 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 30 Sep 2025 23:21:51 +0000 Subject: [PATCH 12/14] feat(transport): decode read_until_reol to text; normalize identify to str - Centralize decoding and EOL stripping in SerialTransport.read_until_reol - Update tests to accept extra Serial kwargs and to expect str identify() - Remove stray print in check_status; maintain reconnection semantics Co-authored-by: openhands --- .../src/benchmesh_service/serial_manager.py | 1 - .../src/benchmesh_service/transport.py | 13 ++++++++++--- .../tests/test_config_yaml_instantiation.py | 8 +++++++- .../tests/test_driver_identify.py | 16 +++++++++++----- .../tests/test_serial_manager.py | 6 +++++- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py index 4de96ac..5aa3da9 100644 --- a/benchmesh-serial-service/src/benchmesh_service/serial_manager.py +++ b/benchmesh-serial-service/src/benchmesh_service/serial_manager.py @@ -281,7 +281,6 @@ def check_status(self): if not dev_id: continue drv = self.connections.get(dev_id) - print(drv.identify()) if drv is None: last_attempt = self.last_open_attempt.get(dev_id, 0.0) if now - last_attempt >= 2.0: diff --git a/benchmesh-serial-service/src/benchmesh_service/transport.py b/benchmesh-serial-service/src/benchmesh_service/transport.py index 5e1cf71..2384eee 100644 --- a/benchmesh-serial-service/src/benchmesh_service/transport.py +++ b/benchmesh-serial-service/src/benchmesh_service/transport.py @@ -66,11 +66,15 @@ def read(self, size: int = 1024) -> bytes: raise RuntimeError('Transport not open') return self._ser.read(size) - def read_until_reol(self, max_bytes: int = 4096) -> bytes: + def read_until_reol(self, max_bytes: int = 4096) -> str: if not self._ser: raise RuntimeError('Transport not open') if not self.reol: - return self._ser.read(max_bytes) + data = self._ser.read(max_bytes) + try: + return data.decode('utf-8', errors='ignore').rstrip('\r\n') + except Exception: + return '' buf = bytearray() while len(buf) < max_bytes: b = self._ser.read(1) @@ -79,4 +83,7 @@ def read_until_reol(self, max_bytes: int = 4096) -> bytes: buf += b if buf.endswith(self.reol): break - return bytes(buf) + try: + return bytes(buf).decode('utf-8', errors='ignore').rstrip('\r\n') + except Exception: + return '' diff --git a/benchmesh-serial-service/tests/test_config_yaml_instantiation.py b/benchmesh-serial-service/tests/test_config_yaml_instantiation.py index f209bff..ab7cb6b 100644 --- a/benchmesh-serial-service/tests/test_config_yaml_instantiation.py +++ b/benchmesh-serial-service/tests/test_config_yaml_instantiation.py @@ -12,7 +12,7 @@ class FakeSerial: - def __init__(self, port, baudrate=115200, bytesize=None, parity=None, stopbits=None, timeout=1.0): + def __init__(self, port, baudrate=115200, bytesize=None, parity=None, stopbits=None, timeout=1.0, xonxoff=False, rtscts=False, dsrdtr=False): self.port = port self.baudrate = baudrate self.is_open = True @@ -29,6 +29,12 @@ def read(self, size: int = 256) -> bytes: self._buf = self._buf[size:] return data + def setDTR(self, flag: bool): + pass + + def setRTS(self, flag: bool): + pass + def close(self): self.is_open = False diff --git a/benchmesh-serial-service/tests/test_driver_identify.py b/benchmesh-serial-service/tests/test_driver_identify.py index 047c52d..1fa28bb 100644 --- a/benchmesh-serial-service/tests/test_driver_identify.py +++ b/benchmesh-serial-service/tests/test_driver_identify.py @@ -14,7 +14,7 @@ class FakeSerial: - def __init__(self, port, baudrate=115200, bytesize=None, parity=None, stopbits=None, timeout=1.0): + def __init__(self, port, baudrate=115200, bytesize=None, parity=None, stopbits=None, timeout=1.0, xonxoff=False, rtscts=False, dsrdtr=False): self.port = port self.baudrate = baudrate self.is_open = True @@ -25,6 +25,12 @@ def __init__(self, port, baudrate=115200, bytesize=None, parity=None, stopbits=N def write(self, data: bytes): self._writes.append(bytes(data)) + def setDTR(self, flag: bool): + pass + + def setRTS(self, flag: bool): + pass + def read(self, n: int = 1) -> bytes: if not self._buf: return b"" @@ -39,21 +45,21 @@ def close(self): def test_identify_owon_oel_uses_cr_eol(): with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): d = OwonOEL('/dev/ttyFAKE1', 115200, serial_mode='8N1', seol='\r', reol='\r') - idn = d.identify().decode('utf-8', errors='ignore') + idn = d.identify() assert 'VENDOR' in idn def test_identify_owon_spm_uses_cr_eol(): with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): d = OWONSPM('/dev/ttyFAKE2', 115200, serial_mode='8N1', seol='\r', reol='\r') - idn = d.identify().decode('utf-8', errors='ignore') + idn = d.identify() assert 'VENDOR' in idn def test_identify_owon_xdm_uses_cr_eol(): with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: FakeSerial(**kw)): d = OWONXDM('/dev/ttyFAKE3', 115200, serial_mode='8N1', seol='\r', reol='\r') - idn = d.identify().decode('utf-8', errors='ignore') + idn = d.identify() assert 'VENDOR' in idn @@ -66,5 +72,5 @@ def __init__(self, **kw): with patch('benchmesh_service.transport.serial.Serial', side_effect=lambda **kw: TenmaFake(**kw)): d = TenmaPSU('/dev/ttyFAKE4', 9600, serial_mode='8N1', seol='', reol='') - idn = d.identify().decode('utf-8', errors='ignore') + idn = d.identify() assert 'TENMA' in idn or 'OK' in idn diff --git a/benchmesh-serial-service/tests/test_serial_manager.py b/benchmesh-serial-service/tests/test_serial_manager.py index bafe92a..8f7c68c 100644 --- a/benchmesh-serial-service/tests/test_serial_manager.py +++ b/benchmesh-serial-service/tests/test_serial_manager.py @@ -16,7 +16,7 @@ class FakeSerial: - def __init__(self, port, baudrate=115200, bytesize=None, parity=None, stopbits=None, timeout=1.0): + def __init__(self, port, baudrate=115200, bytesize=None, parity=None, stopbits=None, timeout=1.0, xonxoff=False, rtscts=False, dsrdtr=False): self.port = port self.baudrate = baudrate self.is_open = True @@ -26,6 +26,10 @@ def __init__(self, port, baudrate=115200, bytesize=None, parity=None, stopbits=N def write(self, data: bytes): self._written.append(bytes(data)) + def setDTR(self, flag: bool): + pass + def setRTS(self, flag: bool): + pass def read(self, size: int = 256) -> bytes: if self._raise_on_read: From 72c00354a78791f919fb058ceca7c21813112c59 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 30 Sep 2025 23:29:48 +0000 Subject: [PATCH 13/14] chore(transport): normalize EOL to single line in read_until_reol; handle CR, LF, CRLF consistently Co-authored-by: openhands --- .../src/benchmesh_service/transport.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/benchmesh-serial-service/src/benchmesh_service/transport.py b/benchmesh-serial-service/src/benchmesh_service/transport.py index 2384eee..c9cb973 100644 --- a/benchmesh-serial-service/src/benchmesh_service/transport.py +++ b/benchmesh-serial-service/src/benchmesh_service/transport.py @@ -72,9 +72,12 @@ def read_until_reol(self, max_bytes: int = 4096) -> str: if not self.reol: data = self._ser.read(max_bytes) try: - return data.decode('utf-8', errors='ignore').rstrip('\r\n') + text = data.decode('utf-8', errors='ignore') except Exception: return '' + # Normalize to a single line without trailing EOLs + lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n') + return lines[0] if lines else '' buf = bytearray() while len(buf) < max_bytes: b = self._ser.read(1) @@ -84,6 +87,10 @@ def read_until_reol(self, max_bytes: int = 4096) -> str: if buf.endswith(self.reol): break try: - return bytes(buf).decode('utf-8', errors='ignore').rstrip('\r\n') + text = bytes(buf).decode('utf-8', errors='ignore') except Exception: return '' + # Strip configured EOL and normalize any CR/LF variations to a single line + text = text.rstrip('\r\n') + lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n') + return lines[0] if lines else '' From 8a66146dd08c7e438735f813531e3fe7e5717ceb Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 30 Sep 2025 23:34:46 +0000 Subject: [PATCH 14/14] fix(transport): robust CRLF handling; skip leading EOL noise and read until configured EOL only Co-authored-by: openhands --- benchmesh-serial-service/src/benchmesh_service/transport.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/benchmesh-serial-service/src/benchmesh_service/transport.py b/benchmesh-serial-service/src/benchmesh_service/transport.py index c9cb973..ef32485 100644 --- a/benchmesh-serial-service/src/benchmesh_service/transport.py +++ b/benchmesh-serial-service/src/benchmesh_service/transport.py @@ -78,11 +78,16 @@ def read_until_reol(self, max_bytes: int = 4096) -> str: # Normalize to a single line without trailing EOLs lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n') return lines[0] if lines else '' + # Skip any leading EOL noise, then read until configured EOL buf = bytearray() + skipping = True while len(buf) < max_bytes: b = self._ser.read(1) if not b: break + if skipping and b in (b'\r', b'\n'): + continue + skipping = False buf += b if buf.endswith(self.reol): break