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:
Brian Harrison
2026-04-01 00:53:34 -04:00
parent f74992f4e5
commit 4944974f6e
4 changed files with 257 additions and 73 deletions

View File

@@ -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 08) ✅ 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 12 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[34]: 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 ───────────────────────────────────────────────