352 lines
13 KiB
Python
352 lines
13 KiB
Python
"""
|
|
sfm/server.py — Seismograph Field Module REST API
|
|
|
|
Wraps the minimateplus library in a small FastAPI service.
|
|
Terra-view proxies /api/sfm/* to this service (same pattern as SLMM at :8100).
|
|
|
|
Default port: 8200
|
|
|
|
Endpoints
|
|
---------
|
|
GET /health Service heartbeat — no device I/O
|
|
GET /device/info POLL + serial number + full config read
|
|
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)
|
|
|
|
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)
|
|
|
|
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:
|
|
python -m uvicorn sfm.server:app --host 0.0.0.0 --port 8200 --reload
|
|
or:
|
|
python sfm/server.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sys
|
|
from typing import Optional
|
|
|
|
# FastAPI / Pydantic
|
|
try:
|
|
from fastapi import FastAPI, HTTPException, Query
|
|
from fastapi.responses import JSONResponse
|
|
import uvicorn
|
|
except ImportError:
|
|
print(
|
|
"fastapi and uvicorn are required for the SFM server.\n"
|
|
"Install them with: pip install fastapi uvicorn",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
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,
|
|
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
log = logging.getLogger("sfm.server")
|
|
|
|
# ── FastAPI app ────────────────────────────────────────────────────────────────
|
|
|
|
app = FastAPI(
|
|
title="Seismograph Field Module (SFM)",
|
|
description=(
|
|
"REST API for Instantel MiniMate Plus seismographs.\n"
|
|
"Implements the minimateplus RS-232 protocol library.\n"
|
|
"Proxied by terra-view at /api/sfm/*."
|
|
),
|
|
version="0.1.0",
|
|
)
|
|
|
|
|
|
# ── Serialisers ────────────────────────────────────────────────────────────────
|
|
# Plain dict helpers — avoids a Pydantic dependency in the library layer.
|
|
|
|
def _serialise_timestamp(ts: Optional[Timestamp]) -> Optional[dict]:
|
|
if ts is None:
|
|
return None
|
|
return {
|
|
"year": ts.year,
|
|
"month": ts.month,
|
|
"day": ts.day,
|
|
"clock_set": ts.clock_set,
|
|
"display": str(ts),
|
|
}
|
|
|
|
|
|
def _serialise_peak_values(pv: Optional[PeakValues]) -> Optional[dict]:
|
|
if pv is None:
|
|
return None
|
|
return {
|
|
"tran_in_s": pv.tran,
|
|
"vert_in_s": pv.vert,
|
|
"long_in_s": pv.long,
|
|
"micl_psi": pv.micl,
|
|
}
|
|
|
|
|
|
def _serialise_project_info(pi: Optional[ProjectInfo]) -> Optional[dict]:
|
|
if pi is None:
|
|
return None
|
|
return {
|
|
"setup_name": pi.setup_name,
|
|
"project": pi.project,
|
|
"client": pi.client,
|
|
"operator": pi.operator,
|
|
"sensor_location": pi.sensor_location,
|
|
"notes": pi.notes,
|
|
}
|
|
|
|
|
|
def _serialise_device_info(info: DeviceInfo) -> dict:
|
|
return {
|
|
"serial": info.serial,
|
|
"firmware_version": info.firmware_version,
|
|
"firmware_minor": info.firmware_minor,
|
|
"dsp_version": info.dsp_version,
|
|
"manufacturer": info.manufacturer,
|
|
"model": info.model,
|
|
}
|
|
|
|
|
|
def _serialise_event(ev: Event) -> dict:
|
|
return {
|
|
"index": ev.index,
|
|
"timestamp": _serialise_timestamp(ev.timestamp),
|
|
"sample_rate": ev.sample_rate,
|
|
"record_type": ev.record_type,
|
|
"peak_values": _serialise_peak_values(ev.peak_values),
|
|
"project_info": _serialise_project_info(ev.project_info),
|
|
}
|
|
|
|
|
|
# ── Transport factory ─────────────────────────────────────────────────────────
|
|
|
|
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 — use a longer timeout to survive cold boots
|
|
# (unit takes 5-15s to wake from RS-232 line assertion over cellular)
|
|
transport = TcpTransport(host, port=tcp_port)
|
|
log.debug("TCP transport: %s:%d", host, tcp_port)
|
|
return MiniMateClient(transport=transport, timeout=30.0)
|
|
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=(
|
|
"Specify either 'port' (serial, e.g. ?port=COM5) "
|
|
"or 'host' (TCP, e.g. ?host=192.168.1.50&tcp_port=12345)"
|
|
),
|
|
)
|
|
|
|
|
|
def _is_tcp(host: Optional[str]) -> bool:
|
|
return bool(host)
|
|
|
|
|
|
def _run_with_retry(fn, *, is_tcp: bool):
|
|
"""
|
|
Call fn() and, for TCP connections only, retry once on ProtocolError.
|
|
|
|
Rationale: when a MiniMate Plus is cold (just had its serial lines asserted
|
|
by the modem or a local bridge), it takes 5-10 seconds to boot before it
|
|
will respond to POLL_PROBE. The first request may time out during that boot
|
|
window; a single automatic retry is enough to recover once the unit is up.
|
|
|
|
Serial connections are NOT retried — a timeout there usually means a real
|
|
problem (wrong port, wrong baud, cable unplugged).
|
|
"""
|
|
try:
|
|
return fn()
|
|
except ProtocolError as exc:
|
|
if not is_tcp:
|
|
raise
|
|
log.info("TCP poll timed out (unit may have been cold) — retrying once")
|
|
return fn() # let any second failure propagate normally
|
|
|
|
|
|
# ── Endpoints ──────────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/health")
|
|
def health() -> dict:
|
|
"""Service heartbeat. No device I/O."""
|
|
return {"status": "ok", "service": "sfm", "version": "0.1.0"}
|
|
|
|
|
|
@app.get("/device/info")
|
|
def device_info(
|
|
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.
|
|
"""
|
|
log.info("GET /device/info port=%s host=%s tcp_port=%d", port, host, tcp_port)
|
|
|
|
try:
|
|
def _do():
|
|
with _build_client(port, baud, host, tcp_port) as client:
|
|
return client.connect()
|
|
info = _run_with_retry(_do, is_tcp=_is_tcp(host))
|
|
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
|
|
|
|
return _serialise_device_info(info)
|
|
|
|
|
|
@app.post("/device/connect")
|
|
def device_connect(
|
|
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, 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="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).
|
|
"""
|
|
log.info("GET /device/events port=%s host=%s", port, host)
|
|
|
|
try:
|
|
def _do():
|
|
with _build_client(port, baud, host, tcp_port) as client:
|
|
return client.connect(), client.get_events()
|
|
info, events = _run_with_retry(_do, is_tcp=_is_tcp(host))
|
|
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
|
|
|
|
return {
|
|
"device": _serialise_device_info(info),
|
|
"event_count": len(events),
|
|
"events": [_serialise_event(ev) for ev in 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="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.
|
|
"""
|
|
log.info("GET /device/event/%d port=%s host=%s", index, port, host)
|
|
|
|
try:
|
|
def _do():
|
|
with _build_client(port, baud, host, tcp_port) as client:
|
|
client.connect()
|
|
return client.get_events()
|
|
events = _run_with_retry(_do, is_tcp=_is_tcp(host))
|
|
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
|
|
|
|
matching = [ev for ev in events if ev.index == index]
|
|
if not matching:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Event index {index} not found on device",
|
|
)
|
|
|
|
return _serialise_event(matching[0])
|
|
|
|
|
|
# ── Entry point ────────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
|
|
ap = argparse.ArgumentParser(description="SFM — Seismograph Field Module API server")
|
|
ap.add_argument("--host", default="0.0.0.0", help="Bind address (default: 0.0.0.0)")
|
|
ap.add_argument("--port", type=int, default=8200, help="Port (default: 8200)")
|
|
ap.add_argument("--reload", action="store_true", help="Enable auto-reload (dev mode)")
|
|
args = ap.parse_args()
|
|
|
|
log.info("Starting SFM server on %s:%d", args.host, args.port)
|
|
uvicorn.run(
|
|
"sfm.server:app",
|
|
host=args.host,
|
|
port=args.port,
|
|
reload=args.reload,
|
|
)
|