Add TCP/modem transport (Sierra Wireless RV55/RX55 field units)

- minimateplus/transport.py: add TcpTransport — stdlib socket-based transport
  with same interface as SerialTransport. Overrides read_until_idle() with
  idle_gap=1.5s to absorb the modem's 1-second serial data forwarding buffer.
- minimateplus/client.py: make `port` param optional (default "") so
  MiniMateClient works cleanly when a pre-built transport is injected.
- minimateplus/__init__.py: export SerialTransport and TcpTransport.
- sfm/server.py: add `host` / `tcp_port` query params to all device endpoints.
  New _build_client() helper selects TCP or serial transport automatically.
  OSError (connection refused, timeout) now returns HTTP 502.
- docs/instantel_protocol_reference.md: add changelog entry and full §14
  (TCP/Modem Transport) documenting confirmed transparent passthrough, no ENQ
  on connect, modem forwarding delay, call-up vs ACH modes, and hardware note
  deprecating Raven X in favour of RV55/RX55.

Usage: GET /device/info?host=<modem_ip>&tcp_port=12345
This commit is contained in:
Brian Harrison
2026-03-31 00:44:50 -04:00
parent b8032e0578
commit 51d1aa917a
5 changed files with 402 additions and 53 deletions

View File

@@ -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-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 | §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-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 (≈1530 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 ## Appendix A — s3_bridge Capture Format

View File

@@ -2,18 +2,26 @@
minimateplus — Instantel MiniMate Plus protocol library. minimateplus — Instantel MiniMate Plus protocol library.
Provides a clean Python API for communicating with MiniMate Plus seismographs 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 from minimateplus import MiniMateClient
with MiniMateClient("COM5") as device: with MiniMateClient("COM5") as device:
info = device.connect() info = device.connect()
events = device.get_events() 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 .client import MiniMateClient
from .models import DeviceInfo, Event from .models import DeviceInfo, Event
from .transport import SerialTransport, TcpTransport
__version__ = "0.1.0" __version__ = "0.1.0"
__all__ = ["MiniMateClient", "DeviceInfo", "Event"] __all__ = ["MiniMateClient", "DeviceInfo", "Event", "SerialTransport", "TcpTransport"]

View File

@@ -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. first implementation simple and matches Blastware's observed behaviour.
Persistent connections can be added later without changing the public API. Persistent connections can be added later without changing the public API.
Example: Example (serial):
from minimateplus import MiniMateClient from minimateplus import MiniMateClient
with MiniMateClient("COM5") as device: with MiniMateClient("COM5") as device:
info = device.connect() # POLL handshake + identity read info = device.connect() # POLL handshake + identity read
events = device.get_events() # download all events events = device.get_events() # download all events
print(info)
for ev in events: Example (TCP / modem):
print(ev) 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 from __future__ import annotations
@@ -55,16 +60,17 @@ class MiniMateClient:
High-level client for a single MiniMate Plus device. High-level client for a single MiniMate Plus device.
Args: Args:
port: Serial port name (e.g. "COM5", "/dev/ttyUSB0"). port: Serial port name (e.g. "COM5", "/dev/ttyUSB0").
baud: Baud rate (default 38400). Not required when a pre-built transport is provided.
timeout: Per-request receive timeout in seconds (default 5.0). baud: Baud rate (default 38400, ignored when transport is provided).
transport: Optional pre-built transport (for testing / TCP future use). 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. If None, a SerialTransport is constructed from port/baud.
""" """
def __init__( def __init__(
self, self,
port: str, port: str = "",
baud: int = 38_400, baud: int = 38_400,
timeout: float = 15.0, timeout: float = 15.0,
transport: Optional[BaseTransport] = None, transport: Optional[BaseTransport] = None,

View File

@@ -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. Provides a thin I/O abstraction so that protocol.py never imports pyserial or
The only concrete implementation here is SerialTransport; a TcpTransport can be socket directly. Two concrete implementations:
added later without touching any other layer.
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: Typical usage:
from minimateplus.transport import SerialTransport from minimateplus.transport import SerialTransport, TcpTransport
t = SerialTransport("COM5") # Direct serial connection
t.connect()
t.write(frame_bytes)
data = t.read_until_idle(timeout=2.0)
t.disconnect()
# or as a context manager:
with SerialTransport("COM5") as t: with SerialTransport("COM5") as t:
t.write(frame_bytes) 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 from __future__ import annotations
import socket
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional from typing import Optional
@@ -256,3 +265,156 @@ class SerialTransport(BaseTransport):
def __repr__(self) -> str: def __repr__(self) -> str:
state = "open" if self.is_connected else "closed" state = "open" if self.is_connected else "closed"
return f"SerialTransport({self.port!r}, baud={self.baud}, {state})" 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})"

View File

@@ -14,11 +14,16 @@ GET /device/events Download all stored events (headers + peak values)
POST /device/connect Explicit connect/identify (same as /device/info) POST /device/connect Explicit connect/identify (same as /device/info)
GET /device/event/{idx} Single event by index (header + waveform record) GET /device/event/{idx} Single event by index (header + waveform record)
All device endpoints accept query params: Transport query params (supply one set):
port — serial port (e.g. COM5, /dev/ttyUSB0) Serial (direct RS-232 cable):
baud — baud rate (default 38400) 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.) (Stateless / reconnect-per-call, matching Blastware's observed behaviour.)
Run with: Run with:
@@ -49,6 +54,7 @@ except ImportError:
from minimateplus import MiniMateClient from minimateplus import MiniMateClient
from minimateplus.protocol import ProtocolError from minimateplus.protocol import ProtocolError
from minimateplus.models import DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp from minimateplus.models import DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp
from minimateplus.transport import TcpTransport, DEFAULT_TCP_PORT
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -131,15 +137,37 @@ def _serialise_event(ev: Event) -> dict:
} }
# ── Common dependency ───────────────────────────────────────────────────────── # ── Transport factory ─────────────────────────────────────────────────────────
def _get_port(port: Optional[str]) -> str: def _build_client(
if not port: 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( raise HTTPException(
status_code=422, 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 ────────────────────────────────────────────────────────────────── # ── Endpoints ──────────────────────────────────────────────────────────────────
@@ -152,23 +180,29 @@ def health() -> dict:
@app.get("/device/info") @app.get("/device/info")
def device_info( def device_info(
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"), port: Optional[str] = Query(None, description="Serial port (e.g. COM5, /dev/ttyUSB0)"),
baud: int = Query(38400, description="Baud rate"), 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: ) -> dict:
""" """
Connect to the device, perform the POLL startup handshake, and return Connect to the device, perform the POLL startup handshake, and return
identity information (serial number, firmware version, model). 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. Equivalent to POST /device/connect — provided as GET for convenience.
""" """
port_str = _get_port(port) log.info("GET /device/info port=%s host=%s tcp_port=%d", port, host, tcp_port)
log.info("GET /device/info port=%s baud=%d", port_str, baud)
try: try:
with MiniMateClient(port_str, baud) as client: with _build_client(port, baud, host, tcp_port) as client:
info = client.connect() info = client.connect()
except HTTPException:
raise
except ProtocolError as exc: except ProtocolError as exc:
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from 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: except Exception as exc:
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
@@ -177,37 +211,46 @@ def device_info(
@app.post("/device/connect") @app.post("/device/connect")
def device_connect( def device_connect(
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"), port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
baud: int = Query(38400, description="Baud rate"), 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: ) -> dict:
""" """
Connect to the device and return identity. POST variant for terra-view Connect to the device and return identity. POST variant for terra-view
compatibility with the SLMM proxy pattern. 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") @app.get("/device/events")
def device_events( def device_events(
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"), port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
baud: int = Query(38400, description="Baud rate"), 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: ) -> dict:
""" """
Connect to the device, read the event index, and download all stored Connect to the device, read the event index, and download all stored
events (event headers + full waveform records with peak values). 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 This does NOT download raw ADC waveform samples — those are large and
fetched separately via GET /device/event/{idx}/waveform (future endpoint). fetched separately via GET /device/event/{idx}/waveform (future endpoint).
""" """
port_str = _get_port(port) log.info("GET /device/events port=%s host=%s", port, host)
log.info("GET /device/events port=%s baud=%d", port_str, baud)
try: try:
with MiniMateClient(port_str, baud) as client: with _build_client(port, baud, host, tcp_port) as client:
info = client.connect() info = client.connect()
events = client.get_events() events = client.get_events()
except HTTPException:
raise
except ProtocolError as exc: except ProtocolError as exc:
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from 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: except Exception as exc:
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from 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}") @app.get("/device/event/{index}")
def device_event( def device_event(
index: int, index: int,
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"), port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
baud: int = Query(38400, description="Baud rate"), 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: ) -> dict:
""" """
Download a single event by index (0-based). 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. Performs: POLL startup → event index → event header → waveform record.
""" """
port_str = _get_port(port) log.info("GET /device/event/%d port=%s host=%s", index, port, host)
log.info("GET /device/event/%d port=%s baud=%d", index, port_str, baud)
try: try:
with MiniMateClient(port_str, baud) as client: with _build_client(port, baud, host, tcp_port) as client:
client.connect() client.connect()
events = client.get_events() events = client.get_events()
except HTTPException:
raise
except ProtocolError as exc: except ProtocolError as exc:
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from 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: except Exception as exc:
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc