diff --git a/docs/instantel_protocol_reference.md b/docs/instantel_protocol_reference.md index be0ff44..993559c 100644 --- a/docs/instantel_protocol_reference.md +++ b/docs/instantel_protocol_reference.md @@ -58,6 +58,7 @@ | 2026-03-12 | §11 | **RESOLVED — BAD CHK false positives on BW POLL frames:** Parser bug — BW frame terminator (`03 41`, ETX+ACK) was being included in the de-stuffed payload instead of being stripped as framing. BW frames end with bare `0x03` (not `10 03`). Fix: strip trailing `03 41` from BW payloads before checksum computation. | | 2026-03-30 | §3, §5.1 | **CONFIRMED — BW→S3 two-step read offset is at payload[5], NOT payload[3:4].** All BW read-command frames have `payload[3] = 0x00` and `payload[4] = 0x00` unconditionally. The two-step offset byte lives at `payload[5]`: `0x00` for the length-probe step, `DATA_LEN` for the data-fetch step. Validated against all captured frames in `bridges/captures/3-11-26/raw_bw_*.bin` — every frame is an exact bit-for-bit match when built with offset at `[5]`. The `page_hi`/`page_lo` framing in the docstring was a misattribution from the S3-side response layout (where `[3]`/`[4]` ARE page bytes). | | 2026-03-30 | §4, §5.2 | **CONFIRMED — S3 probe response page_key is always 0x0000.** The S3 response to a length-probe step does NOT carry the data length back in `page_hi`/`page_lo`. Both bytes are `0x00` in every observed probe response. Data lengths for each SUB are fixed constants (see §5.1 table). The `minimateplus` library now uses a hardcoded `DATA_LENGTHS` dict rather than trying to read the length from the probe response. | +| 2026-03-31 | §12 TCP Transport | **NEW SECTION — TCP/modem transport confirmed transparent from Blastware Operator Manual (714U0301 Rev 22).** Key facts confirmed: (1) Protocol bytes over TCP are bit-for-bit identical to RS-232 — no handshake framing. (2) No ENQ byte on TCP connect (`Enable ENQ on TCP Connect: 0-Disable` in Raven ACEmanager). (3) Raven modem `Data Forwarding Timeout = 1 second` — modem buffers serial bytes up to 1s before forwarding over TCP; `TcpTransport.read_until_idle` uses `idle_gap=1.5s` to compensate. (4) TCP port is user-configurable (12335 in manual example; user's install uses 12345). (5) Baud rate over serial link to modem is 38400,8N1 regardless of TCP path. (6) ACH (Auto Call Home) = INBOUND to server (unit calls home); "call up" = OUTBOUND from client (Blastware/SFM connects to modem IP). `TcpTransport` implements outbound (call-up) mode. | --- @@ -889,6 +890,129 @@ Build in this order — each step is independently testable: --- +## 14. TCP / Modem Transport +> ✅ **CONFIRMED — 2026-03-31** from Blastware Operator Manual 714U0301 Rev 22 §4.4 and ACEmanager Raven modem configuration screenshots. + +The MiniMate Plus protocol is **fully transport-agnostic at the byte level**. The same DLE-framed S3/BW frame stream that flows over RS-232 is transmitted unmodified over a TCP socket. No additional framing, handshake bytes, or session tokens are added at the application layer. + +--- + +### 14.1 Two Usage Modes + +**"Call Up" (Outbound TCP — SFM connects to modem)** + +Blastware or SFM opens a TCP connection to the modem's static IP address on its device port. The modem bridges the TCP socket to its RS-232 serial port, which is wired directly to the MiniMate Plus. From the protocol perspective this is identical to a direct serial connection. + +``` +SFM ──TCP──► Raven modem ──RS-232──► MiniMate Plus + (static IP, port N) (38400,8N1) +``` + +This is the mode implemented by `TcpTransport(host, port)`. Typical call: + +``` +GET /device/info?host=203.0.113.5&tcp_port=12345 +``` + +**"Call Home" / ACH (Inbound TCP — unit calls the server)** + +The MiniMate Plus is configured with an IP address and port. On an event trigger or scheduled time it powers up its modem, which establishes a TCP connection outbound to the server. Blastware (or a future SFM ACH listener) accepts the incoming connection. After the unit connects, the PC has a configurable "Wait for Connection" window to send the first command before the unit times out and hangs up. + +``` +MiniMate Plus ──RS-232──► Raven modem ──TCP──► ACH server (listening) + (static office IP, port N) +``` + +`TcpTransport` is a **client** (outbound connect only). A separate `AchServer` listener component is needed for this mode — not yet implemented. + +--- + +### 14.2 No Application-Layer Handshake on TCP Connect + +✅ **Confirmed from ACEmanager configuration screenshot:** + +``` +Enable ENQ on TCP Connect: 0-Disable +``` + +When a TCP connection is established (in either direction), **no ENQ byte or other handshake marker is sent** by the modem before the protocol stream starts. The first byte from either side is a raw protocol byte — for SFM-initiated call-up, SFM sends POLL_PROBE immediately after `connect()`. + +No banner, no "CONNECT" string, no Telnet negotiation preamble. The Raven modem's TCP dialog is configured with: + +| ACEmanager Setting | Value | Meaning | +|---|---|---| +| TCP Auto Answer | 2 — Telnet Server | TCP mode (transparent pass-through, not actually Telnet) | +| Telnet Echo Mode | 0 — No Echo | No echo of received bytes | +| Enable ENQ on TCP Connect | 0 — Disable | No ENQ byte on connect | +| TCP Connect Response Delay | 0 | No delay before first byte | +| TCP Idle Timeout | 0 | No modem-level idle disconnect | + +--- + +### 14.3 Modem Serial Port Configuration + +> **Hardware note:** The Raven X modem shown in the Blastware manual is 3G-only and no longer operational (3G network shutdown). The current field hardware is the **Sierra Wireless RV55** (and newer RX55). Both run ALEOS firmware and have an identical ACEmanager web UI — the settings below apply to all three generations. + +The modem's RS-232 port (wired to the MiniMate Plus) must be configured as: + +| ACEmanager Setting | Value | +|---|---| +| Configure Serial Port | **38400,8N1** | +| Flow Control | None | +| DB9 Serial Echo | OFF | +| Data Forwarding Timeout | **1 second** (S50=1) | +| Data Forwarding Character | 0 (disabled) | + +The **Data Forwarding Timeout** is the most protocol-critical setting. The modem **accumulates bytes from the RS-232 port for up to 1 second** before forwarding them as a TCP segment. This means: + +- A large S3 response frame may arrive as multiple TCP segments with up to 1-second gaps between them. +- A `read_until_idle` implementation with `idle_gap < 1.0 s` will **incorrectly declare the frame complete mid-stream**. +- `TcpTransport.read_until_idle` overrides the default `idle_gap=0.05 s` to `idle_gap=1.5 s` to compensate. + +If connecting to a unit via a direct Ethernet connection (no serial modem in the path), the 1.5 s idle gap will still work but will feel slower. In that case you can pass `idle_gap=0.1` explicitly. + +--- + +### 14.4 Connection Timeouts on the Unit Side + +The MiniMate Plus firmware has two relevant timeouts configurable via Blastware's Call Home Setup dialog: + +| Timeout | Description | Impact | +|---|---|---| +| **Wait for Connection** | Seconds after TCP connect during which the unit waits for the first BW frame. If nothing arrives, unit terminates the session. | SFM must send POLL_PROBE within this window after `connect()`. Default appears short (≈15–30 s). | +| **Serial Idle Time** | Seconds of inactivity after which the unit terminates the connection. | SFM must complete its work and disconnect cleanly — or send periodic keep-alive frames — within this window. | + +For our `TcpTransport` + `MiniMateProtocol` stack, both timeouts are satisfied automatically because `connect()` is immediately followed by `protocol.poll()` which sends POLL_PROBE, and the full session (POLL + read + disconnect) typically completes in < 30 seconds. + +--- + +### 14.5 Port Numbers + +The TCP port is **user-configurable** in both Blastware and the modem. There is no universally fixed port. + +| Setting location | Value in manual example | Value in user's install | +|---|---|---| +| Blastware TCP Communication dialog | 12335 | 12345 | +| Raven ACEmanager Destination Port | 12349 (UDP example) | varies | + +`TcpTransport` defaults to `DEFAULT_TCP_PORT = 12345` which matches the user's install. This can be overridden by the `port` argument or the `tcp_port` query parameter in the SFM server. + +--- + +### 14.6 ACH Session Lifecycle (Call Home Mode — Future) + +When the unit calls home under ACH, the session lifecycle from the unit's perspective is: + +1. Unit triggers (event or scheduled time) +2. Unit powers up modem, dials / connects TCP to server IP:port +3. Unit waits for "Wait for Connection" window for first BW frame from server +4. Server sends POLL_PROBE → unit responds with POLL_RESPONSE (same as serial) +5. Server reads serial number, full config, events as needed +6. Server disconnects (or unit disconnects on Serial Idle Time expiry) +7. Unit powers modem down, returns to monitor mode + +Step 4 onward is **identical to the serial/call-up protocol**. The only difference from our perspective is that we are the **listener** rather than the **connector**. A future `AchServer` class will accept the incoming TCP connection and hand the socket to `TcpTransport` for processing. + --- ## Appendix A — s3_bridge Capture Format diff --git a/minimateplus/__init__.py b/minimateplus/__init__.py index 76cdca8..e115106 100644 --- a/minimateplus/__init__.py +++ b/minimateplus/__init__.py @@ -2,18 +2,26 @@ minimateplus — Instantel MiniMate Plus protocol library. Provides a clean Python API for communicating with MiniMate Plus seismographs -over RS-232 serial (direct cable) or TCP (via RV50 cellular modem bridge). +over RS-232 serial (direct cable) or TCP (modem / ACH Auto Call Home). -Typical usage: +Typical usage (serial): from minimateplus import MiniMateClient with MiniMateClient("COM5") as device: info = device.connect() events = device.get_events() + +Typical usage (TCP / modem): + from minimateplus import MiniMateClient + from minimateplus.transport import TcpTransport + + with MiniMateClient(transport=TcpTransport("203.0.113.5", 12345)) as device: + info = device.connect() """ from .client import MiniMateClient from .models import DeviceInfo, Event +from .transport import SerialTransport, TcpTransport __version__ = "0.1.0" -__all__ = ["MiniMateClient", "DeviceInfo", "Event"] +__all__ = ["MiniMateClient", "DeviceInfo", "Event", "SerialTransport", "TcpTransport"] diff --git a/minimateplus/client.py b/minimateplus/client.py index 72e05de..b4efef1 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -10,15 +10,20 @@ The client does not hold an open connection between calls. This keeps the first implementation simple and matches Blastware's observed behaviour. Persistent connections can be added later without changing the public API. -Example: +Example (serial): from minimateplus import MiniMateClient with MiniMateClient("COM5") as device: info = device.connect() # POLL handshake + identity read events = device.get_events() # download all events - print(info) - for ev in events: - print(ev) + +Example (TCP / modem): + from minimateplus import MiniMateClient + from minimateplus.transport import TcpTransport + + transport = TcpTransport("203.0.113.5", port=12345) + with MiniMateClient(transport=transport) as device: + info = device.connect() """ from __future__ import annotations @@ -55,16 +60,17 @@ class MiniMateClient: High-level client for a single MiniMate Plus device. Args: - port: Serial port name (e.g. "COM5", "/dev/ttyUSB0"). - baud: Baud rate (default 38400). - timeout: Per-request receive timeout in seconds (default 5.0). - transport: Optional pre-built transport (for testing / TCP future use). + port: Serial port name (e.g. "COM5", "/dev/ttyUSB0"). + Not required when a pre-built transport is provided. + baud: Baud rate (default 38400, ignored when transport is provided). + timeout: Per-request receive timeout in seconds (default 15.0). + transport: Pre-built transport (SerialTransport or TcpTransport). If None, a SerialTransport is constructed from port/baud. """ def __init__( self, - port: str, + port: str = "", baud: int = 38_400, timeout: float = 15.0, transport: Optional[BaseTransport] = None, diff --git a/minimateplus/transport.py b/minimateplus/transport.py index fea8bc7..d0c37e6 100644 --- a/minimateplus/transport.py +++ b/minimateplus/transport.py @@ -1,27 +1,36 @@ """ -transport.py — Serial (and future TCP) transport layer for the MiniMate Plus protocol. +transport.py — Serial and TCP transport layer for the MiniMate Plus protocol. -Provides a thin I/O abstraction so that protocol.py never imports pyserial directly. -The only concrete implementation here is SerialTransport; a TcpTransport can be -added later without touching any other layer. +Provides a thin I/O abstraction so that protocol.py never imports pyserial or +socket directly. Two concrete implementations: + + SerialTransport — direct RS-232 cable connection (pyserial) + TcpTransport — TCP socket to a modem or ACH relay (stdlib socket) + +The MiniMate Plus protocol bytes are identical over both transports. TCP is used +when field units call home via the ACH (Auto Call Home) server, or when SFM +"calls up" a unit by connecting to the modem's IP address directly. + +Field hardware: Sierra Wireless RV55 / RX55 (4G LTE) cellular modem, replacing +the older 3G-only Raven X (now decommissioned). All run ALEOS firmware with an +ACEmanager web UI. Serial port must be configured 38400,8N1, no flow control, +Data Forwarding Timeout = 1 s. Typical usage: - from minimateplus.transport import SerialTransport + from minimateplus.transport import SerialTransport, TcpTransport - t = SerialTransport("COM5") - t.connect() - t.write(frame_bytes) - data = t.read_until_idle(timeout=2.0) - t.disconnect() - - # or as a context manager: + # Direct serial connection with SerialTransport("COM5") as t: t.write(frame_bytes) - data = t.read_until_idle() + + # Modem / ACH TCP connection (Blastware port 12345) + with TcpTransport("192.168.1.50", 12345) as t: + t.write(frame_bytes) """ from __future__ import annotations +import socket import time from abc import ABC, abstractmethod from typing import Optional @@ -256,3 +265,156 @@ class SerialTransport(BaseTransport): def __repr__(self) -> str: state = "open" if self.is_connected else "closed" return f"SerialTransport({self.port!r}, baud={self.baud}, {state})" + + +# ── TCP transport ───────────────────────────────────────────────────────────── + +# Default TCP port for Blastware modem communications / ACH relay. +# Confirmed from field setup: Blastware → Communication Setup → TCP/IP uses 12345. +DEFAULT_TCP_PORT = 12345 + + +class TcpTransport(BaseTransport): + """ + TCP socket transport for MiniMate Plus units in the field. + + The protocol bytes over TCP are identical to RS-232 — TCP is simply a + different physical layer. The modem (Sierra Wireless RV55 / RX55, or older + Raven X) bridges the unit's RS-232 serial port to a TCP socket transparently. + No application-layer handshake or framing is added. + + Two usage scenarios: + + "Call up" (outbound): SFM connects to the unit's modem IP directly. + TcpTransport(host="203.0.113.5", port=12345) + + "Call home" / ACH relay: The unit has already dialled in to the office + ACH server, which bridged the modem to a TCP socket. In this case + the host/port identifies the relay's listening socket, not the modem. + (ACH inbound mode is handled by a separate AchServer — not this class.) + + IMPORTANT — modem data forwarding delay: + Sierra Wireless (and Raven) modems buffer RS-232 bytes for up to 1 second + before forwarding them as a TCP segment ("Data Forwarding Timeout" in + ACEmanager). read_until_idle() is overridden to use idle_gap=1.5 s rather + than the serial default of 0.05 s — without this, the parser would declare + a frame complete mid-stream during the modem's buffering pause. + + Args: + host: IP address or hostname of the modem / ACH relay. + port: TCP port number (default 12345). + connect_timeout: Seconds to wait for the TCP handshake (default 10.0). + """ + + # Internal recv timeout — short so read() returns promptly if no data. + _RECV_TIMEOUT = 0.01 + + def __init__( + self, + host: str, + port: int = DEFAULT_TCP_PORT, + connect_timeout: float = 10.0, + ) -> None: + self.host = host + self.port = port + self.connect_timeout = connect_timeout + self._sock: Optional[socket.socket] = None + + # ── BaseTransport interface ─────────────────────────────────────────────── + + def connect(self) -> None: + """ + Open a TCP connection to host:port. + + Idempotent — does nothing if already connected. + + Raises: + OSError / socket.timeout: if the connection cannot be established. + """ + if self._sock is not None: + return # Already connected — idempotent + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(self.connect_timeout) + sock.connect((self.host, self.port)) + # Switch to short timeout so read() is non-blocking in practice + sock.settimeout(self._RECV_TIMEOUT) + self._sock = sock + + def disconnect(self) -> None: + """Close the TCP socket. Safe to call even if already closed.""" + if self._sock: + try: + self._sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + self._sock.close() + except OSError: + pass + self._sock = None + + @property + def is_connected(self) -> bool: + return self._sock is not None + + def write(self, data: bytes) -> None: + """ + Send all bytes to the peer. + + Raises: + RuntimeError: if not connected. + OSError: on network I/O error. + """ + if not self.is_connected: + raise RuntimeError("TcpTransport.write: not connected") + self._sock.sendall(data) # type: ignore[union-attr] + + def read(self, n: int) -> bytes: + """ + Read up to *n* bytes from the socket. + + Returns b"" immediately if no data is available (non-blocking in + practice thanks to the short socket timeout). + + Raises: + RuntimeError: if not connected. + """ + if not self.is_connected: + raise RuntimeError("TcpTransport.read: not connected") + try: + return self._sock.recv(n) # type: ignore[union-attr] + except socket.timeout: + return b"" + + def read_until_idle( + self, + timeout: float = 2.0, + idle_gap: float = 1.5, + chunk: int = 256, + ) -> bytes: + """ + TCP-aware version of read_until_idle. + + Overrides the BaseTransport default to use a much longer idle_gap (1.5 s + vs 0.05 s for serial). This is necessary because the Raven modem (and + similar cellular modems) buffer serial-port bytes for up to 1 second + before forwarding them over TCP ("Data Forwarding Timeout" setting). + + If read_until_idle returned after a 50 ms quiet period, it would trigger + mid-frame when the modem is still accumulating bytes — causing frame + parse failures on every call. + + Args: + timeout: Hard deadline from first byte (default 2.0 s — callers + typically pass a longer value for S3 frames). + idle_gap: Quiet-line threshold (default 1.5 s to survive modem + buffering). Pass a smaller value only if you are + connecting directly to a unit's Ethernet port with no + modem buffering in the path. + chunk: Bytes per low-level recv() call. + """ + return super().read_until_idle(timeout=timeout, idle_gap=idle_gap, chunk=chunk) + + def __repr__(self) -> str: + state = "connected" if self.is_connected else "disconnected" + return f"TcpTransport({self.host!r}, port={self.port}, {state})" diff --git a/sfm/server.py b/sfm/server.py index 3aeb7ea..41c06f7 100644 --- a/sfm/server.py +++ b/sfm/server.py @@ -14,11 +14,16 @@ GET /device/events Download all stored events (headers + peak values) POST /device/connect Explicit connect/identify (same as /device/info) GET /device/event/{idx} Single event by index (header + waveform record) -All device endpoints accept query params: - port — serial port (e.g. COM5, /dev/ttyUSB0) - baud — baud rate (default 38400) +Transport query params (supply one set): + Serial (direct RS-232 cable): + port — serial port name (e.g. COM5, /dev/ttyUSB0) + baud — baud rate (default 38400) -Each call opens the serial port, does its work, then closes it. + TCP (modem / ACH Auto Call Home): + host — IP address or hostname of the modem or ACH relay + tcp_port — TCP port number (default 12345, Blastware default) + +Each call opens the connection, does its work, then closes it. (Stateless / reconnect-per-call, matching Blastware's observed behaviour.) Run with: @@ -49,6 +54,7 @@ except ImportError: from minimateplus import MiniMateClient from minimateplus.protocol import ProtocolError from minimateplus.models import DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp +from minimateplus.transport import TcpTransport, DEFAULT_TCP_PORT logging.basicConfig( level=logging.INFO, @@ -131,15 +137,37 @@ def _serialise_event(ev: Event) -> dict: } -# ── Common dependency ────────────────────────────────────────────────────────── +# ── Transport factory ───────────────────────────────────────────────────────── -def _get_port(port: Optional[str]) -> str: - if not port: +def _build_client( + port: Optional[str], + baud: int, + host: Optional[str], + tcp_port: int, +) -> MiniMateClient: + """ + Return a MiniMateClient configured for either serial or TCP transport. + + TCP takes priority if *host* is supplied; otherwise *port* (serial) is used. + Raises HTTPException(422) if neither is provided. + """ + if host: + # TCP / modem / ACH path + transport = TcpTransport(host, port=tcp_port) + log.debug("TCP transport: %s:%d", host, tcp_port) + return MiniMateClient(transport=transport) + elif port: + # Direct serial path + log.debug("Serial transport: %s baud=%d", port, baud) + return MiniMateClient(port, baud) + else: raise HTTPException( status_code=422, - detail="Query parameter 'port' is required (e.g. ?port=COM5)", + detail=( + "Specify either 'port' (serial, e.g. ?port=COM5) " + "or 'host' (TCP, e.g. ?host=192.168.1.50&tcp_port=12345)" + ), ) - return port # ── Endpoints ────────────────────────────────────────────────────────────────── @@ -152,23 +180,29 @@ def health() -> dict: @app.get("/device/info") def device_info( - port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"), - baud: int = Query(38400, description="Baud rate"), + port: Optional[str] = Query(None, description="Serial port (e.g. COM5, /dev/ttyUSB0)"), + baud: int = Query(38400, description="Serial baud rate (default 38400)"), + host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay (e.g. 203.0.113.5)"), + tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"), ) -> dict: """ Connect to the device, perform the POLL startup handshake, and return identity information (serial number, firmware version, model). + Supply either *port* (serial) or *host* (TCP/modem). Equivalent to POST /device/connect — provided as GET for convenience. """ - port_str = _get_port(port) - log.info("GET /device/info port=%s baud=%d", port_str, baud) + log.info("GET /device/info port=%s host=%s tcp_port=%d", port, host, tcp_port) try: - with MiniMateClient(port_str, baud) as client: + with _build_client(port, baud, host, tcp_port) as client: info = client.connect() + except HTTPException: + raise except ProtocolError as exc: raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc + except OSError as exc: + raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc except Exception as exc: raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc @@ -177,37 +211,46 @@ def device_info( @app.post("/device/connect") def device_connect( - port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"), - baud: int = Query(38400, description="Baud rate"), + port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"), + baud: int = Query(38400, description="Serial baud rate"), + host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"), + tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"), ) -> dict: """ Connect to the device and return identity. POST variant for terra-view compatibility with the SLMM proxy pattern. """ - return device_info(port=port, baud=baud) + return device_info(port=port, baud=baud, host=host, tcp_port=tcp_port) @app.get("/device/events") def device_events( - port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"), - baud: int = Query(38400, description="Baud rate"), + port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"), + baud: int = Query(38400, description="Serial baud rate"), + host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"), + tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"), ) -> dict: """ Connect to the device, read the event index, and download all stored events (event headers + full waveform records with peak values). + Supply either *port* (serial) or *host* (TCP/modem). + This does NOT download raw ADC waveform samples — those are large and fetched separately via GET /device/event/{idx}/waveform (future endpoint). """ - port_str = _get_port(port) - log.info("GET /device/events port=%s baud=%d", port_str, baud) + log.info("GET /device/events port=%s host=%s", port, host) try: - with MiniMateClient(port_str, baud) as client: + with _build_client(port, baud, host, tcp_port) as client: info = client.connect() events = client.get_events() + except HTTPException: + raise except ProtocolError as exc: raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc + except OSError as exc: + raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc except Exception as exc: raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc @@ -220,24 +263,30 @@ def device_events( @app.get("/device/event/{index}") def device_event( - index: int, - port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"), - baud: int = Query(38400, description="Baud rate"), + index: int, + port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"), + baud: int = Query(38400, description="Serial baud rate"), + host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"), + tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"), ) -> dict: """ Download a single event by index (0-based). + Supply either *port* (serial) or *host* (TCP/modem). Performs: POLL startup → event index → event header → waveform record. """ - port_str = _get_port(port) - log.info("GET /device/event/%d port=%s baud=%d", index, port_str, baud) + log.info("GET /device/event/%d port=%s host=%s", index, port, host) try: - with MiniMateClient(port_str, baud) as client: + with _build_client(port, baud, host, tcp_port) as client: client.connect() events = client.get_events() + except HTTPException: + raise except ProtocolError as exc: raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc + except OSError as exc: + raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc except Exception as exc: raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc