""" 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.middleware.cors import CORSMiddleware 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 ComplianceConfig, 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", ) # Allow requests from the waveform viewer opened as a local file (file://) # and from any dev server or terra-view proxy. app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["GET", "POST"], allow_headers=["*"], ) # ── 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, "hour": ts.hour, "minute": ts.minute, "second": ts.second, "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, "peak_vector_sum": pv.peak_vector_sum, } 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_compliance_config(cc: Optional["ComplianceConfig"]) -> Optional[dict]: if cc is None: return None return { "record_time": cc.record_time, "sample_rate": cc.sample_rate, "trigger_level_geo": cc.trigger_level_geo, "alarm_level_geo": cc.alarm_level_geo, "max_range_geo": cc.max_range_geo, "setup_name": cc.setup_name, "project": cc.project, "client": cc.client, "operator": cc.operator, "sensor_location": cc.sensor_location, "notes": cc.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, "event_count": info.event_count, "compliance_config": _serialise_compliance_config(info.compliance_config), } def _serialise_event(ev: Event, debug: bool = False) -> dict: d: dict = { "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), } if debug: raw = getattr(ev, "_raw_record", None) d["raw_record_hex"] = raw.hex() if raw else None d["raw_record_len"] = len(raw) if raw else 0 return d # ── Transport factory ───────────────────────────────────────────────────────── def _build_client( port: Optional[str], baud: int, host: Optional[str], tcp_port: int, timeout: float = 30.0, ) -> 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. Use timeout=120.0 (or higher) for endpoints that perform a full 5A waveform download — a 70-second event at 1024 sps takes 2-3 minutes to transfer over cellular and each individual recv must complete within the timeout window. """ if host: transport = TcpTransport(host, port=tcp_port) log.debug("TCP transport: %s:%d timeout=%.0fs", host, tcp_port, timeout) return MiniMateClient(transport=transport, timeout=timeout) elif port: 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: info = client.connect() # SUB 08 event_count is unreliable (always returns 1 regardless of # actual storage). Count via 1E/1F chain instead. info.event_count = client.count_events() return info 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})"), debug: bool = Query(False, description="Include raw record hex for field-layout inspection"), ) -> 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). Pass debug=true to include raw_record_hex in each event — useful for verifying field offsets against the protocol reference. 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 debug=%s", port, host, debug) try: def _do(): with _build_client(port, baud, host, tcp_port) as client: return client.connect(), client.get_events(debug=debug) 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 # Fill sample_rate from compliance config where the event record doesn't supply it. # sample_rate is a device-level setting, not stored per-event in the waveform record. if info.compliance_config and info.compliance_config.sample_rate: for ev in events: if ev.sample_rate is None: ev.sample_rate = info.compliance_config.sample_rate # Backfill event.project_info fields that the 210-byte waveform record doesn't carry. # The waveform record only stores "Project:" — client/operator/sensor_location/notes # live in the SUB 1A compliance config, not in the per-event record. if info.compliance_config: cc = info.compliance_config for ev in events: if ev.project_info is None: ev.project_info = ProjectInfo() pi = ev.project_info if pi.client is None: pi.client = cc.client if pi.operator is None: pi.operator = cc.operator if pi.sensor_location is None: pi.sensor_location = cc.sensor_location if pi.notes is None: pi.notes = cc.notes return { "device": _serialise_device_info(info), "event_count": len(events), "events": [_serialise_event(ev, debug=debug) 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]) @app.get("/device/event/{index}/waveform") def device_event_waveform( 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 the full raw ADC waveform for a single event (0-based index). Supply either *port* (serial) or *host* (TCP/modem). Performs: POLL startup → get_events() (to locate the 4-byte waveform key) → download_waveform() (full SUB 5A stream, stop_after_metadata=False). Response includes: - **total_samples**: expected sample-sets from the STRT record - **pretrig_samples**: pre-trigger sample count - **rectime_seconds**: record duration - **samples_decoded**: actual sample-sets decoded (may be less than total_samples if the device is not storing all frames yet, or the capture was partial) - **sample_rate**: samples per second (from compliance config) - **channels**: dict of channel name → list of signed int16 ADC counts (keys: "Tran", "Vert", "Long", "Mic") """ log.info("GET /device/event/%d/waveform port=%s host=%s", index, port, host) try: def _do(): with _build_client(port, baud, host, tcp_port, timeout=120.0) as client: info = client.connect() # full_waveform=True fetches the complete 5A stream inside the # 1E→0A→0C→5A→1F loop. Issuing a second 5A after 1F times out. events = client.get_events(full_waveform=True) matching = [ev for ev in events if ev.index == index] return matching[0] if matching else None, info ev, 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 if ev is None: raise HTTPException( status_code=404, detail=f"Event index {index} not found on device", ) raw = getattr(ev, "raw_samples", None) or {} samples_decoded = len(raw.get("Tran", [])) # Resolve sample_rate from compliance config if not on the event itself sample_rate = ev.sample_rate if sample_rate is None and info.compliance_config: sample_rate = info.compliance_config.sample_rate return { "index": ev.index, "record_type": ev.record_type, "timestamp": _serialise_timestamp(ev.timestamp), "total_samples": ev.total_samples, "pretrig_samples": ev.pretrig_samples, "rectime_seconds": ev.rectime_seconds, "samples_decoded": samples_decoded, "sample_rate": sample_rate, "peak_values": _serialise_peak_values(ev.peak_values), "channels": raw, } # ── 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, )