diff --git a/CLAUDE.md b/CLAUDE.md index 882ae66..54af19d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ CHANGELOG.md ← version history --- -## Current implementation state (v0.6.0) +## Current implementation state (v0.7.0) Full read pipeline working end-to-end over TCP/cellular: @@ -128,9 +128,15 @@ setup as it existed when the event was recorded: "Extended Notes"→ notes ``` -These strings are **NOT** present in the 210-byte SUB 0C waveform record. They reflect -the setup at record time, not the current device config — this is why we fetch them from -5A instead of backfilling from the current compliance config. +**IMPORTANT — 5A "Project:" is session-start config, NOT per-event (confirmed 2026-04-05):** +The "Project:" string in the A5 frame 7 payload reflects the compliance setup from when +the *monitoring session first started*, not the individual event's project name. The per- +event project name is correctly stored in the 210-byte 0C waveform record and must be +used as the authoritative source. `_decode_a5_metadata_into` therefore only sets +`project` from 5A when 0C didn't already supply one. + +"Client:", "User Name:", "Seis Loc:", and "Extended Notes" are **NOT** present in the 0C +record — 5A remains the sole source for those fields and they are set unconditionally. `stop_after_metadata=True` (default) stops the 5A loop as soon as `b"Project:"` appears, then sends the termination frame. @@ -265,20 +271,47 @@ Anchor: `b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00'`, search `cfg[0:150]` ### SUB 0C — Waveform Record (210 bytes = data[11:11+0xD2]) +**sub_code=0x10 (Waveform single-shot) — 9-byte timestamp header:** + | Offset | Field | Type | |---|---|---| | 0 | day | uint8 | -| 1 | sub_code | uint8 (`0x10` = Waveform single-shot, `0x03` = Waveform continuous) | +| 1 | sub_code | uint8 (`0x10`) | | 2 | month | uint8 | | 3–4 | year | uint16 BE | | 5 | unknown | uint8 (always 0) | | 6 | hour | uint8 | | 7 | minute | uint8 | | 8 | second | uint8 | -| 87 | peak_vector_sum | float32 BE | -| label+6 | PPV per channel | float32 BE (search for `"Tran"`, `"Vert"`, `"Long"`, `"MicL"`) | -PPV labels are NOT 4-byte aligned. The label-offset+6 approach is the only reliable method. +**sub_code=0x03 (Waveform continuous) — 10-byte timestamp header (1-byte shift):** + +Confirmed 2026-04-03 against Blastware event report (15:20:17 Apr 3 2026). +Raw wire bytes: `10 03 10 04 07 ea 00 0f 14 11` + +| Offset | Field | Type | Notes | +|---|---|---|---| +| 0 | unknown_a | uint8 | `0x10` observed | +| 1 | day | uint8 | doubles as sub_code position in 0x10 layout | +| 2 | unknown_b | uint8 | `0x10` observed | +| 3 | month | uint8 | | +| 4–5 | year | uint16 BE | | +| 6 | unknown | uint8 | | +| 7 | hour | uint8 | | +| 8 | minute | uint8 | | +| 9 | second | uint8 | | + +**Peak values (both record types):** + +| Location | Field | Type | +|---|---|---| +| `tran_pos - 12` | peak_vector_sum | float32 BE — label-relative, NOT fixed offset | +| `label + 6` | PPV per channel | float32 BE (search for `"Tran"`, `"Vert"`, `"Long"`, `"MicL"`) | + +PPV labels are NOT 4-byte aligned. The label-relative approach is the only reliable method. +`peak_vector_sum` is exactly 12 bytes before the `"Tran"` label — confirmed for both +sub_code=0x10 and sub_code=0x03. Do NOT use fixed offset 87 (only incidentally correct +for 0x10 records). --- @@ -299,9 +332,10 @@ Server retries once on `ProtocolError` for TCP connections (handles cold-boot ti | Capture | Location | Contents | |---|---|---| -| 1-2-26 | `bridges/captures/1-2-26/` | SUB 5A BW TX frames — used to confirm 5A frame format, 11-byte params, DLE-aware checksum | +| 1-2-26 | `bridges/captures/1-2-26/` | SUB 5A BW TX frames — confirmed 5A frame format, 11-byte params, DLE-aware checksum | | 3-11-26 | `bridges/captures/3-11-26/` | Full compliance setup write, Aux Trigger capture | -| 3-31-26 | `bridges/captures/3-31-26/` | Complete event download cycle (148 BW / 147 S3 frames) — confirmed 1E/0A/0C/1F sequence | +| 3-31-26 | `bridges/captures/3-31-26/` | Complete event download cycle (148 BW / 147 S3 frames) — confirmed 1E/0A/0C/1F sequence; only 1 event stored so token=0xFE appeared to work | +| 4-3-26 | `bridges/captures/4-3-26/` | Browse-mode S3 capture with 2+ events — confirmed all-zero params for 1F, 1F response layout, null sentinel, 0A context requirement | --- diff --git a/minimateplus/client.py b/minimateplus/client.py index ab803c3..9a841ef 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -672,9 +672,14 @@ def _decode_a5_metadata_into(frames_data: list[bytes], event: Event) -> None: b"Seis Loc:" at data[735] b"Extended Notes" at data[774] - All frames are concatenated for a single-pass needle search. Fields already - set from the 0C waveform record are overwritten — A5 data is more complete - (the 210-byte 0C record only carries "Project:", not client/operator/etc.). + All frames are concatenated for a single-pass needle search. + + NOTE: 5A appears to return the compliance config from when the *monitoring + session first started*, not per-event config. This means: + - "Project:" from 5A must NOT overwrite a value already set from the 0C record, + because 0C carries the correct per-event project name. + - "Client:", "User Name:", "Seis Loc:", "Extended Notes" are NOT present in the + 210-byte 0C record at all, so 5A remains the sole source for those fields. Modifies event in-place. """ @@ -709,13 +714,17 @@ def _decode_a5_metadata_into(frames_data: list[bytes], event: Event) -> None: event.project_info = ProjectInfo() pi = event.project_info - # Overwrite with A5 values — they are event-time authoritative. - # 0C waveform record only carried "Project:"; A5 carries the full set. - if project: pi.project = project - if client: pi.client = client - if operator: pi.operator = operator - if location: pi.sensor_location = location - if notes: pi.notes = notes + # "project" comes from 0C (per-event, set during _decode_waveform_record_into). + # 5A returns session-start compliance config — its "project" value is NOT + # per-event authoritative. Only use the 5A project as a fallback if 0C + # didn't supply one. + # client / operator / sensor_location / notes are NOT in the 0C record at all + # (confirmed from CLAUDE.md §SUB 5A), so 5A is the sole source for those. + if project and not pi.project: pi.project = project + if client: pi.client = client + if operator: pi.operator = operator + if location: pi.sensor_location = location + if notes: pi.notes = notes log.debug( "a5 metadata: project=%r client=%r operator=%r location=%r", diff --git a/sfm/waveform_viewer.html b/sfm/waveform_viewer.html index d5d6f5f..39fec7f 100644 --- a/sfm/waveform_viewer.html +++ b/sfm/waveform_viewer.html @@ -240,6 +240,7 @@ let charts = {}; let lastData = null; let unitInfo = null; + let eventList = []; // populated from /device/events after connect let currentEventIndex = 0; function setStatus(msg, cls = '') { @@ -283,24 +284,45 @@ return; } - // Populate unit bar + // Populate unit bar from /device/info document.getElementById('u-serial').textContent = unitInfo.serial || '—'; document.getElementById('u-fw').textContent = unitInfo.firmware_version || '—'; const sr = unitInfo.compliance_config?.sample_rate; document.getElementById('u-sr').textContent = sr ? `${sr} sps` : '—'; - const count = unitInfo.event_count ?? 0; - document.getElementById('u-count').textContent = count; - // Build event chips + // Fetch real event list from /device/events — SUB 08 count is unreliable + setStatus('Fetching event list…', 'loading'); + const eventsUrl = `${apiBase}/device/events?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`; + try { + const evResp = await fetch(eventsUrl); + if (!evResp.ok) { + const err = await evResp.json().catch(() => ({ detail: evResp.statusText })); + throw new Error(err.detail || evResp.statusText); + } + const evData = await evResp.json(); + eventList = evData.events || []; + } catch (e) { + setStatus(`Error fetching events: ${e.message}`, 'error'); + btn.disabled = false; + btn.textContent = 'Reconnect'; + return; + } + + const count = eventList.length; + document.getElementById('u-count').textContent = count; + + // Build event chips with timestamps const chipsEl = document.getElementById('event-chips'); chipsEl.innerHTML = ''; - for (let i = 0; i < count; i++) { + eventList.forEach((ev, i) => { const chip = document.createElement('button'); chip.className = 'event-chip' + (i === 0 ? ' active' : ''); - chip.textContent = `Event ${i}`; + const label = ev.timestamp?.display ?? `Event ${ev.index}`; + chip.textContent = label; + chip.title = ev.record_type || ''; chip.onclick = () => selectEvent(i); chipsEl.appendChild(chip); - } + }); document.getElementById('unit-bar').style.display = 'flex'; document.getElementById('load-btn').disabled = count === 0; @@ -324,8 +346,7 @@ c.classList.toggle('active', i === idx); }); document.getElementById('prev-btn').disabled = idx <= 0; - const count = unitInfo?.event_count ?? 0; - document.getElementById('next-btn').disabled = idx >= count - 1; + document.getElementById('next-btn').disabled = idx >= eventList.length - 1; loadWaveform(); } @@ -363,7 +384,7 @@ } function stepEvent(delta) { - const count = unitInfo?.event_count ?? 0; + const count = eventList.length; const next = Math.max(0, Math.min(count - 1, currentEventIndex + delta)); selectEvent(next); }