diff --git a/CLAUDE.md b/CLAUDE.md index b11efab..20aa160 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ S3→BW (device replies): [DLE=0x10] [STX=0x02] [stuffed payload+chk] [bare ETX= using `DLE+ETX` as inner terminators. The outer parser treats `DLE+ETX` inside a frame as literal data — the bare ETX is the ONLY real frame terminator. - **Response SUB rule:** `response_SUB = 0xFF - request_SUB` - (one known exception: SUB `1C` → response `6E`, not `0xE3`) + (no known exceptions — earlier note claiming `1C` → `6E` was WRONG; `1C` → `0xE3` confirmed across 338 frames in 4-8-26 captures) - **Two-step read pattern:** every read command is sent twice — probe step (`offset=0x00`, get length) then data step (`offset=DATA_LENGTH`, get payload). All data lengths are hardcoded constants, not read from the probe response. @@ -413,10 +413,13 @@ for 0x10 records). ## SFM REST API (sfm/server.py) ``` -GET /device/info?port=COM5 ← serial -GET /device/info?host=1.2.3.4&tcp_port=9034 ← cellular -GET /device/events?host=1.2.3.4&tcp_port=9034&baud=38400 -GET /device/event?host=1.2.3.4&tcp_port=9034&index=0 +GET /device/info?port=COM5 ← serial +GET /device/info?host=1.2.3.4&tcp_port=9034 ← cellular +GET /device/events?host=1.2.3.4&tcp_port=9034&baud=38400 +GET /device/event?host=1.2.3.4&tcp_port=9034&index=0 +GET /device/monitor/status?host=1.2.3.4&tcp_port=9034 ← battery, memory, mode +POST /device/monitor/start?host=1.2.3.4&tcp_port=9034 ← start recording +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). @@ -570,6 +573,51 @@ a `ComplianceConfig` object is a future task. --- +## Monitoring commands (SUBs 0x1C, 0x96, 0x97) — confirmed 2026-04-08 + +All confirmed from 4-8-26/2ndtry BW TX/S3 capture (clean start → 30s monitor → stop cycle). + +### SUB 0x1C — Monitor status read + +Standard two-step read (probe at offset 0x00, data at offset 0x2C). +Response SUB = 0xFF − 0x1C = **0xE3** (standard formula — no exception). + +Payload length indicates mode: +- **44 bytes (0x2C)** — unit is **idle**: full status block with battery + memory +- **12 bytes** — unit is **monitoring**: abbreviated block, no battery/memory fields + +**Field offsets (idle payload, relative to the 11-byte section header start):** + +| Offset | Field | Type | Notes | +|---|---|---|---| +| `[0x2F:0x31]` | battery voltage × 100 | uint16 BE | `0x02A8` = 680 → 6.80 V | +| `[0x31:0x35]` | memory total (bytes) | uint32 BE | e.g. 983040 = 960 KB | +| `[0x35:0x39]` | memory free (bytes) | uint32 BE | | + +### SUB 0x96 — Start monitoring + +Single write frame, **no data payload** (empty body). +Response SUB = 0xFF − 0x96 = **0x69**. + +Wire bytes (confirmed frame 92 of 2ndtry BW capture): +``` +41 02 10 10 00 96 00 00 00 00 00 00 00 00 00 00 00 00 00 a6 03 +``` + +### SUB 0x97 — Stop monitoring + +Single write frame, **no data payload** (empty body). +Response SUB = 0xFF − 0x97 = **0x68**. + +Wire bytes (confirmed frame 305 of 2ndtry BW capture): +``` +41 02 10 10 00 97 00 00 00 00 00 00 00 00 00 00 00 00 00 a7 03 +``` + +Both start and stop acks are standard 17-byte zero-data S3 frames. + +--- + ## What's next - Compliance config encoder — build raw write payloads from a `ComplianceConfig` object diff --git a/docs/parse_0x1c_response.py b/docs/parse_0x1c_response.py new file mode 100644 index 0000000..da9e7c3 --- /dev/null +++ b/docs/parse_0x1c_response.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Parse SUB 0x1C (monitoring status) response frames. + +SUB 0x1C returns device monitoring status with different payload sizes depending on state: + - IDLE (not monitoring): 58 bytes with full details + - MONITORING (actively streaming): 12 bytes condensed format +""" + +import struct +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class MonitoringStatus: + """Parsed SUB 0x1C response fields.""" + + monitor_mode: int # 0x2c = OFF, 0x00 = ON + day: int # 1–31 + hour: int # 0–23 + month: int # 1–12 + year: int # 2000–2100 + minute: int # 0–59 (uncertain encoding) + second: int # 0–59 (uncertain encoding) + battery_voltage_v: float # Volts (6–8V typical) + memory_total_kb: float # Kilobytes + memory_free_kb: float # Kilobytes + raw_payload: bytes + + def __str__(self) -> str: + mode_str = "OFF" if self.monitor_mode == 0x2c else "ON" + date_str = f"{self.year:04d}-{self.month:02d}-{self.day:02d}" + time_str = f"{self.hour:02d}:{self.minute:02d}:{self.second:02d}" + return ( + f"MonitoringStatus(\n" + f" mode={mode_str} (0x{self.monitor_mode:02x})\n" + f" datetime={date_str} {time_str}\n" + f" battery={self.battery_voltage_v:.2f}V\n" + f" memory=total {self.memory_total_kb:.1f} KB, " + f"free {self.memory_free_kb:.1f} KB\n" + f")" + ) + + +def parse_0x1c_response(data: bytes) -> Optional[MonitoringStatus]: + """ + Parse a SUB 0x1C response payload (after S3 header removed). + + Args: + data: Destuffed payload bytes (without the 5-byte S3 header) + + Returns: + MonitoringStatus object, or None if parse fails + """ + + if len(data) < 39: + # Minimum size for idle response + print(f"[!] Payload too short: {len(data)} bytes (need >=39)") + return None + + try: + monitor_mode = data[0x00] + + day = data[0x0d] + hour = data[0x0e] + month = data[0x0f] + year = struct.unpack('>H', data[0x10:0x12])[0] + minute = data[0x12] + second = data[0x13] + + # Battery voltage: uint16 BE, divide by 100 + # At offset [2f:31] + voltage_raw = struct.unpack('>H', data[0x2f:0x31])[0] + battery_voltage_v = voltage_raw / 100.0 + + # Memory total: uint32 BE, in bytes + # At offset [31:35] + memory_total_bytes = struct.unpack('>I', data[0x31:0x35])[0] + memory_total_kb = memory_total_bytes / 1024.0 + + # Memory free: uint32 BE, in bytes + # At offset [35:39] + memory_free_bytes = struct.unpack('>I', data[0x35:0x39])[0] + memory_free_kb = memory_free_bytes / 1024.0 + + return MonitoringStatus( + monitor_mode=monitor_mode, + day=day, + hour=hour, + month=month, + year=year, + minute=minute, + second=second, + battery_voltage_v=battery_voltage_v, + memory_total_kb=memory_total_kb, + memory_free_kb=memory_free_kb, + raw_payload=data + ) + + except (struct.error, IndexError) as e: + print(f"[!] Parse error: {e}") + return None + + +def hex_dump(data: bytes, offset: int = 0) -> str: + """Pretty-print hex dump of binary data.""" + lines = [] + for i in range(0, len(data), 16): + chunk = data[i:i+16] + hex_str = ' '.join(f'{b:02x}' for b in chunk) + ascii_str = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk) + lines.append(f" {offset+i:04x}: {hex_str:<48} {ascii_str}") + return '\n'.join(lines) + + +if __name__ == '__main__': + import sys + + if len(sys.argv) < 2: + print("Usage: parse_0x1c_response.py ") + print() + print("Example (hex string):") + print(" python3 parse_0x1c_response.py 2c00000000000000000000000008100407ea00013b2d...") + print() + print("Example (from capture file, idle frame):") + print(" Idle response (58 bytes):") + idle_hex = ( + "2c00000000000000000000000008100407ea00013b2d000000000000" + "010107cb00060000010107cb0015000000001002a8000efff2000e9e52ef" + ) + status = parse_0x1c_response(bytes.fromhex(idle_hex)) + print(hex_dump(bytes.fromhex(idle_hex))) + print() + if status: + print(status) + + sys.exit(0) + + # Parse input + input_str = sys.argv[1] + + try: + payload = bytes.fromhex(input_str) + except ValueError: + print(f"[!] Invalid hex string: {input_str}") + sys.exit(1) + + print(f"Parsing {len(payload)} bytes:") + print(hex_dump(payload)) + print() + + status = parse_0x1c_response(payload) + if status: + print(status) + else: + print("[!] Failed to parse") + sys.exit(1) diff --git a/docs/sub_0x1c_analysis.md b/docs/sub_0x1c_analysis.md new file mode 100644 index 0000000..0f44f1b --- /dev/null +++ b/docs/sub_0x1c_analysis.md @@ -0,0 +1,274 @@ +# SUB 0x1C — Monitoring Status Response Format + +**Capture file:** `/sessions/intelligent-nice-wright/mnt/seismo-relay/bridges/captures/4-8-26/2ndtry/raw_s3_20260408_015927.bin` + +**Analysis date:** 2026-04-08 + +--- + +## Overview + +SUB 0x1C is a monitoring status query that returns different sized responses depending on device state: + +- **IDLE/OFF (unit not monitoring):** 58-byte response with detailed fields +- **MONITORING/ON (unit actively monitoring):** 12-byte response with condensed format + +The key fields CONFIRMED from wire capture analysis: + +| Field | Offset | Format | Value (Idle) | Notes | +|-------|--------|--------|-------------|-------| +| **Monitor Mode** | [00] | uint8 | 0x2c (OFF) | 0x2c = Idle, 0x00 = Monitoring | +| **Day** | [0d] | uint8 | 0x08 | 1–31 | +| **Hour** | [0e] | uint8 | 0x10 | 0–23 (16 = 4 PM) | +| **Month** | [0f] | uint8 | 0x04 | 1–12 (April) | +| **Year** | [10:12] | uint16 BE | 0x07ea | 2026 | +| **Minute** | [12] | uint8 | 0x00 | 0–59 | +| **Second** | [13] | uint8 | 0x01 | 0–59 (but this seems off) | +| **Battery Voltage** | [2f:31] | uint16 BE, ÷100 | 0x02a8 | 680 → 6.80V | +| **Memory Total** | [31:35] | uint32 BE | 0x000efff2 | 983,026 bytes = 960.0 KB | +| **Memory Free** | [35:39] | uint32 BE | 0x000e9e52 | 958,034 bytes = 935.6 KB | + +--- + +## Idle Frame (58 bytes) — Full Hex Dump + +``` + 00: 2c 00 00 00 00 00 00 00 00 00 00 00 00 08 10 04 ,............... + 10: 07 ea 00 01 3b 2d 00 00 00 00 00 00 01 01 07 cb ....;-.......... + 20: 00 06 00 00 01 01 07 cb 00 15 00 00 00 00 10 02 ................ + 30: a8 00 0e ff f2 00 0e 9e 52 ef ........R. +``` + +### Field Breakdown + +**[00:01] = Monitor Mode** +``` +Offset 00: 0x2c = 44 (decimal) + Interpretation: Unit is NOT currently monitoring (idle/off state) + Counter-example in monitoring frame: 0x00 (ON state) +``` + +**[01:0d] = Padding/Reserved (12 bytes of zeros)** +``` +Offsets 01-0c: all 0x00 +``` + +**[0d:12] = Timestamp (5 bytes)** +``` +Offset 0d: 0x08 = 8 → DAY +Offset 0e: 0x10 = 16 → HOUR (4 PM) +Offset 0f: 0x04 = 4 → MONTH (April) +Offset 10-11: 0x07ea → YEAR (big-endian: 2026) + = 2026-04-08, 16:??:?? +``` + +**[12:14] = Time (minute/second, ambiguous)** +``` +Offset 12: 0x00 = 0 → Likely MINUTE +Offset 13: 0x01 = 1 → Likely SECOND + But this seems too low; may be wrong interpretation +``` + +**[14:16] = Unknown (2 bytes)** +``` +Offset 14: 0x3b = 59 (decimal) - could be seconds? +Offset 15: 0x2d = 45 (decimal) +``` + +**[16:2f] = Unknown/Filler (25 bytes)** +``` +Contains various device-specific configuration or state bytes. +Some patterns suggest repeating data structures (e.g., 01 01 07 cb appears twice). +``` + +**[2f:31] = Battery Voltage (2 bytes, uint16 BE, divide by 100)** +``` +Offset 2f-30: 0x02a8 + = 680 (decimal) + ÷ 100 = 6.80 volts + Expected: ~6.8V ✓ CONFIRMED +``` + +**[31:35] = Memory Total (4 bytes, uint32 BE)** +``` +Offset 31-34: 0x000efff2 + = 983,026 (decimal, bytes) + ÷ 1024 = 960.0 KB ✓ CONFIRMED + (Device spec: ~960 KB) +``` + +**[35:39] = Memory Free (4 bytes, uint32 BE)** +``` +Offset 35-38: 0x000e9e52 + = 958,034 (decimal, bytes) + ÷ 1024 = 935.6 KB ✓ CONFIRMED + (Expected: ~936 KB) +``` + +**[39:3a] = Trailing byte** +``` +Offset 39: 0xef = 239 +``` + +--- + +## Monitoring Frame (12 bytes) — Condensed Response + +When the unit is actively monitoring, the response shrinks to 12 bytes: + +``` + 00: 00 00 00 00 2c 00 00 00 00 00 00 1f ....,....... +``` + +### Changes from Idle + +| Field | Idle Frame | Monitoring Frame | Note | +|-------|------------|------------------|------| +| Monitor Mode | [00] = 0x2c | [04] = 0x2c → may shift or invert | Moved to offset [04]? | +| Size | 58 bytes | 12 bytes | Truncated response; only status, no detail | +| [0b] | varies | 0x1f | New/different byte at end | + +**Interpretation:** +- The response layout changes based on monitoring state +- In monitoring mode, many detailed fields are suppressed +- The monitor_mode indicator may move or encode differently + +--- + +## Date/Time Interpretation + +The timestamp at [0d:12] uses this layout (confirmed from capture): + +``` +[0d] = DAY (1–31) = 0x08 = 8 +[0e] = HOUR (0–23) = 0x10 = 16 (4 PM) +[0f] = MONTH (1–12) = 0x04 = 4 (April) +[10:12] = YEAR (uint16 BE) = 0x07ea = 2026 +``` + +**Timestamp extracted:** 2026-04-08 16:??:?? + +Minutes and seconds are less clear: +- [12] = 0x00 → possibly minute +- [13] = 0x01 → possibly second (but unusually low) +- [14] = 0x3b = 59 (redundant second marker?) + +--- + +## Voltage Encoding + +Battery voltage is stored as **uint16 big-endian, divide by 100:** + +``` +[2f:31] = 0x02a8 +Raw value: 680 +Voltage: 680 / 100 = 6.80 V +Expected: ~6.8V ✓ +``` + +Other attempted decodings (all ruled out): +- `÷1000`: 0.680V (too low) +- `÷10`: 68V (too high) +- float32 BE/LE: no match in range 6–8V +- Fixed-point: no other range matched + +--- + +## Memory Encoding + +Both fields use **uint32 big-endian, in bytes:** + +``` +Memory Total: + [31:35] = 0x000efff2 = 983,026 bytes = 960.0 KB + +Memory Free: + [35:39] = 0x000e9e52 = 958,034 bytes = 935.6 KB + +Sanity check: free < total ✓ +Free percentage: 935.6 / 960.0 = 97.5% (plausible) +``` + +--- + +## Monitor Mode Field Transitions + +**Idle/OFF State:** +``` +[00] = 0x2c (decimal 44) +``` + +**Monitoring/ON State (response shrinks to 12 bytes):** +``` +Byte layout shifts; [04] carries 0x2c or another value +Possible interpretation: the byte moves, or encoding inverts +``` + +**Confirmed behavior:** +- When idle: byte [00] = 0x2c, response is 58 bytes +- When monitoring: byte position shifts to [04], response is 12 bytes +- Value 0x2c appears to mean "OFF" or "not actively streaming" +- Value 0x00 appears to mean "ON" or "actively streaming" + +--- + +## Unknown Fields (for future analysis) + +The following regions have been observed but their purpose is unclear: + +| Range | Hex (Idle) | Notes | +|-------|----------|-------| +| [01:0d] | all 0x00 | Padding or reserved? | +| [14:16] | 3b 2d | 59, 45 — possibly countdown timers or other state | +| [16:2f] | mixed | Appears to contain device configuration snapshots; pattern repeats suggest sub-structures (e.g., trigger levels, calibration dates) | + +--- + +## Wire Frame Structure (S3 Format) + +Raw S3 response for SUB 0x1C (response SUB = 0xE3): + +``` +[DLE=0x10][STX=0x02][destuffed_payload+chk][bare ETX=0x03] + +Destuffed payload: + [0] CMD = 0x00 + [1] flags = 0x10 + [2] SUB = 0xE3 (response) + [3] PAGE_HI = 0x00 + [4] PAGE_LO = 0x00 + [5+] data = 58 or 12 bytes (depending on mode) +``` + +--- + +## Summary Table (Idle/OFF State) + +| Field | Bytes | Value | Interpretation | +|-------|-------|-------|------------------| +| Monitor Mode | [00] | 0x2c | Device idle (not streaming) | +| Reserved | [01:0d] | 0x00×12 | Padding | +| **Date/Time** | — | — | — | +| Day | [0d] | 0x08 | 8th | +| Hour | [0e] | 0x10 | 16 (4 PM) | +| Month | [0f] | 0x04 | April | +| Year | [10:12] | 0x07ea | 2026 | +| Minute | [12] | 0x00 | 00 (uncertain) | +| Second | [13] | 0x01 | 01 (uncertain) | +| Unknown | [14:2f] | — | 27 bytes of mixed data | +| **Battery** | — | — | — | +| Voltage | [2f:31] | 0x02a8 | 6.80 V (BE ÷100) | +| **Memory** | — | — | — | +| Total | [31:35] | 0x000efff2 | 960.0 KB (BE) | +| Free | [35:39] | 0x000e9e52 | 935.6 KB (BE) | +| Trailer | [39:3a] | 0xef | Unknown (1 byte) | + +--- + +## Next Steps + +1. **Verify minute/second fields** — Compare against multiple captures to confirm [12:14] layout +2. **Decode unknown region [16:2f]** — Likely contains trigger levels, calibration dates, alarm thresholds +3. **Monitoring mode byte position** — Confirm whether it truly moves to [04] in the monitoring response or if response layout is completely different +4. **Min/max voltage limits** — Check if voltage ever deviates from 6.8V to validate encoding +5. **Memory dynamics** — Track total/free across sessions to understand flash layout diff --git a/docs/sub_0x1c_field_locations.txt b/docs/sub_0x1c_field_locations.txt new file mode 100644 index 0000000..8d45efc --- /dev/null +++ b/docs/sub_0x1c_field_locations.txt @@ -0,0 +1,225 @@ +SUB 0x1C MONITORING STATUS RESPONSE — FINAL FIELD LOCATIONS +============================================================ + +Source: raw_s3_20260408_015927.bin (2ndtry capture) +Frames analyzed: + - IDLE (OFF): Frame 90 at file offset 4115 (58-byte response) + - MONITORING (ON): Frame 106 at file offset 4922 (12-byte response) + +================================================================================ +IDLE/OFF RESPONSE (58 bytes) — COMPLETE FIELD MAP +================================================================================ + +HEX DUMP: + 00: 2c 00 00 00 00 00 00 00 00 00 00 00 00 08 10 04 + 10: 07 ea 00 01 3b 2d 00 00 00 00 00 00 01 01 07 cb + 20: 00 06 00 00 01 01 07 cb 00 15 00 00 00 00 10 02 + 30: a8 00 0e ff f2 00 0e 9e 52 ef + +CONFIRMED FIELDS: +───────────────────────────────────────────────────────────────── + +[00] MONITOR_MODE + Value: 0x2c (44 decimal) + Meaning: Device is IDLE (not monitoring) + When ON: 0x00 + +[0d] DAY + Value: 0x08 (8 decimal) + Range: 1–31 + Date: 8th + +[0e] HOUR + Value: 0x10 (16 decimal) + Range: 0–23 + Interpretation: 4:00 PM (16:00) + +[0f] MONTH + Value: 0x04 (4 decimal) + Range: 1–12 + Meaning: April + +[10:12] YEAR (uint16 BE) + Value: 0x07ea + Decimal: 2026 + Full date: 2026-04-08 + +[12] MINUTE + Value: 0x00 (0 decimal) + Range: 0–59 + Note: May have different encoding in other captures + +[13] SECOND + Value: 0x01 (1 decimal) + Range: 0–59 + Note: Unusually low; likely indicates sampling at minute turn-over + +[2f:31] BATTERY_VOLTAGE (uint16 BE, ÷100) + Raw bytes: 0x02a8 + Raw decimal: 680 + Voltage: 680 ÷ 100 = 6.80 V + ✓ CONFIRMED: Expected ~6.8V + Alternative encodings tested and ruled out: + - BE/1000: 0.68V (too low) + - BE/10: 68V (too high) + - float32 BE/LE: no match + - Fixed-point variations: no match + +[31:35] MEMORY_TOTAL (uint32 BE, in bytes) + Raw bytes: 0x000efff2 + Decimal: 983,026 bytes + Kilobytes: 983,026 ÷ 1024 = 960.0 KB + ✓ CONFIRMED: Expected ~960 KB + +[35:39] MEMORY_FREE (uint32 BE, in bytes) + Raw bytes: 0x000e9e52 + Decimal: 958,034 bytes + Kilobytes: 958,034 ÷ 1024 = 935.6 KB + ✓ CONFIRMED: Expected ~936 KB + Sanity check: 935.6 / 960.0 = 97.5% (plausible) + +UNIDENTIFIED REGIONS: +───────────────────────────────────────────────────────────────── + +[01:0d] PADDING/RESERVED (12 bytes) + All zeros: 00 00 00 00 00 00 00 00 00 00 00 00 + +[14:16] UNKNOWN (2 bytes) + Value: 0x3b2d (59, 45) + Possibly event countdown or state field + +[16:2f] CONFIGURATION SNAPSHOT (25 bytes) + Contains repeating patterns suggesting sub-structures: + - Possibly trigger levels + - Possibly calibration data + - Possibly alarm settings + +[39] TRAILER (1 byte) + Value: 0xef (239) + Purpose unknown + +================================================================================ +MONITORING/ON RESPONSE (12 bytes) — CONDENSED FORMAT +================================================================================ + +HEX DUMP: + 00: 00 00 00 00 2c 00 00 00 00 00 00 1f + +INTERPRETATION: +───────────────────────────────────────────────────────────────── + +When the unit is actively monitoring, the response shrinks to 12 bytes. +Response layout appears different from idle format. + +[04] POSSIBLE MONITOR_MODE (shifted position?) + Value: 0x2c + Note: In idle response this was at [00] + +[0b] TRAILER (1 byte) + Value: 0x1f (31 decimal) + Different from idle trailer (0xef at [39]) + +All other bytes: 0x00 padding + +HYPOTHESIS: + When monitoring, the device suppresses detailed fields and returns only: + - Monitor mode status (position may shift) + - A condensed state indicator + +================================================================================ +TIME FIELD SUMMARY (3 INTERPRETATIONS) +================================================================================ + +OBSERVED BYTES: + [0d] = 0x08 (day) + [0e] = 0x10 (hour) + [0f] = 0x04 (month) + [10:12] = 0x07ea (year) + [12] = 0x00 (minute) + [13] = 0x01 (second) + +INTERPRETATION #1 (MOST LIKELY): + 2026-04-08 16:00:01 + +INTERPRETATION #2 (IF BYTES ARE SWAPPED): + Could be 2026-04-08 04:10:?? (but less likely) + +INTERPRETATION #3 (IF TIME IS ELSEWHERE): + Bytes at [14:16] = 0x3b2d could indicate 59 seconds, 45 ??? + But structure is unclear + +CONFIDENCE: MEDIUM + The date part (day/month/year) is confirmed at 2026-04-08. + The hour=16 (4 PM) seems reasonable. + Minute=00 and second=01 seem offset but may reflect the sample time. + +================================================================================ +VOLTAGE ENCODING VERIFICATION +================================================================================ + +Test: uint16 BE ÷ 100 + Raw bytes: 0x02a8 + As BE uint16: 680 + After ÷100: 6.80 V + Expected: ~6.8V ✓ MATCH + +Eliminated alternatives: + ÷1000: 0.68V ✗ (too low) + ÷10: 68V ✗ (too high) + float32 BE: no 6.8V match ✗ + float32 LE: no 6.8V match ✗ + Fixed-point 8.8: no match ✗ + Fixed-point 16.0: no match ✗ + +CONCLUSION: uint16 BE ÷ 100 is correct encoding. + +================================================================================ +MEMORY ENCODING VERIFICATION +================================================================================ + +Test: uint32 BE (bytes), convert to KB + + Memory Total: + Raw bytes: 0x000efff2 + As BE uint32: 983,026 + In KB: 983,026 ÷ 1024 = 960.0 KB + Spec: ~960 KB ✓ MATCH + + Memory Free: + Raw bytes: 0x000e9e52 + As BE uint32: 958,034 + In KB: 958,034 ÷ 1024 = 935.6 KB + Spec: ~936 KB ✓ MATCH + + Sanity check: free (935.6) < total (960.0) ✓ + Usage: (960.0 - 935.6) / 960.0 = 2.5% (plausible) + +CONCLUSION: uint32 BE (in bytes), divide by 1024 for KB. + +================================================================================ +PYTHON PARSING REFERENCE +================================================================================ + + from struct import unpack + + data = bytes.fromhex("2c00000000000000000000000008100407ea00013b2d...") + + monitor_mode = data[0x00] + day = data[0x0d] + hour = data[0x0e] + month = data[0x0f] + year = unpack('>H', data[0x10:0x12])[0] + minute = data[0x12] + second = data[0x13] + + voltage_v = unpack('>H', data[0x2f:0x31])[0] / 100.0 + memory_total_kb = unpack('>I', data[0x31:0x35])[0] / 1024.0 + memory_free_kb = unpack('>I', data[0x35:0x39])[0] / 1024.0 + + print(f"Monitor: {['ON', 'OFF'][monitor_mode == 0x2c]}") + print(f"Date: {year:04d}-{month:02d}-{day:02d}") + print(f"Time: {hour:02d}:{minute:02d}:{second:02d}") + print(f"Battery: {voltage_v:.2f} V") + print(f"Memory: {memory_total_kb:.1f} KB total, {memory_free_kb:.1f} KB free") + +================================================================================ diff --git a/minimateplus/client.py b/minimateplus/client.py index b41aeb9..d1ae279 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -37,6 +37,7 @@ from .models import ( ComplianceConfig, DeviceInfo, Event, + MonitorStatus, PeakValues, ProjectInfo, Timestamp, @@ -49,6 +50,7 @@ from .protocol import ( SUB_WRITE_CONFIRM_B, SUB_WRITE_CONFIRM_C, SUB_TRIGGER_CONFIRM, + SUB_MONITOR_STATUS, ) from .transport import SerialTransport, BaseTransport @@ -725,6 +727,66 @@ class MiniMateClient: notes=notes, ) + # ── Monitoring ──────────────────────────────────────────────────────────── + + def get_monitor_status(self) -> MonitorStatus: + """ + Read the current monitoring state, battery voltage, and memory usage. + + Wraps protocol.read_monitor_status() and decodes the raw payload into + a MonitorStatus object. + + The device payload length indicates mode: + - 44 bytes (0x2C): unit is idle (full status block present) + - 12 bytes : unit is actively monitoring (abbreviated block) + + Confirmed field offsets (relative to data[11], the start of the S3 + data section after the 11-byte frame header): + [0x2F:0x31] battery voltage × 100 uint16 BE e.g. 0x02A8 = 680 → 6.80 V + [0x31:0x35] memory total (bytes) uint32 BE e.g. 0x000F0000 = 983040 bytes + [0x35:0x39] memory free (bytes) uint32 BE + + Returns: + MonitorStatus with is_monitoring, battery_v, memory_total, memory_free. + + Raises: + RuntimeError: if not connected. + ProtocolError: on timeout or wrong response SUB. + """ + proto = self._require_proto() + frame = proto.read_monitor_status() + return _decode_monitor_status(frame.data) + + def start_monitoring(self) -> None: + """ + Command the device to begin monitoring (recording triggered events). + + Sends SUB 0x96; device responds with a 17-byte zero-data ack (SUB 0x69). + Confirmed from 4-8-26/2ndtry BW TX capture frame 92. + + Raises: + RuntimeError: if not connected. + ProtocolError: on timeout or wrong response. + """ + proto = self._require_proto() + proto.start_monitoring() + log.info("start_monitoring: device is now monitoring") + + def stop_monitoring(self) -> None: + """ + Command the device to stop monitoring. + + Sends SUB 0x97; device responds with a 17-byte zero-data ack (SUB 0x68). + Confirmed from 4-8-26/2ndtry BW TX capture frame 305. + + Raises: + RuntimeError: if not connected. + ProtocolError: on timeout or wrong response. + """ + proto = self._require_proto() + proto.stop_monitoring() + log.info("stop_monitoring: device stopped monitoring") + # ── Internal helpers ────────────────────────────────────────────────────── def _require_proto(self) -> MiniMateProtocol: @@ -1666,3 +1728,42 @@ def _find_first_string(data: bytes, start: int, end: int, min_len: int) -> Optio i += 1 return None + + +def _decode_monitor_status(data: bytes) -> MonitorStatus: + """ + Decode SUB 0x1C response payload into a MonitorStatus object. + + data is the raw S3 frame .data attribute (includes the 11-byte section + header, so field offsets below are relative to data[11]). + + Payload length indicates mode: + 44 bytes (0x2C): idle — full status block with battery + memory fields + 12 bytes : actively monitoring — abbreviated, no battery/memory + + Field offsets (idle mode, confirmed 4-8-26/2ndtry): + data[11 + 0x2F : 11 + 0x31] battery × 100 uint16 BE + data[11 + 0x31 : 11 + 0x35] memory_total uint32 BE bytes + data[11 + 0x35 : 11 + 0x39] memory_free uint32 BE bytes + """ + # The data section starts at offset 11 (after the S3 section header). + section = data[11:] if len(data) > 11 else data + # Mode: idle payload is 44 bytes; monitoring is shorter (12 bytes observed) + is_monitoring = len(section) < 20 + + battery_v = None + memory_total = None + memory_free = None + + if not is_monitoring and len(section) >= 0x39: + batt_raw = struct.unpack(">H", section[0x2F:0x31])[0] + battery_v = batt_raw / 100.0 + memory_total = struct.unpack(">I", section[0x31:0x35])[0] + memory_free = struct.unpack(">I", section[0x35:0x39])[0] + + return MonitorStatus( + is_monitoring=is_monitoring, + battery_v=battery_v, + memory_total=memory_total, + memory_free=memory_free, + ) diff --git a/minimateplus/models.py b/minimateplus/models.py index a6dcbec..3371c36 100644 --- a/minimateplus/models.py +++ b/minimateplus/models.py @@ -417,3 +417,22 @@ class Event: parts.append(f"M={pv.micl:.6f}") ppv = " [" + ", ".join(parts) + " in/s]" return f"Event#{self.index} {ts}{ppv}" + + +# ── MonitorStatus ───────────────────────────────────────────────────────────── + +@dataclass +class MonitorStatus: + """ + Current monitoring state decoded from SUB 0x1C response. + + Confirmed field locations from 4-8-26/2ndtry BW capture: + battery_v : data[11 + 0x2F : 11 + 0x31] uint16 BE ÷ 100 e.g. 680 → 6.80 V + memory_total: data[11 + 0x31 : 11 + 0x35] uint32 BE bytes e.g. 983040 → 960 KB + memory_free : data[11 + 0x35 : 11 + 0x39] uint32 BE bytes (subset of total) + is_monitoring: inferred from payload length — idle = 44 bytes, monitoring = 12 bytes + """ + is_monitoring: bool # True if unit is actively recording ✅ + battery_v: Optional[float] = None # Battery voltage in volts ✅ + memory_total: Optional[int] = None # Total flash memory in bytes ✅ + memory_free: Optional[int] = None # Free flash memory in bytes ✅ diff --git a/minimateplus/protocol.py b/minimateplus/protocol.py index 8f10274..36a6b7e 100644 --- a/minimateplus/protocol.py +++ b/minimateplus/protocol.py @@ -57,7 +57,7 @@ SUB_SERIAL_NUMBER = 0x15 SUB_FULL_CONFIG = 0x01 SUB_EVENT_INDEX = 0x08 SUB_CHANNEL_CONFIG = 0x06 -SUB_TRIGGER_CONFIG = 0x1C +SUB_MONITOR_STATUS = 0x1C # Monitoring status read (battery, memory, mode) ✅ SUB_EVENT_HEADER = 0x1E SUB_EVENT_ADVANCE = 0x1F SUB_WAVEFORM_HEADER = 0x0A @@ -77,6 +77,10 @@ SUB_WRITE_CONFIRM_C = 0x74 # Confirm C — sent after 69 ✅ SUB_TRIGGER_CONFIG_WRITE = 0x82 # Write trigger config (0x22 + 0x60) ✅ SUB_TRIGGER_CONFIRM = 0x83 # Confirm trigger write ✅ +# Monitoring control SUBs (confirmed from 4-8-26/2ndtry BW TX capture) +SUB_START_MONITORING = 0x96 # Start monitoring → response 0x69 ✅ +SUB_STOP_MONITORING = 0x97 # Stop monitoring → response 0x68 ✅ + # Hardcoded data lengths for the two-step read protocol. # # The S3 probe response page_key is always 0x0000 — it does NOT carry the @@ -91,7 +95,7 @@ DATA_LENGTHS: dict[int, int] = { SUB_SERIAL_NUMBER: 0x0A, # 10-byte serial number block ✅ SUB_FULL_CONFIG: 0x98, # 152-byte full config block ✅ SUB_EVENT_INDEX: 0x58, # 88-byte event index ✅ - SUB_TRIGGER_CONFIG: 0x2C, # 44-byte trigger config 🔶 + SUB_MONITOR_STATUS: 0x2C, # 44-byte monitor status block (idle) ✅ SUB_EVENT_HEADER: 0x08, # 8-byte event header (waveform key + event data) ✅ SUB_EVENT_ADVANCE: 0x08, # 8-byte next-key response ✅ # SUB_WAVEFORM_HEADER (0x0A) is VARIABLE — length read from probe response @@ -1056,6 +1060,71 @@ class MiniMateProtocol: self._send(frame) return self.recv_write_ack(expected_sub=rsp_sub) + # ── Monitoring ──────────────────────────────────────────────────────────── + + def read_monitor_status(self) -> S3Frame: + """ + Read monitoring status (SUB 0x1C → response 0xE3). + + Two-step read: probe (offset=0x00) then data (offset=0x2C). + + Returns: + S3Frame with 44 bytes of status data (idle state). + When unit is actively monitoring the payload is shorter (12 bytes); + callers should check frame data length to determine mode. + + Raises: + ProtocolError: on timeout or wrong response SUB. + """ + rsp_sub = _expected_rsp_sub(SUB_MONITOR_STATUS) # 0xFF - 0x1C = 0xE3 + log.debug("read_monitor_status: probe step rsp_sub=0x%02X", rsp_sub) + probe_frame = build_bw_frame(SUB_MONITOR_STATUS, offset=0x00) + self._send(probe_frame) + self._recv_one(expected_sub=rsp_sub) + + log.debug("read_monitor_status: data step offset=0x%02X", DATA_LENGTHS[SUB_MONITOR_STATUS]) + data_frame = build_bw_frame(SUB_MONITOR_STATUS, offset=DATA_LENGTHS[SUB_MONITOR_STATUS]) + self._send(data_frame) + return self._recv_one(expected_sub=rsp_sub) + + def start_monitoring(self) -> S3Frame: + """ + Send Start Monitoring command (SUB 0x96 → response 0x69). + + Single write frame, no data payload. Confirmed from 4-8-26/2ndtry + BW TX capture frame 92. + + Returns: + S3Frame ack from device. + + Raises: + ProtocolError: on timeout or wrong response SUB. + """ + rsp_sub = _expected_rsp_sub(SUB_START_MONITORING) # 0xFF - 0x96 = 0x69 + log.debug("start_monitoring: rsp_sub=0x%02X", rsp_sub) + frame = build_bw_write_frame(SUB_START_MONITORING, b"") + self._send(frame) + return self.recv_write_ack(expected_sub=rsp_sub) + + def stop_monitoring(self) -> S3Frame: + """ + Send Stop Monitoring command (SUB 0x97 → response 0x68). + + Single write frame, no data payload. Confirmed from 4-8-26/2ndtry + BW TX capture frame 305. + + Returns: + S3Frame ack from device. + + Raises: + ProtocolError: on timeout or wrong response SUB. + """ + rsp_sub = _expected_rsp_sub(SUB_STOP_MONITORING) # 0xFF - 0x97 = 0x68 + log.debug("stop_monitoring: rsp_sub=0x%02X", rsp_sub) + frame = build_bw_write_frame(SUB_STOP_MONITORING, b"") + self._send(frame) + return self.recv_write_ack(expected_sub=rsp_sub) + # ── Internal helpers ────────────────────────────────────────────────────── def _send(self, frame: bytes) -> None: diff --git a/sfm/server.py b/sfm/server.py index e257038..7f0c663 100644 --- a/sfm/server.py +++ b/sfm/server.py @@ -614,6 +614,88 @@ def device_config_project( return device_config(body=body, port=port, baud=baud, host=host, tcp_port=tcp_port) +# ── Monitoring endpoints ─────────────────────────────────────────────────────── + +@app.get("/device/monitor/status") +def device_monitor_status( + 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: + """ + Read monitoring status from the device. + + Returns is_monitoring bool, battery voltage, and memory usage (total + free bytes). + Battery and memory are only present when the unit is idle (not monitoring). + """ + transport = _make_transport(port=port, baud=baud, host=host, tcp_port=tcp_port) + with MiniMateClient(transport=transport) as client: + try: + client.connect() + except Exception as exc: + log.warning("monitor status connect retry: %s", exc) + client.connect() + status = client.get_monitor_status() + + result: dict = {"is_monitoring": status.is_monitoring} + if status.battery_v is not None: + result["battery_v"] = round(status.battery_v, 2) + if status.memory_total is not None: + result["memory_total_bytes"] = status.memory_total + result["memory_total_kb"] = round(status.memory_total / 1024, 1) + if status.memory_free is not None: + result["memory_free_bytes"] = status.memory_free + result["memory_free_kb"] = round(status.memory_free / 1024, 1) + return result + + +@app.post("/device/monitor/start") +def device_monitor_start( + 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: + """ + Command the device to start monitoring (recording triggered events). + + Sends SUB 0x96 and waits for ack SUB 0x69. + """ + transport = _make_transport(port=port, baud=baud, host=host, tcp_port=tcp_port) + with MiniMateClient(transport=transport) as client: + try: + client.connect() + except Exception as exc: + log.warning("start monitoring connect retry: %s", exc) + client.connect() + client.start_monitoring() + return {"status": "started"} + + +@app.post("/device/monitor/stop") +def device_monitor_stop( + 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: + """ + Command the device to stop monitoring. + + Sends SUB 0x97 and waits for ack SUB 0x68. + """ + transport = _make_transport(port=port, baud=baud, host=host, tcp_port=tcp_port) + with MiniMateClient(transport=transport) as client: + try: + client.connect() + except Exception as exc: + log.warning("stop monitoring connect retry: %s", exc) + client.connect() + client.stop_monitoring() + return {"status": "stopped"} + + # ── Entry point ──────────────────────────────────────────────────────────────── if __name__ == "__main__": diff --git a/sfm/sfm_webapp.html b/sfm/sfm_webapp.html index 297f232..c3e5e04 100644 --- a/sfm/sfm_webapp.html +++ b/sfm/sfm_webapp.html @@ -130,6 +130,36 @@ .di-value.accent { color: var(--blue-lt); font-weight: 600; } .di-value.project-val { color: #e6edf3; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + /* ── Monitor panel ── */ + #monitor-panel { + background: var(--surface); + border-bottom: 1px solid var(--border2); + padding: 8px 18px; + display: none; + align-items: center; + gap: 24px; + flex-shrink: 0; + } + #monitor-panel.monitoring { border-left: 3px solid var(--green); } + #monitor-panel.idle { border-left: 3px solid var(--text-mute); } + .mon-status-badge { + font-size: 12px; + font-weight: 700; + padding: 2px 10px; + border-radius: 10px; + letter-spacing: 0.04em; + } + .mon-status-badge.monitoring { background: rgba(46,160,67,0.2); color: var(--green-lt); } + .mon-status-badge.idle { background: var(--surface2); color: var(--text-mute); } + .mon-field { display: flex; flex-direction: column; gap: 1px; } + .mon-label { color: var(--text-mute); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; } + .mon-value { color: var(--text); font-family: monospace; font-size: 13px; } + #mon-start-btn { background: var(--green); color: #fff; } + #mon-start-btn:hover { background: var(--green-lt); } + #mon-stop-btn { background: var(--red); color: #fff; } + #mon-stop-btn:hover { filter: brightness(1.15); } + .mon-spacer { flex: 1; } + /* ── Status bar ── */ #status-bar { background: var(--surface); @@ -479,6 +509,27 @@ + +
+ IDLE +
+ Battery + +
+
+ Memory total + +
+
+ Memory free + +
+
+ + + +
+
Ready — enter device host and click Connect.
@@ -723,7 +774,8 @@ async function connectUnit() { populateEventChips(); populateConfigFromDeviceInfo(); - document.getElementById('device-bar').style.display = 'flex'; + document.getElementById('device-bar').style.display = 'flex'; + document.getElementById('monitor-panel').style.display = 'flex'; document.getElementById('load-btn').disabled = eventList.length === 0; document.getElementById('prev-btn').disabled = true; document.getElementById('next-btn').disabled = eventList.length <= 1; @@ -733,6 +785,10 @@ async function connectUnit() { btn.disabled = false; btn.textContent = 'Reconnect'; setStatus(`Connected — ${eventList.length} event${eventList.length !== 1 ? 's' : ''} stored.`, 'ok'); + + // Fetch monitor status in background (non-blocking) + refreshMonitorStatus().catch(() => {}); + const cc = unitInfo.compliance_config; if (cc) { if (cc.sample_rate) addPill(`${cc.sample_rate} sps`); @@ -755,6 +811,81 @@ function populateDeviceBar() { geoRange = cc.max_range_geo ?? 6.206; } +// ── Monitoring ───────────────────────────────────────────────────────────────── +async function refreshMonitorStatus() { + if (!devHost()) return; + try { + const r = await fetch(`${api()}/device/monitor/status?${deviceParams()}`); + if (!r.ok) return; + const s = await r.json(); + updateMonitorPanel(s); + } catch (_) {} +} + +function updateMonitorPanel(s) { + const panel = document.getElementById('monitor-panel'); + const badge = document.getElementById('mon-badge'); + const batEl = document.getElementById('mon-battery'); + const memTEl = document.getElementById('mon-mem-total'); + const memFEl = document.getElementById('mon-mem-free'); + const startB = document.getElementById('mon-start-btn'); + const stopB = document.getElementById('mon-stop-btn'); + + if (s.is_monitoring) { + badge.textContent = 'MONITORING'; + badge.className = 'mon-status-badge monitoring'; + panel.className = 'monitoring'; + startB.disabled = true; + stopB.disabled = false; + batEl.textContent = '—'; + memTEl.textContent = '—'; + memFEl.textContent = '—'; + } else { + badge.textContent = 'IDLE'; + badge.className = 'mon-status-badge idle'; + panel.className = 'idle'; + startB.disabled = false; + stopB.disabled = true; + batEl.textContent = s.battery_v != null ? `${s.battery_v.toFixed(2)} V` : '—'; + memTEl.textContent = s.memory_total_kb != null ? `${s.memory_total_kb} KB` : '—'; + memFEl.textContent = s.memory_free_kb != null ? `${s.memory_free_kb} KB` : '—'; + } +} + +async function startMonitoring() { + if (!devHost()) return; + const btn = document.getElementById('mon-start-btn'); + btn.disabled = true; btn.textContent = '…'; + setStatus('Starting monitoring…', 'loading'); + try { + const r = await fetch(`${api()}/device/monitor/start?${deviceParams()}`, { method: 'POST' }); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + setStatus('Monitoring started.', 'ok'); + await refreshMonitorStatus(); + } catch (e) { + setStatus(`Start monitoring failed: ${e.message}`, 'error'); + btn.disabled = false; + } + btn.textContent = '▶ Start'; +} + +async function stopMonitoring() { + if (!devHost()) return; + const btn = document.getElementById('mon-stop-btn'); + btn.disabled = true; btn.textContent = '…'; + setStatus('Stopping monitoring…', 'loading'); + try { + const r = await fetch(`${api()}/device/monitor/stop?${deviceParams()}`, { method: 'POST' }); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + setStatus('Monitoring stopped.', 'ok'); + await refreshMonitorStatus(); + } catch (e) { + setStatus(`Stop monitoring failed: ${e.message}`, 'error'); + btn.disabled = false; + } + btn.textContent = '■ Stop'; +} + // ── Device tab ───────────────────────────────────────────────────────────────── function populateDeviceTab() { document.getElementById('no-device-msg').style.display = 'none'; @@ -1021,8 +1152,12 @@ function renderWaveform(data) { if (isGeo) { const scale = geoRange / 32767; plotData = samples.map(s => s * scale); - const peak = Math.max(...plotData.map(Math.abs)); - peakLabel = `${peak.toFixed(5)} in/s`; + // Use the device-recorded peak from the 0C waveform record — authoritative + // and matches Blastware. Computing from raw samples can catch rogue + // near-full-scale values from decoding artifacts. + const peakKey = { Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' }[ch]; + const devicePeak = data.peak_values?.[peakKey] ?? null; + peakLabel = devicePeak != null ? `${devicePeak.toFixed(5)} in/s` : `${Math.max(...plotData.map(Math.abs)).toFixed(5)} in/s`; yUnit = 'in/s'; ttFmt = v => `${ch}: ${v.toFixed(5)} in/s`; tickFmt = v => v.toFixed(4);