From a7ab6eaf7c27788fba97010168707f1df9374cce Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Tue, 7 Apr 2026 17:26:24 -0400 Subject: [PATCH] feat: add config API endpoint and JSON schema draft --- docs/device_config_schema.json | 120 +++++++++++++ minimateplus/client.py | 317 ++++++++++++++++++++++++--------- sfm/server.py | 114 +++++++++--- 3 files changed, 438 insertions(+), 113 deletions(-) create mode 100644 docs/device_config_schema.json diff --git a/docs/device_config_schema.json b/docs/device_config_schema.json new file mode 100644 index 0000000..594957e --- /dev/null +++ b/docs/device_config_schema.json @@ -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" + } + ] +} diff --git a/minimateplus/client.py b/minimateplus/client.py index 82c9861..b41aeb9 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -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,84 +716,15 @@ 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 - ) - 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) + """Backwards-compat shim — delegates to apply_config().""" + self.apply_config( + project=project, + client_name=client_name, + operator=operator, + seis_loc=seis_loc, + notes=notes, ) - # 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 ────────────────────────────────────────────────────── def _require_proto(self) -> MiniMateProtocol: @@ -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), ) diff --git a/sfm/server.py b/sfm/server.py index 9bbae63..2ea6789 100644 --- a/sfm/server.py +++ b/sfm/server.py @@ -478,58 +478,98 @@ def device_event_waveform( # ── Write endpoints ─────────────────────────────────────────────────────────── -class ProjectInfoBody(BaseModel): - """Request body for POST /device/config/project.""" - project: Optional[str] = None - client_name: Optional[str] = None - operator: Optional[str] = None - seis_loc: Optional[str] = None - notes: Optional[str] = None +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 + seis_loc: Optional[str] = None + 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): { - "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, + "project": "Bridge Inspection 2026", + "client_name": "City of Portland", + "operator": "Brian Harrison", + "seis_loc": "South Abutment", + "notes": "Pre-blast baseline" } 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 ────────────────────────────────────────────────────────────────