feat: decode waveform record timestamp, record type, and Peak Vector Sum
Confirmed 2026-04-01 against Blastware event report for BE11529 thump
event ("00:28:12 April 1, 2026", PVS 3.906 in/s).
models.py:
- Timestamp.from_waveform_record(): decode 9-byte format from 0C record
bytes[0-8]: [day][sub_code][month][year:2BE][?][hour][min][sec]
- Timestamp: add hour/minute/second optional fields; __str__ includes
time when available
- PeakValues: add peak_vector_sum field (confirmed fixed offset 87)
client.py:
- _decode_waveform_record_into: add timestamp decode from bytes[0:9]
- _extract_record_type: decode byte[1] (sub_code), not ASCII string
search; 0x10 → "Waveform", histogram TBD
- _extract_peak_floats: add PVS from offset 87 (IEEE 754 BE float32)
= √(T²+V²+L²) at max instantaneous vector moment
sfm/server.py:
- _serialise_timestamp: add hour/minute/second/day fields to JSON
- _serialise_peak_values: add peak_vector_sum to JSON
docs: update §7.7.5 and §8 with confirmed 9-byte timestamp layout,
PVS field, and byte[1] record type encoding; update command table;
close resolved open questions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,30 +24,52 @@ from typing import Optional
|
||||
@dataclass
|
||||
class Timestamp:
|
||||
"""
|
||||
6-byte event timestamp decoded from the MiniMate Plus wire format.
|
||||
Event timestamp decoded from the MiniMate Plus wire format.
|
||||
|
||||
Wire layout: [flag:1] [year:2 BE] [unknown:1] [month:1] [day:1]
|
||||
Two source formats exist:
|
||||
|
||||
1. 6-byte format (from event index / 1E header — not yet decoded in client):
|
||||
[flag:1] [year:2 BE] [unknown:1] [month:1] [day:1]
|
||||
Use Timestamp.from_bytes().
|
||||
|
||||
2. 9-byte format (from Full Waveform Record / 0C, bytes 0–8) ✅ CONFIRMED:
|
||||
[day:1] [sub_code:1] [month:1] [year:2 BE] [unknown:1] [hour:1] [min:1] [sec:1]
|
||||
Use Timestamp.from_waveform_record().
|
||||
|
||||
Confirmed 2026-04-01 against Blastware event report (BE11529 thump event):
|
||||
raw bytes: 01 10 04 07 ea 00 00 1c 0c
|
||||
→ day=1, sub_code=0x10 (Waveform mode), month=4, year=2026,
|
||||
hour=0, minute=28, second=12 ← matches Blastware "00:28:12 April 1, 2026"
|
||||
|
||||
The sub_code at byte[1] is the record-mode indicator:
|
||||
0x10 → Waveform (continuous / single-shot) ✅
|
||||
other → Histogram (code not yet captured ❓)
|
||||
|
||||
The year 1995 is the device's factory-default RTC date — it appears
|
||||
whenever the battery has been disconnected. Treat 1995 as "clock not set".
|
||||
"""
|
||||
raw: bytes # raw 6-byte sequence for round-tripping
|
||||
flag: int # byte 0 — validity/type flag (usually 0x01) 🔶
|
||||
year: int # bytes 1–2 big-endian uint16 ✅
|
||||
unknown_byte: int # byte 3 — likely hours/minutes ❓
|
||||
month: int # byte 4 ✅
|
||||
day: int # byte 5 ✅
|
||||
raw: bytes # raw bytes for round-tripping
|
||||
flag: int # byte 0 of 6-byte format, or sub_code from 9-byte format
|
||||
year: int # ✅
|
||||
unknown_byte: int # separator byte (purpose unclear ❓)
|
||||
month: int # ✅
|
||||
day: int # ✅
|
||||
|
||||
# Time fields — populated only from the 9-byte waveform-record format
|
||||
hour: Optional[int] = None # ✅ (waveform record format)
|
||||
minute: Optional[int] = None # ✅ (waveform record format)
|
||||
second: Optional[int] = None # ✅ (waveform record format)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> "Timestamp":
|
||||
"""
|
||||
Decode a 6-byte timestamp sequence.
|
||||
Decode a 6-byte timestamp (6-byte event-index format).
|
||||
|
||||
Args:
|
||||
data: exactly 6 bytes from the device payload.
|
||||
|
||||
Returns:
|
||||
Decoded Timestamp.
|
||||
Decoded Timestamp (no time fields).
|
||||
|
||||
Raises:
|
||||
ValueError: if data is not exactly 6 bytes.
|
||||
@@ -68,6 +90,55 @@ class Timestamp:
|
||||
day=day,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_waveform_record(cls, data: bytes) -> "Timestamp":
|
||||
"""
|
||||
Decode a 9-byte timestamp from the first bytes of a 210-byte waveform
|
||||
record (SUB 0C / Full Waveform Record response).
|
||||
|
||||
Wire layout (✅ CONFIRMED 2026-04-01 against Blastware event report):
|
||||
byte[0]: day (uint8)
|
||||
byte[1]: sub_code / mode flag (0x10 = Waveform mode) 🔶
|
||||
byte[2]: month (uint8)
|
||||
bytes[3–4]: year (big-endian uint16)
|
||||
byte[5]: unknown (0x00 in all observed samples ❓)
|
||||
byte[6]: hour (uint8)
|
||||
byte[7]: minute (uint8)
|
||||
byte[8]: second (uint8)
|
||||
|
||||
Args:
|
||||
data: at least 9 bytes; only the first 9 are consumed.
|
||||
|
||||
Returns:
|
||||
Decoded Timestamp with hour/minute/second populated.
|
||||
|
||||
Raises:
|
||||
ValueError: if data is fewer than 9 bytes.
|
||||
"""
|
||||
if len(data) < 9:
|
||||
raise ValueError(
|
||||
f"Waveform record timestamp requires at least 9 bytes, got {len(data)}"
|
||||
)
|
||||
day = data[0]
|
||||
sub_code = data[1] # 0x10 = Waveform; histogram code not yet confirmed
|
||||
month = data[2]
|
||||
year = struct.unpack_from(">H", data, 3)[0]
|
||||
unknown_byte = data[5]
|
||||
hour = data[6]
|
||||
minute = data[7]
|
||||
second = data[8]
|
||||
return cls(
|
||||
raw=bytes(data[:9]),
|
||||
flag=sub_code,
|
||||
year=year,
|
||||
unknown_byte=unknown_byte,
|
||||
month=month,
|
||||
day=day,
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
second=second,
|
||||
)
|
||||
|
||||
@property
|
||||
def clock_set(self) -> bool:
|
||||
"""False when year == 1995 (factory default / battery-lost state)."""
|
||||
@@ -76,7 +147,10 @@ class Timestamp:
|
||||
def __str__(self) -> str:
|
||||
if not self.clock_set:
|
||||
return f"CLOCK_NOT_SET ({self.year}-{self.month:02d}-{self.day:02d})"
|
||||
return f"{self.year}-{self.month:02d}-{self.day:02d}"
|
||||
date_str = f"{self.year}-{self.month:02d}-{self.day:02d}"
|
||||
if self.hour is not None:
|
||||
return f"{date_str} {self.hour:02d}:{self.minute:02d}:{self.second:02d}"
|
||||
return date_str
|
||||
|
||||
|
||||
# ── Device identity ───────────────────────────────────────────────────────────
|
||||
@@ -136,15 +210,28 @@ class ChannelConfig:
|
||||
@dataclass
|
||||
class PeakValues:
|
||||
"""
|
||||
Per-channel peak particle velocity / pressure for a single event.
|
||||
Per-channel peak particle velocity / pressure for a single event, plus the
|
||||
scalar Peak Vector Sum.
|
||||
|
||||
Extracted from the Full Waveform Record (SUB F3), stored as IEEE 754
|
||||
big-endian floats in the device's native units (in/s / psi).
|
||||
Extracted from the Full Waveform Record (SUB F3 / 0C response), stored as
|
||||
IEEE 754 big-endian floats in the device's native units (in/s / psi).
|
||||
|
||||
Per-channel PPV location (✅ CONFIRMED 2026-04-01):
|
||||
Found by searching for the 4-byte channel label string ("Tran", "Vert",
|
||||
"Long", "MicL") and reading the float at label_offset + 6.
|
||||
|
||||
Peak Vector Sum (✅ CONFIRMED 2026-04-01):
|
||||
Fixed offset 87 in the 210-byte record.
|
||||
= √(Tran² + Vert² + Long²) at the sample instant of maximum combined
|
||||
geo motion. NOT the vector sum of the three per-channel peak values
|
||||
(those may occur at different times).
|
||||
Matches Blastware's "Peak Vector Sum" display exactly.
|
||||
"""
|
||||
tran: Optional[float] = None # Transverse PPV (in/s) ✅
|
||||
vert: Optional[float] = None # Vertical PPV (in/s) ✅
|
||||
long: Optional[float] = None # Longitudinal PPV (in/s) ✅
|
||||
micl: Optional[float] = None # Air overpressure (psi) 🔶 (units uncertain)
|
||||
tran: Optional[float] = None # Transverse PPV (in/s) ✅
|
||||
vert: Optional[float] = None # Vertical PPV (in/s) ✅
|
||||
long: Optional[float] = None # Longitudinal PPV (in/s) ✅
|
||||
micl: Optional[float] = None # Air overpressure (psi) 🔶 (units uncertain)
|
||||
peak_vector_sum: Optional[float] = None # Scalar geo PVS (in/s) ✅
|
||||
|
||||
|
||||
# ── Project / operator metadata ───────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user