Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03d224ccc3 | |||
| ef2c38e7db |
@@ -4,6 +4,95 @@ All notable changes to seismo-relay are documented here.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.11.0 — 2026-04-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`sfm/database.py` — SeismoDb** — SQLite persistence layer for all ACH data.
|
||||||
|
Three tables, all unit-keyed by serial number:
|
||||||
|
- `ach_sessions` — one row per inbound call-home: serial, timestamp, peer IP,
|
||||||
|
events_downloaded, monitor_entries, duration_seconds
|
||||||
|
- `events` — one row per triggered waveform event: serial, waveform_key (dedup),
|
||||||
|
timestamp, Tran/Vert/Long/VectorSum/Mic PPV, project/client/operator/sensor_location
|
||||||
|
strings, sample_rate, record_type, false_trigger flag
|
||||||
|
- `monitor_log` — one row per monitoring interval: serial, waveform_key (dedup),
|
||||||
|
start_time, stop_time, duration_seconds, geo_threshold_ips
|
||||||
|
- WAL mode, per-request connections — safe for the single-writer / occasional-reader
|
||||||
|
ACH server pattern
|
||||||
|
- Deduplication by `(serial, waveform_key)` UNIQUE constraint — re-runs and repeat
|
||||||
|
call-homes never produce duplicate rows
|
||||||
|
|
||||||
|
- **`ach_server.py` — DB integration** — after each successful call-home, writes new
|
||||||
|
events and monitor log entries to `seismo_relay.db` then records the session in
|
||||||
|
`ach_sessions`. DB write failures are logged as warnings and do not abort the session.
|
||||||
|
|
||||||
|
- **`sfm/server.py` — DB read endpoints**:
|
||||||
|
- `GET /db/units` — distinct serials with last_seen, total_events, total_monitor_entries
|
||||||
|
- `GET /db/events` — query events with serial / date range / false_trigger filters
|
||||||
|
- `GET /db/monitor_log` — query monitoring intervals
|
||||||
|
- `GET /db/sessions` — query ACH call-home sessions
|
||||||
|
- `PATCH /db/events/{id}/false_trigger` — flag/unflag false triggers (for review UI)
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
- seismo-relay DB is unit-keyed only — no project concepts. Project aggregation is
|
||||||
|
terra-view's responsibility via `UnitAssignment` / `DeploymentRecord` + date range
|
||||||
|
queries against the SFM DB endpoints.
|
||||||
|
- DB file lives at `bridges/captures/seismo_relay.db` by default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.10.0 — 2026-04-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`MiniMateClient.get_monitor_log_entries(skip_keys=None)`** — browse-mode walk
|
||||||
|
(`1E → 0A → 1F`) that collects partial records (`0x2C` record type) from the device's
|
||||||
|
event list without triggering a full waveform download (no 0C or 5A). Returns
|
||||||
|
`list[MonitorLogEntry]`. Each entry represents one continuous monitoring interval where
|
||||||
|
no threshold was exceeded.
|
||||||
|
|
||||||
|
- **`_decode_0a_partial_header(raw_data, index, key4)`** in `client.py` — decodes a SUB
|
||||||
|
0x0A response payload whose record type is `0x2C`. Extracts:
|
||||||
|
- `start_time` / `stop_time` — two consecutive timestamps; auto-detects 9-byte
|
||||||
|
(sub_code=0x10, single-shot) vs 10-byte (sub_code=0x03, continuous) format from
|
||||||
|
`raw_data[11]`. Handles a 1-byte gap between the two timestamps that occurs when
|
||||||
|
ts1 and ts2 share the same minute:second.
|
||||||
|
- `serial` — device serial string found via `b"BE"` anchor scan.
|
||||||
|
- `geo_threshold_ips` — trigger level found via `b"Geo: "` anchor scan.
|
||||||
|
|
||||||
|
- **`MonitorLogEntry` dataclass** in `models.py` — new model for partial records:
|
||||||
|
`index`, `key`, `start_time`, `stop_time`, `serial`, `geo_threshold_ips`,
|
||||||
|
`raw_header`, and a `duration_seconds` property.
|
||||||
|
|
||||||
|
- **`read_waveform_header()` return value extended** — now returns `(data_rsp.data, length)`
|
||||||
|
(full payload) instead of `(data_rsp.data[11:11+length], length)`. Callers get the
|
||||||
|
complete payload including the record-type byte at position 0. Full records use
|
||||||
|
`raw_data[11:11+length]` as before; partial records are detected by `raw_data[0] == 0x2C`.
|
||||||
|
|
||||||
|
- **ACH server: monitor log collection** — after `get_events()`, calls
|
||||||
|
`get_monitor_log_entries(skip_keys=seen_keys)` and saves new entries to
|
||||||
|
`monitor_log.json` in the session directory. Monitor log keys are included in
|
||||||
|
`downloaded_keys` for state persistence (no re-processing on next call-home).
|
||||||
|
|
||||||
|
- **`_monitor_log_entry_to_dict()`** in `ach_server.py` — serialises a `MonitorLogEntry`
|
||||||
|
to a JSON-compatible dict with ISO-format timestamps.
|
||||||
|
|
||||||
|
### Protocol / Documentation
|
||||||
|
|
||||||
|
- **SUB 0x0A partial record (0x2C) format confirmed** (✅ 4-11-26 MITM capture, 12 frames):
|
||||||
|
- Record type `0x2C` at `raw_data[0]`; length < 64 bytes.
|
||||||
|
- Two timestamps at `raw_data[11:]` — start and stop of the monitoring interval.
|
||||||
|
- ASCII metadata region after timestamps: `BE<serial>\x00Geo: <float> in/s`.
|
||||||
|
- Edge case: 1-byte separator between timestamps when ts1 and ts2 share minute:second.
|
||||||
|
- 10-byte timestamp format (sub_code=0x03) signalled by `raw_data[11] == 0x10`.
|
||||||
|
|
||||||
|
- **Key reuse detection for monitor log entries** — monitor log keys are tracked alongside
|
||||||
|
event keys in `ach_state.json` so the ACH server does not re-process them after a
|
||||||
|
call-home cycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.9.0 — 2026-04-11
|
## v0.9.0 — 2026-04-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for
|
Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for
|
||||||
managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem
|
managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem
|
||||||
(Sierra Wireless RV50 / RV55). Current version: **v0.9.0**.
|
(Sierra Wireless RV50 / RV55). Current version: **v0.10.0**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,9 +25,9 @@ CHANGELOG.md ← version history
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current implementation state (v0.9.0)
|
## Current implementation state (v0.10.0)
|
||||||
|
|
||||||
Full read pipeline + write pipeline + erase pipeline working end-to-end over TCP/cellular:
|
Full read pipeline + write pipeline + erase pipeline + monitor log working end-to-end over TCP/cellular:
|
||||||
|
|
||||||
| Step | SUB | Status |
|
| Step | SUB | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -42,7 +42,8 @@ Full read pipeline + write pipeline + erase pipeline working end-to-end over TCP
|
|||||||
| **Bulk waveform stream (event-time metadata)** | **5A** | ✅ new v0.6.0 |
|
| **Bulk waveform stream (event-time metadata)** | **5A** | ✅ new v0.6.0 |
|
||||||
| Event advance / next key | 1F | ✅ |
|
| Event advance / next key | 1F | ✅ |
|
||||||
| **Write commands (push config to device)** | **68–83** | ✅ new v0.8.0 |
|
| **Write commands (push config to device)** | **68–83** | ✅ new v0.8.0 |
|
||||||
| **Erase all events** | **0xA3 → 0x1C → 0x06 → 0xA2** | ✅ **new v0.9.0** |
|
| **Erase all events** | **0xA3 → 0x1C → 0x06 → 0xA2** | ✅ new v0.9.0 |
|
||||||
|
| **Monitor log entries (partial 0x2C records)** | **0A browse** | ✅ **new v0.10.0** |
|
||||||
|
|
||||||
`get_events()` sequence per event: `1E → 0A → 0C → 5A → 1F`
|
`get_events()` sequence per event: `1E → 0A → 0C → 5A → 1F`
|
||||||
|
|
||||||
@@ -415,6 +416,8 @@ for 0x10 records).
|
|||||||
|
|
||||||
## SFM REST API (sfm/server.py)
|
## SFM REST API (sfm/server.py)
|
||||||
|
|
||||||
|
### Live device endpoints (connect to device per-request)
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /device/info?port=COM5 ← serial
|
GET /device/info?port=COM5 ← serial
|
||||||
GET /device/info?host=1.2.3.4&tcp_port=9034 ← cellular
|
GET /device/info?host=1.2.3.4&tcp_port=9034 ← cellular
|
||||||
@@ -427,6 +430,19 @@ POST /device/monitor/stop?host=1.2.3.4&tcp_port=9034 ← stop recording
|
|||||||
|
|
||||||
Server retries once on `ProtocolError` for TCP connections (handles cold-boot timing).
|
Server retries once on `ProtocolError` for TCP connections (handles cold-boot timing).
|
||||||
|
|
||||||
|
### DB read endpoints (query seismo_relay.db written by ach_server.py)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /db/units ← all known serials + summary stats
|
||||||
|
GET /db/events?serial=BE11529&from_dt=&to_dt=&limit= ← triggered events, newest first
|
||||||
|
GET /db/monitor_log?serial=BE11529&from_dt=&to_dt= ← monitoring intervals, newest first
|
||||||
|
GET /db/sessions?serial=BE11529&limit=50 ← ACH call-home sessions, newest first
|
||||||
|
PATCH /db/events/{id}/false_trigger?value=true ← flag/unflag false triggers
|
||||||
|
```
|
||||||
|
|
||||||
|
DB file: `bridges/captures/seismo_relay.db` (default; override with `--db-path` at startup).
|
||||||
|
All DB endpoints are read-only except `PATCH /db/events/{id}/false_trigger`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key wire captures (reference material)
|
## Key wire captures (reference material)
|
||||||
@@ -795,8 +811,134 @@ the erase). Used for post-erase detection.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Monitor log entries — SUB 0x0A partial records (confirmed 2026-04-11)
|
||||||
|
|
||||||
|
Confirmed from 4-11-26 MITM capture: 12 partial records (record type `0x2C`) and 7 full
|
||||||
|
event records (record type `0x46`) across 19 total 0x0A responses.
|
||||||
|
|
||||||
|
### Record type detection
|
||||||
|
|
||||||
|
`read_waveform_header()` returns `(raw_data, length)` where `raw_data = data_rsp.data`
|
||||||
|
(the full payload including prefix bytes). The record type is at `raw_data[0]`:
|
||||||
|
|
||||||
|
| Value | Type | How to process |
|
||||||
|
|---|---|---|
|
||||||
|
| `0x46` | Full triggered event | Normal download: 0C → 5A → 1F |
|
||||||
|
| `0x2C` | Monitor log entry (partial) | No 0C/5A; decode inline from 0A payload |
|
||||||
|
|
||||||
|
Length heuristic: `length < 0x40` (64) reliably identifies partial records across all
|
||||||
|
observed captures. Both checks (`raw_data[0] == 0x2C` and `length < 0x40`) are used.
|
||||||
|
|
||||||
|
### SUB 0x0A partial record (0x2C) payload layout
|
||||||
|
|
||||||
|
All offsets are from `raw_data` (the full `data_rsp.data` array including the 11-byte
|
||||||
|
prefix before the actual header bytes start).
|
||||||
|
|
||||||
|
```
|
||||||
|
raw_data[0] = 0x2C ← record type (partial / monitor log)
|
||||||
|
raw_data[1:11] = prefix bytes (vary; contain key4 copy, flags, length)
|
||||||
|
raw_data[11:] = timestamp and ASCII metadata payload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timestamp auto-detection** (confirmed from 4-11-26 capture):
|
||||||
|
|
||||||
|
```
|
||||||
|
raw_data[11] == 0x10 → 10-byte sub_code=0x03 format (continuous mode)
|
||||||
|
raw_data[11] != 0x10 → 9-byte sub_code=0x10 format (single-shot mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
**9-byte timestamp format (sub_code=0x10):**
|
||||||
|
|
||||||
|
| Byte | Field |
|
||||||
|
|---|---|
|
||||||
|
| 0 | day |
|
||||||
|
| 1 | `0x10` (sub_code marker) |
|
||||||
|
| 2 | month |
|
||||||
|
| 3–4 | year (uint16 BE) |
|
||||||
|
| 5 | unknown (0x00) |
|
||||||
|
| 6 | hour |
|
||||||
|
| 7 | minute |
|
||||||
|
| 8 | second |
|
||||||
|
|
||||||
|
**10-byte timestamp format (sub_code=0x03):**
|
||||||
|
|
||||||
|
| Byte | Field |
|
||||||
|
|---|---|
|
||||||
|
| 0 | `0x10` (marker) |
|
||||||
|
| 1 | day |
|
||||||
|
| 2 | `0x10` (marker) |
|
||||||
|
| 3 | month |
|
||||||
|
| 4–5 | year (uint16 BE) |
|
||||||
|
| 6 | unknown (0x00) |
|
||||||
|
| 7 | hour |
|
||||||
|
| 8 | minute |
|
||||||
|
| 9 | second |
|
||||||
|
|
||||||
|
**Two timestamps:** Each partial record contains two timestamps — `start_time` and
|
||||||
|
`stop_time` — stored consecutively:
|
||||||
|
- `ts1` (start) at `raw_data[ts_offset : ts_offset + ts_size]` where `ts_offset = 11`
|
||||||
|
- `ts2` (stop) at `raw_data[ts1_end : ts1_end + ts_size]`
|
||||||
|
|
||||||
|
**Edge case — 1-byte gap between timestamps:** Occurs when ts1 and ts2 share the same
|
||||||
|
minute:second. If `try_ts(raw_data[ts1_end:])` fails, try `try_ts(raw_data[ts1_end+1:])`.
|
||||||
|
Confirmed in frames 121, 161, 165 of the 4-11-26 MITM capture. Frame 121 still shows 0s
|
||||||
|
duration (both decode to 16:02:00) — the extra byte appears in all same-second cases.
|
||||||
|
|
||||||
|
**ASCII metadata after timestamps:**
|
||||||
|
```
|
||||||
|
<separator bytes> BE<serial>\x00Geo: <float> in/s ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- Serial: scan for `b"BE"`, read until `b"\x00"` (e.g. `"BE11529"`)
|
||||||
|
- Geo threshold: scan for `b"Geo: "`, read float until next space (e.g. `0.254` in/s)
|
||||||
|
|
||||||
|
A separator of variable length (4–5 bytes of `\x00` + flags) sits between the two
|
||||||
|
timestamps and the ASCII region. The `b"BE"` anchor scan is robust to separator length
|
||||||
|
variation.
|
||||||
|
|
||||||
|
### `_decode_0a_partial_header(raw_data, index, key4)` — client.py
|
||||||
|
|
||||||
|
Returns a `MonitorLogEntry` or `None`. Called by `get_monitor_log_entries()` for each
|
||||||
|
event key whose 0x0A response has `raw_data[0] == 0x2C` or `length < 0x40`.
|
||||||
|
|
||||||
|
### `MiniMateClient.get_monitor_log_entries(skip_keys=None)` — client.py
|
||||||
|
|
||||||
|
Browse-mode walk: `1E → 0A → check type → decode if partial → 1F`. No 0x0C or 5A reads
|
||||||
|
performed. Full (0x46) records are skipped without decoding. Returns `list[MonitorLogEntry]`.
|
||||||
|
|
||||||
|
`skip_keys` (optional `set[str]`): keys in this set are still advanced through the walk
|
||||||
|
(to avoid disrupting the iteration sequence), but no `MonitorLogEntry` is created for them.
|
||||||
|
|
||||||
|
### `MonitorLogEntry` model — models.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class MonitorLogEntry:
|
||||||
|
index: int # 0-based position
|
||||||
|
key: str # 8-hex event key
|
||||||
|
start_time: Optional[datetime.datetime] = None
|
||||||
|
stop_time: Optional[datetime.datetime] = None
|
||||||
|
serial: Optional[str] = None
|
||||||
|
geo_threshold_ips: Optional[float] = None
|
||||||
|
raw_header: Optional[bytes] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_seconds(self) -> Optional[float]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### ACH server integration (v0.10.0)
|
||||||
|
|
||||||
|
After `get_events()`, the ACH server calls `get_monitor_log_entries(skip_keys=seen_keys)`.
|
||||||
|
New entries are saved to `monitor_log.json` in the session directory. Monitor log keys are
|
||||||
|
included in `current_keys` for state persistence so they are not re-processed on the next
|
||||||
|
call-home.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What's next
|
## What's next
|
||||||
|
|
||||||
|
- **Database** — SQLite store for events + monitor log entries; dedup by key; queryable
|
||||||
|
- **Histograms** — decode histogram-mode A5 data (noise floor tracking)
|
||||||
- Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
|
- Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
|
||||||
- Locate "Sensor Check" byte in compliance config (need capture with Disabled vs Before-monitoring)
|
- Locate "Sensor Check" byte in compliance config (need capture with Disabled vs Before-monitoring)
|
||||||
- Modem manager — push RV50/RV55 configs via Sierra Wireless API
|
- Modem manager — push RV50/RV55 configs via Sierra Wireless API
|
||||||
|
|||||||
+82
-2
@@ -67,7 +67,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||||||
|
|
||||||
from minimateplus.transport import SocketTransport
|
from minimateplus.transport import SocketTransport
|
||||||
from minimateplus.client import MiniMateClient
|
from minimateplus.client import MiniMateClient
|
||||||
from minimateplus.models import DeviceInfo, Event
|
from minimateplus.models import DeviceInfo, Event, MonitorLogEntry
|
||||||
|
from sfm.database import SeismoDb
|
||||||
|
|
||||||
log = logging.getLogger("ach_server")
|
log = logging.getLogger("ach_server")
|
||||||
|
|
||||||
@@ -136,6 +137,7 @@ class AchSession:
|
|||||||
events_only: bool,
|
events_only: bool,
|
||||||
max_events: Optional[int],
|
max_events: Optional[int],
|
||||||
state_path: Path,
|
state_path: Path,
|
||||||
|
db: "SeismoDb",
|
||||||
clear_after_download: bool = False,
|
clear_after_download: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.sock = sock
|
self.sock = sock
|
||||||
@@ -145,6 +147,7 @@ class AchSession:
|
|||||||
self.events_only = events_only
|
self.events_only = events_only
|
||||||
self.max_events = max_events
|
self.max_events = max_events
|
||||||
self.state_path = state_path
|
self.state_path = state_path
|
||||||
|
self.db = db
|
||||||
self.clear_after_download = clear_after_download
|
self.clear_after_download = clear_after_download
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
@@ -372,6 +375,66 @@ class AchSession:
|
|||||||
else:
|
else:
|
||||||
log.info(" [OK] No new events since last call-home -- nothing to save")
|
log.info(" [OK] No new events since last call-home -- nothing to save")
|
||||||
|
|
||||||
|
# ── Monitor log entries (partial records / continuous monitoring) ──
|
||||||
|
# Browse walk (0A + 1F only) to collect monitor log entries for
|
||||||
|
# recording intervals where no threshold was crossed. This is a
|
||||||
|
# second 1E-based pass over the device's record list, separate from
|
||||||
|
# the get_events() download loop above.
|
||||||
|
log.info(" Collecting monitor log entries (browse walk)...")
|
||||||
|
new_monitor_entries: list[MonitorLogEntry] = []
|
||||||
|
try:
|
||||||
|
new_monitor_entries = client.get_monitor_log_entries(
|
||||||
|
skip_keys=seen_keys if seen_keys else None,
|
||||||
|
)
|
||||||
|
if new_monitor_entries:
|
||||||
|
_save_json(
|
||||||
|
session_dir / "monitor_log.json",
|
||||||
|
[_monitor_log_entry_to_dict(e) for e in new_monitor_entries],
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
" [OK] %d new monitor log entry(s) saved",
|
||||||
|
len(new_monitor_entries),
|
||||||
|
)
|
||||||
|
for ml in new_monitor_entries:
|
||||||
|
log.info(
|
||||||
|
" MONLOG [%s] %s → %s (%s)",
|
||||||
|
ml.key,
|
||||||
|
ml.start_time.isoformat() if ml.start_time else "?",
|
||||||
|
ml.stop_time.isoformat() if ml.stop_time else "?",
|
||||||
|
f"{ml.duration_seconds:.0f}s" if ml.duration_seconds is not None else "?s",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.info(" [OK] No new monitor log entries")
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning(
|
||||||
|
" [WARN] Monitor log collection failed: %s -- continuing",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Persist to SQLite DB ─────────────────────────────────────
|
||||||
|
_session_start = datetime.datetime.now()
|
||||||
|
try:
|
||||||
|
_ev_ins, _ev_skip = self.db.insert_events(
|
||||||
|
new_events, serial=serial or self.peer, session_id=None
|
||||||
|
)
|
||||||
|
_ml_ins, _ml_skip = self.db.insert_monitor_log(
|
||||||
|
new_monitor_entries, session_id=None
|
||||||
|
)
|
||||||
|
_session_id = self.db.insert_ach_session(
|
||||||
|
serial=serial or self.peer,
|
||||||
|
peer=self.peer,
|
||||||
|
events_downloaded=_ev_ins,
|
||||||
|
monitor_entries=_ml_ins,
|
||||||
|
duration_seconds=(datetime.datetime.now() - _session_start).total_seconds(),
|
||||||
|
session_time=_session_start,
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
" [DB] session=%s events +%d (skip %d) monitor +%d (skip %d)",
|
||||||
|
_session_id[:8], _ev_ins, _ev_skip, _ml_ins, _ml_skip,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning(" [WARN] DB write failed: %s -- continuing", exc)
|
||||||
|
|
||||||
# ── Optional: erase device memory after successful download ────
|
# ── Optional: erase device memory after successful download ────
|
||||||
erased_successfully = False
|
erased_successfully = False
|
||||||
if self.clear_after_download and new_events:
|
if self.clear_after_download and new_events:
|
||||||
@@ -387,11 +450,15 @@ class AchSession:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ── Update persistent state ───────────────────────────────────
|
# ── Update persistent state ───────────────────────────────────
|
||||||
current_keys = [
|
# Include both triggered-event keys and monitor-log keys in the
|
||||||
|
# downloaded set so they are not re-processed on the next call-home.
|
||||||
|
current_event_keys = [
|
||||||
e._waveform_key.hex()
|
e._waveform_key.hex()
|
||||||
for e in all_events
|
for e in all_events
|
||||||
if e._waveform_key is not None
|
if e._waveform_key is not None
|
||||||
]
|
]
|
||||||
|
current_monitor_keys = [e.key for e in new_monitor_entries]
|
||||||
|
current_keys = current_event_keys + current_monitor_keys
|
||||||
|
|
||||||
if erased_successfully:
|
if erased_successfully:
|
||||||
# Device memory is clear. Reset downloaded_keys and the
|
# Device memory is clear. Reset downloaded_keys and the
|
||||||
@@ -492,12 +559,24 @@ def _event_to_dict(e: Event) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _monitor_log_entry_to_dict(e: MonitorLogEntry) -> dict:
|
||||||
|
return {
|
||||||
|
"key": e.key,
|
||||||
|
"start_time": e.start_time.isoformat() if e.start_time else None,
|
||||||
|
"stop_time": e.stop_time.isoformat() if e.stop_time else None,
|
||||||
|
"duration_seconds": e.duration_seconds,
|
||||||
|
"serial": e.serial,
|
||||||
|
"geo_threshold_ips": e.geo_threshold_ips,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Main server loop ───────────────────────────────────────────────────────────
|
# ── Main server loop ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def serve(args: argparse.Namespace) -> None:
|
def serve(args: argparse.Namespace) -> None:
|
||||||
output_dir = Path(args.output)
|
output_dir = Path(args.output)
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
state_path = output_dir / "ach_state.json"
|
state_path = output_dir / "ach_state.json"
|
||||||
|
db = SeismoDb(output_dir / "seismo_relay.db")
|
||||||
|
|
||||||
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
@@ -550,6 +629,7 @@ def serve(args: argparse.Namespace) -> None:
|
|||||||
events_only=args.events_only,
|
events_only=args.events_only,
|
||||||
max_events=max_ev,
|
max_events=max_ev,
|
||||||
state_path=state_path,
|
state_path=state_path,
|
||||||
|
db=db,
|
||||||
clear_after_download=args.clear_after_download,
|
clear_after_download=args.clear_after_download,
|
||||||
)
|
)
|
||||||
t = threading.Thread(target=session.run, daemon=True, name=f"ach-{peer}")
|
t = threading.Thread(target=session.run, daemon=True, name=f"ach-{peer}")
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ Typical usage (TCP / modem):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .client import MiniMateClient
|
from .client import MiniMateClient
|
||||||
from .models import DeviceInfo, Event
|
from .models import DeviceInfo, Event, MonitorLogEntry
|
||||||
from .transport import SerialTransport, TcpTransport
|
from .transport import SerialTransport, TcpTransport
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
__all__ = ["MiniMateClient", "DeviceInfo", "Event", "SerialTransport", "TcpTransport"]
|
__all__ = ["MiniMateClient", "DeviceInfo", "Event", "MonitorLogEntry", "SerialTransport", "TcpTransport"]
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Example (TCP / modem):
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -37,6 +38,7 @@ from .models import (
|
|||||||
ComplianceConfig,
|
ComplianceConfig,
|
||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
Event,
|
Event,
|
||||||
|
MonitorLogEntry,
|
||||||
MonitorStatus,
|
MonitorStatus,
|
||||||
PeakValues,
|
PeakValues,
|
||||||
ProjectInfo,
|
ProjectInfo,
|
||||||
@@ -300,6 +302,96 @@ class MiniMateClient:
|
|||||||
log.info("list_event_keys: %d key(s): %s", len(keys), keys)
|
log.info("list_event_keys: %d key(s): %s", len(keys), keys)
|
||||||
return keys
|
return keys
|
||||||
|
|
||||||
|
def get_monitor_log_entries(
|
||||||
|
self,
|
||||||
|
skip_keys: Optional[set] = None,
|
||||||
|
) -> list[MonitorLogEntry]:
|
||||||
|
"""
|
||||||
|
Collect all monitor log entries (partial records, type 0x2C) from the
|
||||||
|
device using the browse-mode 1E → 0A → 1F walk.
|
||||||
|
|
||||||
|
This is the fast path for monitor log data. No 0C or 5A commands are
|
||||||
|
issued — all available monitor log information is in the 0x0A response
|
||||||
|
header alone.
|
||||||
|
|
||||||
|
Full triggered events (0x0A response type 0x46) are silently skipped.
|
||||||
|
Only partial records (type 0x2C) are returned as MonitorLogEntry objects.
|
||||||
|
|
||||||
|
Confirmed from 4-11-26 MITM capture: Blastware's ACH mode performs a
|
||||||
|
full browse walk (Phase 3: 0x0A + 1F × all records) AFTER the triggered-
|
||||||
|
event download phase. The partial records encountered in this walk are
|
||||||
|
the monitor log entries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skip_keys: optional set of 8-hex key strings to skip (already seen).
|
||||||
|
Keys in this set still advance the walk (0A + 1F) but are
|
||||||
|
not decoded or returned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of MonitorLogEntry objects in device storage order.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProtocolError: on unrecoverable communication failure.
|
||||||
|
"""
|
||||||
|
proto = self._require_proto()
|
||||||
|
try:
|
||||||
|
key4, data8 = proto.read_event_first()
|
||||||
|
except ProtocolError as exc:
|
||||||
|
log.warning("get_monitor_log_entries: 1E failed: %s -- returning []", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if data8[4:8] == b"\x00\x00\x00\x00":
|
||||||
|
log.info("get_monitor_log_entries: device is empty")
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries: list[MonitorLogEntry] = []
|
||||||
|
idx = 0
|
||||||
|
|
||||||
|
while data8[4:8] != b"\x00\x00\x00\x00":
|
||||||
|
cur_key = key4
|
||||||
|
key_hex = cur_key.hex()
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_data, rec_len = proto.read_waveform_header(cur_key)
|
||||||
|
except ProtocolError as exc:
|
||||||
|
log.warning(
|
||||||
|
"get_monitor_log_entries: 0A failed for key=%s: %s -- stopping",
|
||||||
|
key_hex, exc,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Only decode partial records (0x2C); full records (0x46) are silently skipped.
|
||||||
|
if rec_len < 0x40 and raw_data and (not skip_keys or key_hex not in skip_keys):
|
||||||
|
entry = _decode_0a_partial_header(raw_data, idx, cur_key)
|
||||||
|
if entry is not None:
|
||||||
|
entries.append(entry)
|
||||||
|
log.debug(
|
||||||
|
"get_monitor_log_entries: [%d] key=%s %s → %s",
|
||||||
|
idx, key_hex, entry.start_time, entry.stop_time,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.debug(
|
||||||
|
"get_monitor_log_entries: [%d] key=%s type=0x%02X %s",
|
||||||
|
idx, key_hex, rec_len,
|
||||||
|
"skip (already seen)" if skip_keys and key_hex in skip_keys else "skip (full record)",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
key4, data8 = proto.advance_event(browse=True)
|
||||||
|
except ProtocolError as exc:
|
||||||
|
log.warning(
|
||||||
|
"get_monitor_log_entries: 1F failed after %d record(s): %s -- stopping",
|
||||||
|
idx, exc,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"get_monitor_log_entries: walked %d record(s), found %d monitor log entry(s)",
|
||||||
|
idx, len(entries),
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
def delete_all_events(self) -> None:
|
def delete_all_events(self) -> None:
|
||||||
"""
|
"""
|
||||||
Erase all stored events from the device memory.
|
Erase all stored events from the device memory.
|
||||||
@@ -1856,6 +1948,123 @@ def _find_first_string(data: bytes, start: int, end: int, min_len: int) -> Optio
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_0a_partial_header(raw_data: bytes, index: int, key4: bytes) -> Optional[MonitorLogEntry]:
|
||||||
|
"""
|
||||||
|
Decode a SUB 0x0A response for a partial (monitor log) record into a
|
||||||
|
MonitorLogEntry.
|
||||||
|
|
||||||
|
Called when read_waveform_header() returns rec_len < 0x40 (i.e. 0x2C = 44).
|
||||||
|
raw_data is the complete data_rsp.data from the protocol layer.
|
||||||
|
|
||||||
|
Layout of raw_data:
|
||||||
|
[0] = 0x2C (partial record type)
|
||||||
|
[1:5] = 0x00 × 4
|
||||||
|
[5:9] = event key (big-endian)
|
||||||
|
[9:11] = 0x00 × 2
|
||||||
|
[11:] = timestamp_start + timestamp_stop + sep + serial + geo_string
|
||||||
|
|
||||||
|
Timestamp format detection (auto):
|
||||||
|
raw_data[11] == 0x10 → 10-byte sub_code=0x03 continuous format
|
||||||
|
raw_data[12] == 0x10 → 9-byte sub_code=0x10 single-shot format
|
||||||
|
|
||||||
|
Both timestamps use the same format (detected from the first byte).
|
||||||
|
A 1-byte gap can appear between ts1 and ts2 for certain timestamps
|
||||||
|
(observed empirically when both timestamps share the same minute:second).
|
||||||
|
The parser handles this by trying ts2 immediately after ts1, then with
|
||||||
|
a 1-byte skip if that fails.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MonitorLogEntry if decoding succeeds, None on error.
|
||||||
|
"""
|
||||||
|
if len(raw_data) < 20 or raw_data[0] != 0x2C:
|
||||||
|
return None
|
||||||
|
|
||||||
|
key_hex = key4.hex()
|
||||||
|
|
||||||
|
def try_ts9(b: bytes):
|
||||||
|
"""9-byte sub_code=0x10 format. Returns datetime or None."""
|
||||||
|
if len(b) < 9 or b[1] != 0x10:
|
||||||
|
return None
|
||||||
|
day = b[0]; month = b[2]; year = (b[3] << 8) | b[4]
|
||||||
|
hr = b[6]; mn = b[7]; sec = b[8]
|
||||||
|
if not (1 <= day <= 31 and 1 <= month <= 12 and 2000 <= year <= 2050
|
||||||
|
and hr <= 23 and mn <= 59 and sec <= 59):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.datetime(year, month, day, hr, mn, sec)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_ts10(b: bytes):
|
||||||
|
"""10-byte sub_code=0x03 format. Returns datetime or None."""
|
||||||
|
if len(b) < 10 or b[0] != 0x10 or b[2] != 0x10:
|
||||||
|
return None
|
||||||
|
day = b[1]; month = b[3]; year = (b[4] << 8) | b[5]
|
||||||
|
hr = b[7]; mn = b[8]; sec = b[9]
|
||||||
|
if not (1 <= day <= 31 and 1 <= month <= 12 and 2000 <= year <= 2050
|
||||||
|
and hr <= 23 and mn <= 59 and sec <= 59):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.datetime(year, month, day, hr, mn, sec)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ts_offset = 11
|
||||||
|
if len(raw_data) <= ts_offset:
|
||||||
|
return MonitorLogEntry(index=index, key=key_hex, raw_header=raw_data)
|
||||||
|
|
||||||
|
# Detect timestamp format.
|
||||||
|
if raw_data[ts_offset] == 0x10:
|
||||||
|
ts_size = 10
|
||||||
|
try_ts = try_ts10
|
||||||
|
else:
|
||||||
|
ts_size = 9
|
||||||
|
try_ts = try_ts9
|
||||||
|
|
||||||
|
# Parse ts1.
|
||||||
|
ts1 = try_ts(raw_data[ts_offset:ts_offset + ts_size])
|
||||||
|
ts1_end = ts_offset + ts_size
|
||||||
|
|
||||||
|
# Parse ts2 immediately after ts1, then with 1-byte skip if needed.
|
||||||
|
ts2 = try_ts(raw_data[ts1_end:ts1_end + ts_size])
|
||||||
|
if ts2 is None:
|
||||||
|
ts2 = try_ts(raw_data[ts1_end + 1:ts1_end + 1 + ts_size])
|
||||||
|
|
||||||
|
# Extract serial and geo threshold from "BE11529\0" and "Geo: X.XXX in/s\0".
|
||||||
|
serial: Optional[str] = None
|
||||||
|
geo_ips: Optional[float] = None
|
||||||
|
|
||||||
|
serial_pos = raw_data.find(b"BE")
|
||||||
|
if serial_pos >= 0:
|
||||||
|
# Read null-terminated serial starting at serial_pos.
|
||||||
|
null_pos = raw_data.find(b"\x00", serial_pos)
|
||||||
|
if null_pos > serial_pos:
|
||||||
|
serial = raw_data[serial_pos:null_pos].decode("ascii", errors="replace")
|
||||||
|
# Geo string follows the null byte.
|
||||||
|
geo_start = (null_pos + 1) if null_pos > serial_pos else serial_pos + 7
|
||||||
|
geo_bytes = raw_data[geo_start:]
|
||||||
|
# "Geo: X.XXX in/s\0" — extract float after "Geo: ".
|
||||||
|
geo_str_pos = geo_bytes.find(b"Geo: ")
|
||||||
|
if geo_str_pos >= 0:
|
||||||
|
geo_val_bytes = geo_bytes[geo_str_pos + 5:] # after "Geo: "
|
||||||
|
geo_val_end = geo_val_bytes.find(b" ") # before " in/s"
|
||||||
|
if geo_val_end > 0:
|
||||||
|
try:
|
||||||
|
geo_ips = float(geo_val_bytes[:geo_val_end].decode("ascii"))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return MonitorLogEntry(
|
||||||
|
index=index,
|
||||||
|
key=key_hex,
|
||||||
|
start_time=ts1,
|
||||||
|
stop_time=ts2,
|
||||||
|
serial=serial,
|
||||||
|
geo_threshold_ips=geo_ips,
|
||||||
|
raw_header=raw_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _decode_monitor_status(data: bytes) -> MonitorStatus:
|
def _decode_monitor_status(data: bytes) -> MonitorStatus:
|
||||||
"""
|
"""
|
||||||
Decode SUB 0x1C response payload into a MonitorStatus object.
|
Decode SUB 0x1C response payload into a MonitorStatus object.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Notes on certainty:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
import struct
|
import struct
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -419,6 +420,65 @@ class Event:
|
|||||||
return f"Event#{self.index} {ts}{ppv}"
|
return f"Event#{self.index} {ts}{ppv}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── MonitorLogEntry ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MonitorLogEntry:
|
||||||
|
"""
|
||||||
|
A monitor log entry decoded from a SUB 0x0A (WAVEFORM_HEADER) response
|
||||||
|
whose first byte is 0x2C (partial record, recording mode = continuous
|
||||||
|
monitoring without a triggered event).
|
||||||
|
|
||||||
|
These are the "partial bins" that Blastware stores between triggered events.
|
||||||
|
Each entry represents one monitoring interval — the span of time during
|
||||||
|
which the unit was actively monitoring but no threshold crossing occurred.
|
||||||
|
|
||||||
|
Confirmed from 4-11-26 MITM capture analysis (2026-04-11):
|
||||||
|
|
||||||
|
Header layout (full response data[0:]):
|
||||||
|
data[0] = 0x2C (partial record type / data length in probe response)
|
||||||
|
data[1:5] = 0x00 × 4
|
||||||
|
data[5:9] = event key (4 bytes, big-endian hex)
|
||||||
|
data[9:11] = 0x00 × 2
|
||||||
|
data[11:] = timestamp_start (9 or 10 bytes depending on recording mode)
|
||||||
|
+ timestamp_stop (same format)
|
||||||
|
+ separator (4–5 bytes, variable)
|
||||||
|
+ serial null-terminated (e.g. "BE11529\\0")
|
||||||
|
+ "Geo: X.XXX in/s\\0" (trigger threshold string)
|
||||||
|
|
||||||
|
Timestamp format detection:
|
||||||
|
data[11] == 0x10 → 10-byte sub_code=0x03 (continuous) format
|
||||||
|
data[12] == 0x10 → 9-byte sub_code=0x10 (single-shot) format
|
||||||
|
|
||||||
|
In contrast to Event (triggered records, type 0x46), MonitorLogEntry
|
||||||
|
records do NOT have a waveform record (SUB 0x0C) or bulk waveform stream
|
||||||
|
(SUB 5A). All available metadata is in the 0x0A header alone.
|
||||||
|
"""
|
||||||
|
index: int # 0-based position in device record list
|
||||||
|
key: str # 8-hex event key (e.g. "01114290") ✅
|
||||||
|
|
||||||
|
start_time: Optional[datetime.datetime] = None # monitoring session start ✅
|
||||||
|
stop_time: Optional[datetime.datetime] = None # monitoring session stop ✅
|
||||||
|
serial: Optional[str] = None # device serial (e.g. "BE11529") ✅
|
||||||
|
geo_threshold_ips: Optional[float] = None # trigger level from "Geo: X.XXX in/s" ✅
|
||||||
|
|
||||||
|
# Raw bytes for debugging / future decoding
|
||||||
|
raw_header: Optional[bytes] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_seconds(self) -> Optional[float]:
|
||||||
|
"""Duration of monitoring interval in seconds, or None if times unavailable."""
|
||||||
|
if self.start_time and self.stop_time:
|
||||||
|
return (self.stop_time - self.start_time).total_seconds()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
start = self.start_time.isoformat() if self.start_time else "?"
|
||||||
|
stop = self.stop_time.isoformat() if self.stop_time else "?"
|
||||||
|
dur = f" ({self.duration_seconds:.0f}s)" if self.duration_seconds is not None else ""
|
||||||
|
return f"MonitorLog#{self.index} key={self.key} {start}→{stop}{dur}"
|
||||||
|
|
||||||
|
|
||||||
# ── MonitorStatus ─────────────────────────────────────────────────────────────
|
# ── MonitorStatus ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
+19
-11
@@ -394,23 +394,32 @@ class MiniMateProtocol:
|
|||||||
Send the SUB 0A (WAVEFORM_HEADER) two-step read for *key4*.
|
Send the SUB 0A (WAVEFORM_HEADER) two-step read for *key4*.
|
||||||
|
|
||||||
The data length for 0A is VARIABLE and must be read from the probe
|
The data length for 0A is VARIABLE and must be read from the probe
|
||||||
response at data[4]. Two known values:
|
response at data[4]. Two confirmed values:
|
||||||
0x30 — full histogram bin (has a waveform record to follow)
|
0x46 (70) — full triggered event (has 0C waveform record to follow)
|
||||||
0x26 — partial histogram bin (no waveform record)
|
0x2C (44) — partial / monitor-log entry (no 0C record; 0A header only)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key4: 4-byte waveform record address from 1E or 1F.
|
key4: 4-byte waveform record address from 1E or 1F.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(header_bytes, record_length) where:
|
(raw_data, record_length) where:
|
||||||
header_bytes — raw data section starting at data[11]
|
raw_data — complete data_rsp.data bytes (full response payload)
|
||||||
record_length — DATA_LENGTH read from probe (0x30 or 0x26)
|
record_length — DATA_LENGTH read from probe (0x46 for full, 0x2C for partial)
|
||||||
|
|
||||||
|
The raw_data layout:
|
||||||
|
raw_data[0] = record type (0x46 = full triggered event, 0x2C = partial/monitor)
|
||||||
|
raw_data[1:5] = 0x00 × 4
|
||||||
|
raw_data[5:9] = event key (4 bytes)
|
||||||
|
raw_data[9:11] = 0x00 × 2
|
||||||
|
raw_data[11:] = timestamps + separator + serial + channel strings
|
||||||
|
(see MonitorLogEntry in models.py for full layout)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ProtocolError: on timeout, bad checksum, or wrong response SUB.
|
ProtocolError: on timeout, bad checksum, or wrong response SUB.
|
||||||
|
|
||||||
Confirmed from 3-31-26 capture: 0A probe response data[4] carries
|
Confirmed from 4-11-26 MITM capture: 0A probe response data[4] carries
|
||||||
the variable length; data-request uses that length as the offset byte.
|
the variable length; data-request uses that length as the offset byte.
|
||||||
|
record_length == data[0] in virtually all cases (confirmed empirically).
|
||||||
"""
|
"""
|
||||||
rsp_sub = _expected_rsp_sub(SUB_WAVEFORM_HEADER)
|
rsp_sub = _expected_rsp_sub(SUB_WAVEFORM_HEADER)
|
||||||
params = waveform_key_params(key4)
|
params = waveform_key_params(key4)
|
||||||
@@ -420,7 +429,7 @@ class MiniMateProtocol:
|
|||||||
probe_rsp = self._recv_one(expected_sub=rsp_sub)
|
probe_rsp = self._recv_one(expected_sub=rsp_sub)
|
||||||
|
|
||||||
# Variable length — read from probe response data[4]
|
# Variable length — read from probe response data[4]
|
||||||
length = probe_rsp.data[4] if len(probe_rsp.data) > 4 else 0x30
|
length = probe_rsp.data[4] if len(probe_rsp.data) > 4 else 0x46
|
||||||
log.debug("read_waveform_header: 0A data request offset=0x%02X", length)
|
log.debug("read_waveform_header: 0A data request offset=0x%02X", length)
|
||||||
|
|
||||||
if length == 0:
|
if length == 0:
|
||||||
@@ -429,12 +438,11 @@ class MiniMateProtocol:
|
|||||||
self._send(build_bw_frame(SUB_WAVEFORM_HEADER, length, params))
|
self._send(build_bw_frame(SUB_WAVEFORM_HEADER, length, params))
|
||||||
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
||||||
|
|
||||||
header_bytes = data_rsp.data[11:11 + length]
|
|
||||||
log.debug(
|
log.debug(
|
||||||
"read_waveform_header: key=%s length=0x%02X is_full=%s",
|
"read_waveform_header: key=%s length=0x%02X is_full=%s",
|
||||||
key4.hex(), length, length == 0x30,
|
key4.hex(), length, length >= 0x40,
|
||||||
)
|
)
|
||||||
return header_bytes, length
|
return data_rsp.data, length
|
||||||
|
|
||||||
def read_waveform_data_raw(self) -> bytes:
|
def read_waveform_data_raw(self) -> bytes:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+406
@@ -0,0 +1,406 @@
|
|||||||
|
"""
|
||||||
|
sfm/database.py — SQLite persistence layer for seismo-relay.
|
||||||
|
|
||||||
|
Three tables, all keyed by unit serial number:
|
||||||
|
|
||||||
|
ach_sessions — one row per inbound ACH call-home
|
||||||
|
events — one row per triggered waveform event (deduped by serial+key)
|
||||||
|
monitor_log — one row per monitoring interval (deduped by serial+key)
|
||||||
|
|
||||||
|
The DB file lives at:
|
||||||
|
<output_dir>/seismo_relay.db (default: bridges/captures/seismo_relay.db)
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
from sfm.database import SeismoDb
|
||||||
|
|
||||||
|
db = SeismoDb("bridges/captures/seismo_relay.db")
|
||||||
|
|
||||||
|
# Write a call-home session
|
||||||
|
session_id = db.insert_ach_session(serial="BE11529", peer="1.2.3.4:51920",
|
||||||
|
events_downloaded=3, monitor_entries=2,
|
||||||
|
duration_seconds=47.3)
|
||||||
|
|
||||||
|
# Write events (silently skips duplicates)
|
||||||
|
db.insert_events(events, serial="BE11529", session_id=session_id)
|
||||||
|
|
||||||
|
# Write monitor log entries
|
||||||
|
db.insert_monitor_log(entries, session_id=session_id)
|
||||||
|
|
||||||
|
# Query
|
||||||
|
rows = db.query_events(serial="BE11529", from_dt=datetime(...), to_dt=datetime(...))
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from minimateplus.models import Event, MonitorLogEntry
|
||||||
|
|
||||||
|
log = logging.getLogger("sfm.database")
|
||||||
|
|
||||||
|
# ── Schema ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ach_sessions (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID
|
||||||
|
serial TEXT NOT NULL,
|
||||||
|
session_time TEXT NOT NULL, -- ISO-8601 UTC
|
||||||
|
peer TEXT, -- "ip:port"
|
||||||
|
events_downloaded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
monitor_entries INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duration_seconds REAL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ach_sessions_serial ON ach_sessions(serial);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ach_sessions_time ON ach_sessions(session_time);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID
|
||||||
|
serial TEXT NOT NULL,
|
||||||
|
waveform_key TEXT NOT NULL, -- 8-hex device key (dedup field)
|
||||||
|
session_id TEXT, -- FK → ach_sessions.id
|
||||||
|
timestamp TEXT, -- ISO-8601 local time from device
|
||||||
|
tran_ppv REAL, -- in/s
|
||||||
|
vert_ppv REAL, -- in/s
|
||||||
|
long_ppv REAL, -- in/s
|
||||||
|
peak_vector_sum REAL, -- in/s
|
||||||
|
mic_ppv REAL, -- psi or dB depending on setup
|
||||||
|
project TEXT,
|
||||||
|
client TEXT,
|
||||||
|
operator TEXT,
|
||||||
|
sensor_location TEXT,
|
||||||
|
sample_rate INTEGER,
|
||||||
|
record_type TEXT, -- "single_shot" | "continuous"
|
||||||
|
false_trigger INTEGER NOT NULL DEFAULT 0, -- 0=no, 1=yes (manual flag)
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
UNIQUE(serial, waveform_key)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_serial ON events(serial);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS monitor_log (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID
|
||||||
|
serial TEXT NOT NULL,
|
||||||
|
waveform_key TEXT NOT NULL, -- 8-hex device key (dedup field)
|
||||||
|
session_id TEXT, -- FK → ach_sessions.id
|
||||||
|
start_time TEXT, -- ISO-8601
|
||||||
|
stop_time TEXT, -- ISO-8601
|
||||||
|
duration_seconds REAL,
|
||||||
|
geo_threshold_ips REAL, -- in/s
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
UNIQUE(serial, waveform_key)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_monitor_log_serial ON monitor_log(serial);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_monitor_log_start ON monitor_log(start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_monitor_log_session ON monitor_log(session_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── SeismoDb class ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SeismoDb:
|
||||||
|
"""
|
||||||
|
Thin SQLite wrapper for seismo-relay persistence.
|
||||||
|
|
||||||
|
Thread-safe: each call opens, uses, and closes a connection with
|
||||||
|
check_same_thread=False and WAL mode enabled. For the ACH server's
|
||||||
|
single-writer / occasional-reader pattern this is more than sufficient.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str | Path) -> None:
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._init_schema()
|
||||||
|
log.info("SeismoDb initialised at %s", self.db_path)
|
||||||
|
|
||||||
|
# ── Internal helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode = WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _init_schema(self) -> None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.executescript(_SCHEMA)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _iso(dt: Optional[datetime.datetime]) -> Optional[str]:
|
||||||
|
return dt.isoformat() if dt is not None else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _new_id() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
# ── ACH sessions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def insert_ach_session(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
serial: str,
|
||||||
|
peer: Optional[str] = None,
|
||||||
|
events_downloaded: int = 0,
|
||||||
|
monitor_entries: int = 0,
|
||||||
|
duration_seconds: Optional[float] = None,
|
||||||
|
session_time: Optional[datetime.datetime] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Insert a new ACH session row. Returns the new session UUID."""
|
||||||
|
sid = self._new_id()
|
||||||
|
ts = self._iso(session_time or datetime.datetime.utcnow())
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ach_sessions
|
||||||
|
(id, serial, session_time, peer,
|
||||||
|
events_downloaded, monitor_entries, duration_seconds)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(sid, serial, ts, peer,
|
||||||
|
events_downloaded, monitor_entries, duration_seconds),
|
||||||
|
)
|
||||||
|
log.debug("ach_session inserted: %s serial=%s events=%d monitor=%d",
|
||||||
|
sid, serial, events_downloaded, monitor_entries)
|
||||||
|
return sid
|
||||||
|
|
||||||
|
def get_sessions(
|
||||||
|
self,
|
||||||
|
serial: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return recent ACH sessions, newest first."""
|
||||||
|
with self._connect() as conn:
|
||||||
|
if serial:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM ach_sessions WHERE serial=? "
|
||||||
|
"ORDER BY session_time DESC LIMIT ?",
|
||||||
|
(serial, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM ach_sessions ORDER BY session_time DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
# ── Events ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def insert_events(
|
||||||
|
self,
|
||||||
|
events: list[Event],
|
||||||
|
*,
|
||||||
|
serial: str,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Insert triggered events. Silently skips duplicates (serial+waveform_key).
|
||||||
|
Returns (inserted, skipped).
|
||||||
|
"""
|
||||||
|
inserted = skipped = 0
|
||||||
|
with self._connect() as conn:
|
||||||
|
for ev in events:
|
||||||
|
key = ev._waveform_key.hex() if ev._waveform_key else None
|
||||||
|
if key is None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
ts = None
|
||||||
|
if ev.timestamp:
|
||||||
|
try:
|
||||||
|
ts = datetime.datetime(
|
||||||
|
ev.timestamp.year, ev.timestamp.month, ev.timestamp.day,
|
||||||
|
ev.timestamp.hour, ev.timestamp.minute, ev.timestamp.second,
|
||||||
|
).isoformat()
|
||||||
|
except Exception:
|
||||||
|
ts = str(ev.timestamp)
|
||||||
|
|
||||||
|
pv = ev.peak_values
|
||||||
|
pi = ev.project_info
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO events
|
||||||
|
(id, serial, waveform_key, session_id, timestamp,
|
||||||
|
tran_ppv, vert_ppv, long_ppv, peak_vector_sum, mic_ppv,
|
||||||
|
project, client, operator, sensor_location,
|
||||||
|
sample_rate, record_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self._new_id(), serial, key, session_id, ts,
|
||||||
|
pv.tran if pv else None,
|
||||||
|
pv.vert if pv else None,
|
||||||
|
pv.long if pv else None,
|
||||||
|
pv.peak_vector_sum if pv else None,
|
||||||
|
pv.micl if pv else None,
|
||||||
|
pi.project if pi else None,
|
||||||
|
pi.client if pi else None,
|
||||||
|
pi.operator if pi else None,
|
||||||
|
pi.sensor_location if pi else None,
|
||||||
|
ev.sample_rate,
|
||||||
|
ev.record_type,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
log.debug("insert_events serial=%s inserted=%d skipped=%d",
|
||||||
|
serial, inserted, skipped)
|
||||||
|
return inserted, skipped
|
||||||
|
|
||||||
|
def query_events(
|
||||||
|
self,
|
||||||
|
serial: Optional[str] = None,
|
||||||
|
from_dt: Optional[datetime.datetime] = None,
|
||||||
|
to_dt: Optional[datetime.datetime] = None,
|
||||||
|
false_trigger: Optional[bool] = None,
|
||||||
|
limit: int = 500,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Query events with optional filters. Returns newest first."""
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if serial:
|
||||||
|
clauses.append("serial = ?")
|
||||||
|
params.append(serial)
|
||||||
|
if from_dt:
|
||||||
|
clauses.append("timestamp >= ?")
|
||||||
|
params.append(from_dt.isoformat())
|
||||||
|
if to_dt:
|
||||||
|
clauses.append("timestamp <= ?")
|
||||||
|
params.append(to_dt.isoformat())
|
||||||
|
if false_trigger is not None:
|
||||||
|
clauses.append("false_trigger = ?")
|
||||||
|
params.append(1 if false_trigger else 0)
|
||||||
|
|
||||||
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
params += [limit, offset]
|
||||||
|
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM events {where} "
|
||||||
|
f"ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def set_false_trigger(self, event_id: str, value: bool) -> bool:
|
||||||
|
"""Set or clear the false_trigger flag on an event. Returns True if found."""
|
||||||
|
with self._connect() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE events SET false_trigger=? WHERE id=?",
|
||||||
|
(1 if value else 0, event_id),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
# ── Monitor log ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def insert_monitor_log(
|
||||||
|
self,
|
||||||
|
entries: list[MonitorLogEntry],
|
||||||
|
*,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Insert monitor log entries. Silently skips duplicates (serial+waveform_key).
|
||||||
|
Returns (inserted, skipped).
|
||||||
|
"""
|
||||||
|
inserted = skipped = 0
|
||||||
|
with self._connect() as conn:
|
||||||
|
for e in entries:
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO monitor_log
|
||||||
|
(id, serial, waveform_key, session_id,
|
||||||
|
start_time, stop_time, duration_seconds,
|
||||||
|
geo_threshold_ips)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self._new_id(),
|
||||||
|
e.serial or "",
|
||||||
|
e.key,
|
||||||
|
session_id,
|
||||||
|
self._iso(e.start_time),
|
||||||
|
self._iso(e.stop_time),
|
||||||
|
e.duration_seconds,
|
||||||
|
e.geo_threshold_ips,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
log.debug("insert_monitor_log inserted=%d skipped=%d", inserted, skipped)
|
||||||
|
return inserted, skipped
|
||||||
|
|
||||||
|
def query_monitor_log(
|
||||||
|
self,
|
||||||
|
serial: Optional[str] = None,
|
||||||
|
from_dt: Optional[datetime.datetime] = None,
|
||||||
|
to_dt: Optional[datetime.datetime] = None,
|
||||||
|
limit: int = 500,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Query monitor log entries with optional filters. Returns newest first."""
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if serial:
|
||||||
|
clauses.append("serial = ?")
|
||||||
|
params.append(serial)
|
||||||
|
if from_dt:
|
||||||
|
clauses.append("start_time >= ?")
|
||||||
|
params.append(from_dt.isoformat())
|
||||||
|
if to_dt:
|
||||||
|
clauses.append("start_time <= ?")
|
||||||
|
params.append(to_dt.isoformat())
|
||||||
|
|
||||||
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
params += [limit, offset]
|
||||||
|
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM monitor_log {where} "
|
||||||
|
f"ORDER BY start_time DESC LIMIT ? OFFSET ?",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
# ── Fleet overview ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def query_units(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Return one row per known serial with summary stats:
|
||||||
|
last_seen, total_events, total_monitor_entries.
|
||||||
|
"""
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
s.serial,
|
||||||
|
MAX(s.session_time) AS last_seen,
|
||||||
|
SUM(s.events_downloaded) AS total_events,
|
||||||
|
SUM(s.monitor_entries) AS total_monitor_entries,
|
||||||
|
COUNT(*) AS total_sessions
|
||||||
|
FROM ach_sessions s
|
||||||
|
GROUP BY s.serial
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
+119
@@ -34,6 +34,7 @@ or:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -58,6 +59,7 @@ from minimateplus import MiniMateClient
|
|||||||
from minimateplus.protocol import ProtocolError
|
from minimateplus.protocol import ProtocolError
|
||||||
from minimateplus.models import ComplianceConfig, DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp
|
from minimateplus.models import ComplianceConfig, DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp
|
||||||
from minimateplus.transport import TcpTransport, DEFAULT_TCP_PORT
|
from minimateplus.transport import TcpTransport, DEFAULT_TCP_PORT
|
||||||
|
from sfm.database import SeismoDb
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
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 ────────────────────────────────────────────────────────────────
|
# ── Serialisers ────────────────────────────────────────────────────────────────
|
||||||
# Plain dict helpers — avoids a Pydantic dependency in the library layer.
|
# Plain dict helpers — avoids a Pydantic dependency in the library layer.
|
||||||
|
|
||||||
@@ -698,6 +715,108 @@ def device_monitor_stop(
|
|||||||
return {"status": "stopped"}
|
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 ────────────────────────────────────────────────────────────────
|
# ── Entry point ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user