feat: Add monitoring functionality to MiniMate protocol and web interface
- Introduced new SUBs for monitoring status, start, and stop commands in protocol.py. - Implemented read_monitor_status, start_monitoring, and stop_monitoring methods in MiniMateProtocol class. - Added new API endpoints for monitoring status retrieval and control in server.py. - Enhanced the web application with a monitoring panel, including battery and memory status display. - Created a new Python script to parse SUB 0x1C response frames for monitoring status. - Documented the monitoring status response format and field locations in markdown and text files.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <hex_string_or_file>")
|
||||
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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
================================================================================
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 ✅
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__":
|
||||
|
||||
+138
-3
@@ -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 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Monitor panel ───────────────────────────────────────────────── -->
|
||||
<div id="monitor-panel">
|
||||
<span class="mon-status-badge idle" id="mon-badge">IDLE</span>
|
||||
<div class="mon-field">
|
||||
<span class="mon-label">Battery</span>
|
||||
<span class="mon-value" id="mon-battery">—</span>
|
||||
</div>
|
||||
<div class="mon-field">
|
||||
<span class="mon-label">Memory total</span>
|
||||
<span class="mon-value" id="mon-mem-total">—</span>
|
||||
</div>
|
||||
<div class="mon-field">
|
||||
<span class="mon-label">Memory free</span>
|
||||
<span class="mon-value" id="mon-mem-free">—</span>
|
||||
</div>
|
||||
<div class="mon-spacer"></div>
|
||||
<button class="btn" id="mon-refresh-btn" onclick="refreshMonitorStatus()" title="Refresh monitoring status">↻ Status</button>
|
||||
<button class="btn" id="mon-start-btn" onclick="startMonitoring()" title="Start monitoring">▶ Start</button>
|
||||
<button class="btn" id="mon-stop-btn" onclick="stopMonitoring()" title="Stop monitoring">■ Stop</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Status bar ─────────────────────────────────────────────────── -->
|
||||
<div id="status-bar">Ready — enter device host and click Connect.</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user