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:
@@ -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 (≈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
|
## Appendix A — s3_bridge Capture Format
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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})"
|
||||||
|
|||||||
105
sfm/server.py
105
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)
|
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user