""" 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 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=( "Specify either 'port' (serial, e.g. ?port=COM5) " "or 'host' (TCP, e.g. ?host=192.168.1.50&tcp_port=12345)" ), ) # ── 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: 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 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: 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 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: 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 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, )