v0.11.0 — SQLite persistence layer (SeismoDb)
sfm/database.py (new)
- SeismoDb class: three tables keyed by unit serial number
- ach_sessions: one row per ACH call-home
- events: one row per triggered event, deduped by (serial, waveform_key)
- monitor_log: one row per monitoring interval, deduped by (serial, waveform_key)
- WAL mode, per-request connections, silent dedup via UNIQUE constraint
- Query helpers: query_events(), query_monitor_log(), get_sessions(), query_units()
- false_trigger flag on events for future review UI / report filtering
bridges/ach_server.py
- Import SeismoDb; create shared instance at startup pointed at
bridges/captures/seismo_relay.db
- After each call-home: insert_events() + insert_monitor_log() + insert_ach_session()
- DB failures logged as warnings, never abort the session
sfm/server.py
- Import SeismoDb; lazy singleton via _get_db()
- New DB read endpoints: GET /db/units, /db/events, /db/monitor_log, /db/sessions
- PATCH /db/events/{id}/false_trigger for manual review flagging
CLAUDE.md / CHANGELOG.md
- Document DB schema, SFM DB endpoints, architecture decision (unit-keyed only)
- Version bump to v0.11.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+119
@@ -34,6 +34,7 @@ or:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -58,6 +59,7 @@ 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
|
||||
from sfm.database import SeismoDb
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -88,6 +90,21 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
# ── DB ────────────────────────────────────────────────────────────────────────
|
||||
# Shared SeismoDb instance. Path can be overridden by --db-path at startup,
|
||||
# or defaults to bridges/captures/seismo_relay.db relative to the repo root.
|
||||
|
||||
_DEFAULT_DB_PATH = Path(__file__).parent.parent / "bridges" / "captures" / "seismo_relay.db"
|
||||
_db: Optional[SeismoDb] = None
|
||||
|
||||
|
||||
def _get_db() -> SeismoDb:
|
||||
global _db
|
||||
if _db is None:
|
||||
_db = SeismoDb(_DEFAULT_DB_PATH)
|
||||
return _db
|
||||
|
||||
|
||||
# ── Serialisers ────────────────────────────────────────────────────────────────
|
||||
# Plain dict helpers — avoids a Pydantic dependency in the library layer.
|
||||
|
||||
@@ -698,6 +715,108 @@ def device_monitor_stop(
|
||||
return {"status": "stopped"}
|
||||
|
||||
|
||||
# ── DB read endpoints ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# These endpoints expose the seismo-relay SQLite DB written by ach_server.py.
|
||||
# All queries are read-only. Terra-view calls these to build project event
|
||||
# views, unit history panels, and (eventually) vibration summary reports.
|
||||
|
||||
|
||||
@app.get("/db/units")
|
||||
def db_units() -> list[dict]:
|
||||
"""
|
||||
Return one row per known serial with summary stats:
|
||||
last_seen, total_events, total_monitor_entries, total_sessions.
|
||||
"""
|
||||
return _get_db().query_units()
|
||||
|
||||
|
||||
@app.get("/db/events")
|
||||
def db_events(
|
||||
serial: Optional[str] = Query(None, description="Filter by unit serial (e.g. BE11529)"),
|
||||
from_dt: Optional[str] = Query(None, description="ISO-8601 start datetime (inclusive)"),
|
||||
to_dt: Optional[str] = Query(None, description="ISO-8601 end datetime (inclusive)"),
|
||||
false_trigger: Optional[bool] = Query(None, description="Filter by false_trigger flag"),
|
||||
limit: int = Query(500, description="Max rows to return (default 500)"),
|
||||
offset: int = Query(0, description="Pagination offset"),
|
||||
) -> dict:
|
||||
"""
|
||||
Query triggered events from the DB.
|
||||
|
||||
Returns events newest-first. All filter params are optional.
|
||||
|
||||
Example:
|
||||
GET /db/events?serial=BE11529&from_dt=2026-04-01&limit=100
|
||||
"""
|
||||
from_parsed = datetime.datetime.fromisoformat(from_dt) if from_dt else None
|
||||
to_parsed = datetime.datetime.fromisoformat(to_dt) if to_dt else None
|
||||
|
||||
rows = _get_db().query_events(
|
||||
serial=serial,
|
||||
from_dt=from_parsed,
|
||||
to_dt=to_parsed,
|
||||
false_trigger=false_trigger,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return {"count": len(rows), "events": rows}
|
||||
|
||||
|
||||
@app.patch("/db/events/{event_id}/false_trigger")
|
||||
def db_set_false_trigger(
|
||||
event_id: str,
|
||||
value: bool = Query(..., description="True to flag as false trigger, False to clear"),
|
||||
) -> dict:
|
||||
"""
|
||||
Set or clear the false_trigger flag on a single event.
|
||||
|
||||
Used by the terra-view event review UI.
|
||||
Returns 404 if the event_id is not found.
|
||||
"""
|
||||
found = _get_db().set_false_trigger(event_id, value)
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
|
||||
return {"status": "ok", "event_id": event_id, "false_trigger": value}
|
||||
|
||||
|
||||
@app.get("/db/monitor_log")
|
||||
def db_monitor_log(
|
||||
serial: Optional[str] = Query(None, description="Filter by unit serial"),
|
||||
from_dt: Optional[str] = Query(None, description="ISO-8601 start datetime (inclusive)"),
|
||||
to_dt: Optional[str] = Query(None, description="ISO-8601 end datetime (inclusive)"),
|
||||
limit: int = Query(500, description="Max rows to return"),
|
||||
offset: int = Query(0, description="Pagination offset"),
|
||||
) -> dict:
|
||||
"""
|
||||
Query monitor log entries (continuous monitoring intervals) from the DB.
|
||||
|
||||
Returns entries newest-first.
|
||||
"""
|
||||
from_parsed = datetime.datetime.fromisoformat(from_dt) if from_dt else None
|
||||
to_parsed = datetime.datetime.fromisoformat(to_dt) if to_dt else None
|
||||
|
||||
rows = _get_db().query_monitor_log(
|
||||
serial=serial,
|
||||
from_dt=from_parsed,
|
||||
to_dt=to_parsed,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return {"count": len(rows), "entries": rows}
|
||||
|
||||
|
||||
@app.get("/db/sessions")
|
||||
def db_sessions(
|
||||
serial: Optional[str] = Query(None, description="Filter by unit serial"),
|
||||
limit: int = Query(50, description="Max rows to return"),
|
||||
) -> dict:
|
||||
"""
|
||||
Query ACH call-home sessions from the DB, newest first.
|
||||
"""
|
||||
rows = _get_db().get_sessions(serial=serial, limit=limit)
|
||||
return {"count": len(rows), "sessions": rows}
|
||||
|
||||
|
||||
# ── Entry point ────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user