feat: add config API endpoint and JSON schema draft
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://terra-mechanics.com/schemas/seismo-relay/device-config/v1",
|
||||
"title": "MiniMate Plus Device Config",
|
||||
"description": "Writable configuration fields for an Instantel MiniMate Plus seismograph, as exposed by the seismo-relay SFM API (POST /device/config). All fields are optional — only supplied fields are written; all others are round-tripped from the device.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
"properties": {
|
||||
|
||||
"sample_rate": {
|
||||
"title": "Sample Rate",
|
||||
"description": "ADC sample rate in samples per second. Must be one of the three supported rates.",
|
||||
"type": "integer",
|
||||
"enum": [1024, 2048, 4096],
|
||||
"examples": [1024]
|
||||
},
|
||||
|
||||
"record_time": {
|
||||
"title": "Record Time",
|
||||
"description": "Waveform record duration in seconds. Typical values are 1.0–15.0 s. The device stores this as a 32-bit IEEE 754 float.",
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 60.0,
|
||||
"examples": [3.0]
|
||||
},
|
||||
|
||||
"trigger_level_geo": {
|
||||
"title": "Trigger Level (Geo)",
|
||||
"description": "Geophone trigger threshold in in/s. Event recording begins when any geo channel exceeds this level.",
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"examples": [0.5]
|
||||
},
|
||||
|
||||
"alarm_level_geo": {
|
||||
"title": "Alarm Level (Geo)",
|
||||
"description": "Geophone alarm threshold in in/s. An alarm is flagged when any geo channel exceeds this level.",
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"examples": [1.0]
|
||||
},
|
||||
|
||||
"max_range_geo": {
|
||||
"title": "Max Range (Geo)",
|
||||
"description": "Full-scale calibration constant for geo channels in in/s. This is a factory-calibrated value — only modify if you have a calibration certificate. Default for MiniMate Plus is approximately 6.206 in/s.",
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"examples": [6.206]
|
||||
},
|
||||
|
||||
"project": {
|
||||
"title": "Project",
|
||||
"description": "Project name or description. Stored in the compliance config block and echoed on event reports. Max 41 ASCII characters.",
|
||||
"type": "string",
|
||||
"maxLength": 41,
|
||||
"examples": ["Bridge Inspection 2026"]
|
||||
},
|
||||
|
||||
"client_name": {
|
||||
"title": "Client",
|
||||
"description": "Client or company name. Max 41 ASCII characters.",
|
||||
"type": "string",
|
||||
"maxLength": 41,
|
||||
"examples": ["City of Portland"]
|
||||
},
|
||||
|
||||
"operator": {
|
||||
"title": "Operator",
|
||||
"description": "Operator or technician name. Stored as 'User Name:' in the device. Max 41 ASCII characters.",
|
||||
"type": "string",
|
||||
"maxLength": 41,
|
||||
"examples": ["Brian Harrison"]
|
||||
},
|
||||
|
||||
"seis_loc": {
|
||||
"title": "Sensor Location",
|
||||
"description": "Sensor location description. Stored as 'Seis Loc:' in the device. Max 41 ASCII characters.",
|
||||
"type": "string",
|
||||
"maxLength": 41,
|
||||
"examples": ["South Abutment — 3 m from blast"]
|
||||
},
|
||||
|
||||
"notes": {
|
||||
"title": "Extended Notes",
|
||||
"description": "Free-form notes. Stored as 'Extended Notes' in the device. Max 41 ASCII characters.",
|
||||
"type": "string",
|
||||
"maxLength": 41,
|
||||
"examples": ["Pre-blast baseline, no charges"]
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
"examples": [
|
||||
{
|
||||
"project": "Bridge Inspection 2026",
|
||||
"client_name": "City of Portland",
|
||||
"operator": "Brian Harrison",
|
||||
"seis_loc": "South Abutment",
|
||||
"notes": "Pre-blast baseline"
|
||||
},
|
||||
{
|
||||
"sample_rate": 1024,
|
||||
"record_time": 3.0,
|
||||
"trigger_level_geo": 0.5,
|
||||
"alarm_level_geo": 1.0
|
||||
},
|
||||
{
|
||||
"sample_rate": 2048,
|
||||
"record_time": 5.0,
|
||||
"trigger_level_geo": 0.25,
|
||||
"alarm_level_geo": 0.75,
|
||||
"project": "Quarry Blast Monitoring",
|
||||
"client_name": "Acme Quarry LLC",
|
||||
"operator": "Brian Harrison",
|
||||
"seis_loc": "Nearest Structure — East Wall",
|
||||
"notes": "Production blast series B"
|
||||
}
|
||||
]
|
||||
}
|
||||
+233
-84
@@ -617,6 +617,97 @@ class MiniMateClient:
|
||||
|
||||
log.info("push_config_raw: complete")
|
||||
|
||||
def apply_config(
|
||||
self,
|
||||
*,
|
||||
# Recording parameters
|
||||
sample_rate: Optional[int] = None,
|
||||
record_time: Optional[float] = None,
|
||||
# Threshold parameters (geo channels, in/s)
|
||||
trigger_level_geo: Optional[float] = None,
|
||||
alarm_level_geo: Optional[float] = None,
|
||||
max_range_geo: Optional[float] = None,
|
||||
# Project / operator strings
|
||||
project: Optional[str] = None,
|
||||
client_name: Optional[str] = None,
|
||||
operator: Optional[str] = None,
|
||||
seis_loc: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Read the current device config, apply any supplied changes to the
|
||||
compliance block, and write the full config back to the device.
|
||||
|
||||
Only non-None arguments are modified; all other bytes are round-tripped
|
||||
verbatim from the device.
|
||||
|
||||
Configurable fields
|
||||
-------------------
|
||||
Recording parameters:
|
||||
sample_rate : int — samples/sec; valid values: 1024, 2048, 4096
|
||||
record_time : float — record duration in seconds (e.g. 2.0, 3.0)
|
||||
|
||||
Trigger/alarm thresholds (geo channels, in/s):
|
||||
trigger_level_geo : float — trigger threshold (e.g. 0.5)
|
||||
alarm_level_geo : float — alarm threshold (e.g. 1.0)
|
||||
max_range_geo : float — full-scale calibration constant (e.g. 6.206)
|
||||
rarely changed — only set if you know what you're doing
|
||||
|
||||
Project / operator strings (max 41 ASCII characters each):
|
||||
project : str
|
||||
client_name : str
|
||||
operator : str
|
||||
seis_loc : str — sensor location
|
||||
notes : str — extended notes
|
||||
|
||||
Write sequence (confirmed from 3-11-26 BW TX capture):
|
||||
68→73 | 71×3→72 | 82→83 | 69→74→72
|
||||
|
||||
Write payloads:
|
||||
event_index_data : 88 bytes — read live via SUB 08
|
||||
compliance_data : 2128 bytes — read live via SUB 1A (2126 bytes) + \\x00\\x00 footer
|
||||
trigger_data : 29 bytes — hardcoded from 3-11-26 capture
|
||||
waveform_data : 204 bytes — read live via SUB 09
|
||||
|
||||
Raises:
|
||||
RuntimeError: if not connected.
|
||||
ProtocolError: if any read or write step fails.
|
||||
ValueError: if compliance buffer is not the expected 2126 bytes.
|
||||
"""
|
||||
proto = self._require_proto()
|
||||
|
||||
# 1. Read current payloads from the device
|
||||
log.info("apply_config: reading event index (SUB 08)")
|
||||
event_index_data = proto.read_event_index()
|
||||
|
||||
log.info("apply_config: reading compliance config (SUB 1A)")
|
||||
compliance_raw = proto.read_compliance_config() # 2126 bytes
|
||||
|
||||
log.info("apply_config: reading waveform data (SUB 09)")
|
||||
waveform_data = proto.read_waveform_data_raw() # 204 bytes
|
||||
|
||||
trigger_data = _TRIGGER_DATA_HARDCODED # 29 bytes
|
||||
|
||||
# 2. Patch the compliance buffer and build the 2128-byte write payload
|
||||
compliance_data = _encode_compliance_config(
|
||||
compliance_raw,
|
||||
sample_rate=sample_rate,
|
||||
record_time=record_time,
|
||||
trigger_level_geo=trigger_level_geo,
|
||||
alarm_level_geo=alarm_level_geo,
|
||||
max_range_geo=max_range_geo,
|
||||
project=project,
|
||||
client_name=client_name,
|
||||
operator=operator,
|
||||
seis_loc=seis_loc,
|
||||
notes=notes,
|
||||
)
|
||||
log.info("apply_config: compliance payload ready (%d bytes)", len(compliance_data))
|
||||
|
||||
# 3. Push the full write sequence to the device
|
||||
self.push_config_raw(event_index_data, compliance_data, trigger_data, waveform_data)
|
||||
log.info("apply_config: complete")
|
||||
|
||||
def set_project_info(
|
||||
self,
|
||||
project: Optional[str] = None,
|
||||
@@ -625,83 +716,14 @@ class MiniMateClient:
|
||||
seis_loc: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
POC: Read the current config from the device, patch ASCII project fields
|
||||
in the compliance block, and write the modified config back.
|
||||
|
||||
Only fields passed as non-None are modified. All other bytes are
|
||||
round-tripped verbatim.
|
||||
|
||||
Label slot format confirmed from 3-11-26 BW TX capture:
|
||||
- Compliance buffer is 2126 bytes (three E5 data frames concatenated).
|
||||
- Each label ("Project:", "Client:", etc.) anchors a 64-byte slot.
|
||||
- ASCII value starts at slot_start + 22, max 42 bytes, null-padded.
|
||||
|
||||
Known labels and their confirmed positions in the 2126-byte buffer:
|
||||
b"Project:" → value at 125 + 22 = 147
|
||||
b"Client:" → value at 189 + 22 = 211
|
||||
b"User Name:" → value at 253 + 22 = 275
|
||||
b"Seis Loc:" → value at 317 + 22 = 339
|
||||
b"Extended Notes" → value at 381 + 22 = 403
|
||||
|
||||
Write payloads:
|
||||
event_index_data : 88 bytes — read live from device via SUB 08
|
||||
compliance_data : 2128 bytes — 2126 read bytes + 2-byte footer \\x00\\x00
|
||||
(checksum formula unknown for POC; device may ignore it)
|
||||
trigger_data : 29 bytes — hardcoded from 3-11-26 capture
|
||||
waveform_data : 204 bytes — read live from device via SUB 09
|
||||
|
||||
Raises:
|
||||
RuntimeError: if not connected.
|
||||
ProtocolError: if any read or write step fails.
|
||||
"""
|
||||
proto = self._require_proto()
|
||||
|
||||
# 1. Read current payloads from the device
|
||||
log.info("set_project_info: reading event index (SUB 08)")
|
||||
event_index_data = proto.read_event_index() # 88 bytes
|
||||
|
||||
log.info("set_project_info: reading compliance config (SUB 1A)")
|
||||
compliance_raw = bytearray(proto.read_compliance_config()) # 2126 bytes
|
||||
|
||||
log.info("set_project_info: reading waveform data (SUB 09)")
|
||||
waveform_data = proto.read_waveform_data_raw() # 204 bytes
|
||||
|
||||
trigger_data = _TRIGGER_DATA_HARDCODED # 29 bytes
|
||||
|
||||
# 2. Patch ASCII fields inside the compliance buffer
|
||||
|
||||
def _set_field(label: bytes, value: Optional[str]) -> None:
|
||||
if value is None:
|
||||
return
|
||||
idx = compliance_raw.find(label)
|
||||
if idx < 0:
|
||||
log.warning(
|
||||
"set_project_info: label %r not found in compliance data", label
|
||||
"""Backwards-compat shim — delegates to apply_config()."""
|
||||
self.apply_config(
|
||||
project=project,
|
||||
client_name=client_name,
|
||||
operator=operator,
|
||||
seis_loc=seis_loc,
|
||||
notes=notes,
|
||||
)
|
||||
return
|
||||
val_bytes = value.encode("ascii", errors="replace")[: _COMPLIANCE_VALUE_MAX - 1]
|
||||
padded = val_bytes + b"\x00" * (_COMPLIANCE_VALUE_MAX - len(val_bytes))
|
||||
compliance_raw[idx + _COMPLIANCE_VALUE_OFFSET : idx + _COMPLIANCE_SLOT_SIZE] = padded
|
||||
log.debug(
|
||||
"set_project_info: set %r → %r (at offset %d)", label, value, idx
|
||||
)
|
||||
|
||||
_set_field(b"Project:", project)
|
||||
_set_field(b"Client:", client_name)
|
||||
_set_field(b"User Name:", operator)
|
||||
_set_field(b"Seis Loc:", seis_loc)
|
||||
_set_field(b"Extended Notes", notes)
|
||||
|
||||
# 3. Append 2-byte footer (checksum formula unknown; \x00\x00 for POC)
|
||||
compliance_data = bytes(compliance_raw) + b"\x00\x00" # 2128 bytes
|
||||
log.info(
|
||||
"set_project_info: compliance payload ready (%d bytes)", len(compliance_data)
|
||||
)
|
||||
|
||||
# 4. Push the full write sequence to the device
|
||||
self.push_config_raw(event_index_data, compliance_data, trigger_data, waveform_data)
|
||||
log.info("set_project_info: complete")
|
||||
|
||||
# ── Internal helpers ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -1336,6 +1358,128 @@ def _extract_project_strings(data: bytes) -> Optional[ProjectInfo]:
|
||||
)
|
||||
|
||||
|
||||
def _encode_compliance_config(
|
||||
raw: bytes,
|
||||
*,
|
||||
sample_rate: Optional[int] = None,
|
||||
record_time: Optional[float] = None,
|
||||
trigger_level_geo: Optional[float] = None,
|
||||
alarm_level_geo: Optional[float] = None,
|
||||
max_range_geo: Optional[float] = None,
|
||||
project: Optional[str] = None,
|
||||
client_name: Optional[str] = None,
|
||||
operator: Optional[str] = None,
|
||||
seis_loc: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Patch a live 2126-byte compliance buffer (read from the device) with any
|
||||
supplied field values and return the 2128-byte write payload.
|
||||
|
||||
Only non-None arguments are modified; everything else is round-tripped verbatim.
|
||||
|
||||
Numeric field locations (all anchor-relative or label-relative — immune to
|
||||
DLE-jitter shifts):
|
||||
|
||||
Anchor: b'\\xbe\\x80\\x00\\x00\\x00\\x00' (confirmed stable, both BE11529 and BE18189)
|
||||
sample_rate → uint16 BE at anchor_pos - 6
|
||||
record_time → float32 BE at anchor_pos + 6
|
||||
|
||||
Channel block (anchored on b"Tran" with unit-string guard):
|
||||
max_range_geo → float32 BE at tran_pos + 28
|
||||
trigger_level_geo → float32 BE at tran_pos + 34
|
||||
alarm_level_geo → float32 BE at tran_pos + 42
|
||||
|
||||
String field locations (64-byte slots, label+22 format):
|
||||
b"Project:" → value at label_pos + 22, max 41 chars + null
|
||||
b"Client:" → value at label_pos + 22, max 41 chars + null
|
||||
b"User Name:" → value at label_pos + 22, max 41 chars + null
|
||||
b"Seis Loc:" → value at label_pos + 22, max 41 chars + null
|
||||
b"Extended Notes"→ value at label_pos + 22, max 41 chars + null
|
||||
|
||||
Returns:
|
||||
2128-byte write payload: patched 2126-byte buffer + 2-byte footer \\x00\\x00.
|
||||
(Checksum formula for the footer is unknown; \\x00\\x00 confirmed accepted
|
||||
by the device in POC test 2026-04-07.)
|
||||
|
||||
Raises:
|
||||
ValueError: if raw is not exactly 2126 bytes.
|
||||
"""
|
||||
if len(raw) != 2126:
|
||||
raise ValueError(f"_encode_compliance_config: expected 2126 bytes, got {len(raw)}")
|
||||
|
||||
buf = bytearray(raw)
|
||||
|
||||
# ── Numeric: sample_rate + record_time (anchor-relative) ─────────────────
|
||||
_ANC = b'\xbe\x80\x00\x00\x00\x00'
|
||||
_anc = buf.find(_ANC, 0, 150)
|
||||
|
||||
if sample_rate is not None:
|
||||
if _anc < 6:
|
||||
log.warning("_encode_compliance_config: anchor not found — cannot write sample_rate")
|
||||
else:
|
||||
struct.pack_into(">H", buf, _anc - 6, sample_rate)
|
||||
log.debug("_encode_compliance_config: sample_rate=%d → offset %d", sample_rate, _anc - 6)
|
||||
|
||||
if record_time is not None:
|
||||
if _anc < 0 or _anc + 10 > len(buf):
|
||||
log.warning("_encode_compliance_config: anchor not found — cannot write record_time")
|
||||
else:
|
||||
struct.pack_into(">f", buf, _anc + 6, record_time)
|
||||
log.debug("_encode_compliance_config: record_time=%.3f → offset %d", record_time, _anc + 6)
|
||||
|
||||
# ── Numeric: channel block (Tran label + unit-string guard) ───────────────
|
||||
_needs_channel = any(
|
||||
v is not None for v in (trigger_level_geo, alarm_level_geo, max_range_geo)
|
||||
)
|
||||
if _needs_channel:
|
||||
_tran = buf.find(b"Tran", 44)
|
||||
_valid = (
|
||||
_tran >= 0
|
||||
and buf[_tran + 4 : _tran + 5] != b"2"
|
||||
and _tran + 50 <= len(buf)
|
||||
and buf[_tran + 38 : _tran + 42] == b"in.\x00"
|
||||
and buf[_tran + 46 : _tran + 50] == b"/s\x00\x00"
|
||||
)
|
||||
if not _valid:
|
||||
log.warning(
|
||||
"_encode_compliance_config: 'Tran' channel block not found or unit "
|
||||
"guard failed — trigger/alarm/max_range will not be written"
|
||||
)
|
||||
else:
|
||||
if max_range_geo is not None:
|
||||
struct.pack_into(">f", buf, _tran + 28, max_range_geo)
|
||||
log.debug("_encode_compliance_config: max_range_geo=%.4f → offset %d", max_range_geo, _tran + 28)
|
||||
if trigger_level_geo is not None:
|
||||
struct.pack_into(">f", buf, _tran + 34, trigger_level_geo)
|
||||
log.debug("_encode_compliance_config: trigger_level_geo=%.4f → offset %d", trigger_level_geo, _tran + 34)
|
||||
if alarm_level_geo is not None:
|
||||
struct.pack_into(">f", buf, _tran + 42, alarm_level_geo)
|
||||
log.debug("_encode_compliance_config: alarm_level_geo=%.4f → offset %d", alarm_level_geo, _tran + 42)
|
||||
|
||||
# ── ASCII strings (64-byte slot, value at label_pos+22) ───────────────────
|
||||
def _set_string(label: bytes, value: Optional[str]) -> None:
|
||||
if value is None:
|
||||
return
|
||||
idx = buf.find(label)
|
||||
if idx < 0:
|
||||
log.warning("_encode_compliance_config: label %r not found", label)
|
||||
return
|
||||
val_bytes = value.encode("ascii", errors="replace")[:_COMPLIANCE_VALUE_MAX - 1]
|
||||
padded = val_bytes + b"\x00" * (_COMPLIANCE_VALUE_MAX - len(val_bytes))
|
||||
buf[idx + _COMPLIANCE_VALUE_OFFSET : idx + _COMPLIANCE_SLOT_SIZE] = padded
|
||||
log.debug("_encode_compliance_config: %r → %r", label, value)
|
||||
|
||||
_set_string(b"Project:", project)
|
||||
_set_string(b"Client:", client_name)
|
||||
_set_string(b"User Name:", operator)
|
||||
_set_string(b"Seis Loc:", seis_loc)
|
||||
_set_string(b"Extended Notes", notes)
|
||||
|
||||
# 2-byte footer — checksum formula unknown; \x00\x00 confirmed accepted by device
|
||||
return bytes(buf) + b"\x00\x00"
|
||||
|
||||
|
||||
def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
|
||||
"""
|
||||
Decode a 2090-byte SUB 1A (COMPLIANCE_CONFIG) response into a ComplianceConfig.
|
||||
@@ -1345,9 +1489,9 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
|
||||
|
||||
Confirmed field locations (BE11529 with 3-step read, duplicate detection):
|
||||
- cfg[89] = setup_name: first long ASCII string in cfg[40:250] ✅
|
||||
- ANCHOR = b'\\x01\\x2c\\x00\\x00\\xbe\\x80\\x00\\x00\\x00\\x00' in cfg[40:100] ✅
|
||||
- anchor - 2 = sample_rate uint16_BE (1024 normal / 2048 fast / 4096 faster)
|
||||
- anchor + 10 = record_time float32_BE
|
||||
- ANCHOR = b'\\xbe\\x80\\x00\\x00\\x00\\x00' in cfg[0:150] ✅ (revised 2026-04-07)
|
||||
- anchor - 6 = sample_rate uint16_BE (1024 normal / 2048 fast / 4096 faster)
|
||||
- anchor + 6 = record_time float32_BE
|
||||
- "Project:" needle → project string
|
||||
- "Client:" needle → client string
|
||||
- "User Name:" needle → operator string
|
||||
@@ -1397,18 +1541,23 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
|
||||
# making frame C 1 byte shorter than for 1024/2048 and shifting everything.
|
||||
# sample_rate: uint16_BE at anchor - 2
|
||||
# record_time: float32_BE at anchor + 10
|
||||
_ANCHOR = b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00'
|
||||
# 6-byte suffix anchor — confirmed stable across BE11529 and bench unit (BE18189).
|
||||
# The preceding 4 bytes (old anchor prefix 01 2c / 00 3c) vary by unit config;
|
||||
# only be 80 00 00 00 00 is constant.
|
||||
# sample_rate : uint16 BE at anchor_pos - 6
|
||||
# record_time : float32 BE at anchor_pos + 6
|
||||
_ANCHOR = b'\xbe\x80\x00\x00\x00\x00'
|
||||
_anchor = data.find(_ANCHOR, 0, 150)
|
||||
if _anchor >= 2 and _anchor + 14 <= len(data):
|
||||
if _anchor >= 6 and _anchor + 10 <= len(data):
|
||||
try:
|
||||
config.sample_rate = struct.unpack_from(">H", data, _anchor - 2)[0]
|
||||
config.sample_rate = struct.unpack_from(">H", data, _anchor - 6)[0]
|
||||
log.debug(
|
||||
"compliance_config: sample_rate = %d Sa/s (anchor@%d)", config.sample_rate, _anchor
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("compliance_config: sample_rate extraction failed: %s", exc)
|
||||
try:
|
||||
config.record_time = struct.unpack_from(">f", data, _anchor + 10)[0]
|
||||
config.record_time = struct.unpack_from(">f", data, _anchor + 6)[0]
|
||||
log.debug(
|
||||
"compliance_config: record_time = %.3f s (anchor@%d)", config.record_time, _anchor
|
||||
)
|
||||
@@ -1416,7 +1565,7 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
|
||||
log.warning("compliance_config: record_time extraction failed: %s", exc)
|
||||
else:
|
||||
log.warning(
|
||||
"compliance_config: anchor %s not found in cfg[40:100] (len=%d) "
|
||||
"compliance_config: anchor %s not found in cfg[0:150] (len=%d) "
|
||||
"— sample_rate and record_time will be None",
|
||||
_ANCHOR.hex(), len(data),
|
||||
)
|
||||
|
||||
+75
-19
@@ -478,8 +478,41 @@ def device_event_waveform(
|
||||
|
||||
# ── Write endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
class ProjectInfoBody(BaseModel):
|
||||
"""Request body for POST /device/config/project."""
|
||||
class DeviceConfigBody(BaseModel):
|
||||
"""
|
||||
Request body for POST /device/config.
|
||||
|
||||
All fields are optional — only supplied (non-null) fields are written to
|
||||
the device. All other config bytes are round-tripped verbatim.
|
||||
|
||||
Recording parameters
|
||||
--------------------
|
||||
sample_rate : Samples per second. Valid values: 1024, 2048, 4096.
|
||||
record_time : Record duration in seconds (e.g. 1.0, 2.0, 3.0).
|
||||
|
||||
Trigger / alarm thresholds (geo channels, in/s)
|
||||
------------------------------------------------
|
||||
trigger_level_geo : Trigger threshold in in/s (e.g. 0.5).
|
||||
alarm_level_geo : Alarm threshold in in/s (e.g. 1.0).
|
||||
max_range_geo : Full-scale calibration constant (e.g. 6.206).
|
||||
Rarely changed — only set if you know what you're doing.
|
||||
|
||||
Project / operator strings (max 41 ASCII characters each)
|
||||
----------------------------
|
||||
project : Project description.
|
||||
client_name : Client / company name.
|
||||
operator : Operator / technician name.
|
||||
seis_loc : Sensor location description.
|
||||
notes : Extended notes.
|
||||
"""
|
||||
# Recording parameters
|
||||
sample_rate: Optional[int] = None
|
||||
record_time: Optional[float] = None
|
||||
# Threshold parameters
|
||||
trigger_level_geo: Optional[float] = None
|
||||
alarm_level_geo: Optional[float] = None
|
||||
max_range_geo: Optional[float] = None
|
||||
# Project / operator strings
|
||||
project: Optional[str] = None
|
||||
client_name: Optional[str] = None
|
||||
operator: Optional[str] = None
|
||||
@@ -487,25 +520,29 @@ class ProjectInfoBody(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/device/config/project")
|
||||
def device_config_project(
|
||||
body: ProjectInfoBody,
|
||||
@app.post("/device/config")
|
||||
def device_config(
|
||||
body: DeviceConfigBody,
|
||||
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:
|
||||
"""
|
||||
POC: Read the current device config, patch ASCII project/client/operator/
|
||||
sensor-location/notes fields, and write the modified config back.
|
||||
Read the current device config, apply any supplied changes to the compliance
|
||||
block, and write the full config back.
|
||||
|
||||
Only fields included in the JSON body (non-null) are modified. All other
|
||||
bytes are round-tripped verbatim from the device.
|
||||
Only non-null fields in the JSON body are modified. All other config bytes
|
||||
are round-tripped verbatim from the device.
|
||||
|
||||
Supply either *port* (serial) or *host* (TCP/modem).
|
||||
|
||||
Example body:
|
||||
Example body (all fields optional — include only what you want to change):
|
||||
{
|
||||
"sample_rate": 1024,
|
||||
"record_time": 3.0,
|
||||
"trigger_level_geo": 0.5,
|
||||
"alarm_level_geo": 1.0,
|
||||
"project": "Bridge Inspection 2026",
|
||||
"client_name": "City of Portland",
|
||||
"operator": "Brian Harrison",
|
||||
@@ -514,22 +551,25 @@ def device_config_project(
|
||||
}
|
||||
|
||||
Returns:
|
||||
{"status": "ok", "message": "..."} on success.
|
||||
{"status": "ok", "updated_fields": {...}} on success.
|
||||
|
||||
Raises:
|
||||
502 on protocol errors (timeout, bad ack, etc.).
|
||||
422 if neither port nor host is provided.
|
||||
"""
|
||||
log.info(
|
||||
"POST /device/config/project port=%s host=%s body=%s",
|
||||
port, host, body.model_dump(exclude_none=True),
|
||||
)
|
||||
changed = body.model_dump(exclude_none=True)
|
||||
log.info("POST /device/config port=%s host=%s fields=%s", port, host, list(changed.keys()))
|
||||
|
||||
try:
|
||||
def _do():
|
||||
with _build_client(port, baud, host, tcp_port) as client:
|
||||
client.connect()
|
||||
client.set_project_info(
|
||||
client.apply_config(
|
||||
sample_rate=body.sample_rate,
|
||||
record_time=body.record_time,
|
||||
trigger_level_geo=body.trigger_level_geo,
|
||||
alarm_level_geo=body.alarm_level_geo,
|
||||
max_range_geo=body.max_range_geo,
|
||||
project=body.project,
|
||||
client_name=body.client_name,
|
||||
operator=body.operator,
|
||||
@@ -543,12 +583,28 @@ def device_config_project(
|
||||
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 ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
|
||||
|
||||
changed = body.model_dump(exclude_none=True)
|
||||
msg = "Config written. Fields updated: " + (", ".join(changed.keys()) or "none")
|
||||
return {"status": "ok", "message": msg, "updated_fields": changed}
|
||||
return {
|
||||
"status": "ok",
|
||||
"updated_fields": changed,
|
||||
}
|
||||
|
||||
|
||||
# Keep the old endpoint alive under its old URL for anything already calling it
|
||||
@app.post("/device/config/project")
|
||||
def device_config_project(
|
||||
body: DeviceConfigBody,
|
||||
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:
|
||||
"""Deprecated alias for POST /device/config — use that instead."""
|
||||
return device_config(body=body, port=port, baud=baud, host=host, tcp_port=tcp_port)
|
||||
|
||||
|
||||
# ── Entry point ────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user