Skip to content

Commit e891340

Browse files
ngclaude
andcommitted
fix(biometrics): bypass cbor2 C extension that silently skips records
The cbor2 C extension (_cbor2) reads files in internal 4096-byte chunks, advancing f.tell() by 4096 regardless of actual record size. Since RAW records are 17-5000 bytes, cbor2.load(f) silently skips most records. On Pod 5, piezo-dual records are ~2700 bytes so nearly every other record was lost, severely degrading vitals accuracy. Additionally, Pod 5 firmware writes empty placeholder records (data=b'') as sequence markers. cbor2.loads(b'') raises CBORDecodeEOF (a subclass of EOFError), which the read loop caught as end-of-file, terminating reads mid-file with valid data remaining. Fix: Replace cbor2.load(f) with _read_raw_record(f) that manually parses the outer {seq, data} CBOR wrapper byte-by-byte, keeping f.tell() accurate. Empty placeholders return None and are skipped. On read errors, seek back to last known good position instead of breaking the loop. Applied to both piezo-processor and sleep-detector modules. Ref: throwaway31265/free-sleep#46 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d67b72c commit e891340

2 files changed

Lines changed: 166 additions & 4 deletions

File tree

modules/piezo-processor/main.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
from collections import deque
2828
from typing import Optional
2929

30+
import struct
31+
3032
import cbor2
3133
import numpy as np
3234
from scipy.signal import butter, filtfilt, welch
@@ -172,6 +174,78 @@ def compute_breathing_rate(samples: np.ndarray, fs: float = SAMPLE_RATE) -> Opti
172174
log.debug("Breathing rate computation failed: %s", e)
173175
return None
174176

177+
# ---------------------------------------------------------------------------
178+
# CBOR record reader
179+
# ---------------------------------------------------------------------------
180+
181+
def _read_raw_record(f):
182+
"""
183+
Manually parse one outer {seq, data} CBOR record using f.read().
184+
185+
The cbor2 C extension (_cbor2) reads files in internal 4096-byte chunks,
186+
so cbor2.load(f) advances f.tell() by 4096 bytes regardless of the actual
187+
record size. Since RAW file records are typically 17-5000 bytes, this causes
188+
nearly every record to be skipped silently.
189+
190+
This function parses the outer {seq: uint, data: bytes} wrapper byte-by-byte
191+
using f.read(), keeping f.tell() accurate after each record.
192+
193+
Returns the raw inner data bytes, or None for empty placeholder records
194+
(which the Pod firmware writes as sequence number markers with data=b'').
195+
Raises EOFError at end of file, ValueError on malformed data.
196+
"""
197+
b = f.read(1)
198+
if not b:
199+
raise EOFError
200+
if b[0] != 0xa2:
201+
raise ValueError('Expected outer map 0xa2, got 0x%02x' % b[0])
202+
if f.read(4) != b'\x63\x73\x65\x71':
203+
raise ValueError('Expected seq key')
204+
hdr = f.read(1)
205+
if not hdr:
206+
raise EOFError
207+
if hdr[0] == 0x1a:
208+
seq_bytes = f.read(4)
209+
if len(seq_bytes) < 4:
210+
raise EOFError
211+
elif hdr[0] == 0x1b:
212+
seq_bytes = f.read(8)
213+
if len(seq_bytes) < 8:
214+
raise EOFError
215+
else:
216+
raise ValueError('Unexpected seq encoding: 0x%02x' % hdr[0])
217+
if f.read(5) != b'\x64\x64\x61\x74\x61':
218+
raise ValueError('Expected data key')
219+
bs = f.read(1)
220+
if not bs:
221+
raise EOFError
222+
ai = bs[0] & 0x1f
223+
if ai <= 23:
224+
length = ai
225+
elif ai == 24:
226+
lb = f.read(1)
227+
if not lb:
228+
raise EOFError
229+
length = lb[0]
230+
elif ai == 25:
231+
lb = f.read(2)
232+
if len(lb) < 2:
233+
raise EOFError
234+
length = struct.unpack('>H', lb)[0]
235+
elif ai == 26:
236+
lb = f.read(4)
237+
if len(lb) < 4:
238+
raise EOFError
239+
length = struct.unpack('>I', lb)[0]
240+
else:
241+
raise ValueError('Unsupported length encoding: %d' % ai)
242+
data = f.read(length)
243+
if len(data) < length:
244+
raise EOFError
245+
if not data:
246+
return None # empty placeholder record, caller should skip
247+
return data
248+
175249
# ---------------------------------------------------------------------------
176250
# RAW file follower
177251
# ---------------------------------------------------------------------------
@@ -186,6 +260,7 @@ def __init__(self, data_dir: Path):
186260
self.data_dir = data_dir
187261
self._file = None
188262
self._path = None
263+
self._last_pos = 0
189264

190265
def _find_latest(self) -> Optional[Path]:
191266
candidates = sorted(self.data_dir.glob("*.RAW"), key=lambda p: p.stat().st_mtime, reverse=True)
@@ -205,16 +280,22 @@ def read_records(self):
205280
self._file.close()
206281
self._file = open(latest, "rb")
207282
self._path = latest
283+
self._last_pos = 0
208284

209285
try:
210-
record = cbor2.load(self._file)
211-
inner = cbor2.loads(record["data"])
286+
data_bytes = _read_raw_record(self._file)
287+
if data_bytes is None:
288+
self._last_pos = self._file.tell()
289+
continue # empty placeholder record
290+
inner = cbor2.loads(data_bytes)
291+
self._last_pos = self._file.tell()
212292
yield inner
213293
except EOFError:
214294
# No new data yet — poll
215295
time.sleep(0.01)
216296
except Exception as e:
217297
log.warning("Error reading RAW record: %s", e)
298+
self._file.seek(self._last_pos)
218299
time.sleep(1)
219300

220301
# ---------------------------------------------------------------------------

modules/sleep-detector/main.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
from dataclasses import dataclass, field
3030
from typing import Optional
3131

32+
import struct
33+
3234
import cbor2
3335
import numpy as np
3436

@@ -255,6 +257,78 @@ def _flush_movement(self, ts: float) -> None:
255257
self._movement_buf = []
256258
self._last_movement_write = ts
257259

260+
# ---------------------------------------------------------------------------
261+
# CBOR record reader
262+
# ---------------------------------------------------------------------------
263+
264+
def _read_raw_record(f):
265+
"""
266+
Manually parse one outer {seq, data} CBOR record using f.read().
267+
268+
The cbor2 C extension (_cbor2) reads files in internal 4096-byte chunks,
269+
so cbor2.load(f) advances f.tell() by 4096 bytes regardless of the actual
270+
record size. Since RAW file records are typically 17-5000 bytes, this causes
271+
nearly every record to be skipped silently.
272+
273+
This function parses the outer {seq: uint, data: bytes} wrapper byte-by-byte
274+
using f.read(), keeping f.tell() accurate after each record.
275+
276+
Returns the raw inner data bytes, or None for empty placeholder records
277+
(which the Pod firmware writes as sequence number markers with data=b'').
278+
Raises EOFError at end of file, ValueError on malformed data.
279+
"""
280+
b = f.read(1)
281+
if not b:
282+
raise EOFError
283+
if b[0] != 0xa2:
284+
raise ValueError('Expected outer map 0xa2, got 0x%02x' % b[0])
285+
if f.read(4) != b'\x63\x73\x65\x71':
286+
raise ValueError('Expected seq key')
287+
hdr = f.read(1)
288+
if not hdr:
289+
raise EOFError
290+
if hdr[0] == 0x1a:
291+
seq_bytes = f.read(4)
292+
if len(seq_bytes) < 4:
293+
raise EOFError
294+
elif hdr[0] == 0x1b:
295+
seq_bytes = f.read(8)
296+
if len(seq_bytes) < 8:
297+
raise EOFError
298+
else:
299+
raise ValueError('Unexpected seq encoding: 0x%02x' % hdr[0])
300+
if f.read(5) != b'\x64\x64\x61\x74\x61':
301+
raise ValueError('Expected data key')
302+
bs = f.read(1)
303+
if not bs:
304+
raise EOFError
305+
ai = bs[0] & 0x1f
306+
if ai <= 23:
307+
length = ai
308+
elif ai == 24:
309+
lb = f.read(1)
310+
if not lb:
311+
raise EOFError
312+
length = lb[0]
313+
elif ai == 25:
314+
lb = f.read(2)
315+
if len(lb) < 2:
316+
raise EOFError
317+
length = struct.unpack('>H', lb)[0]
318+
elif ai == 26:
319+
lb = f.read(4)
320+
if len(lb) < 4:
321+
raise EOFError
322+
length = struct.unpack('>I', lb)[0]
323+
else:
324+
raise ValueError('Unsupported length encoding: %d' % ai)
325+
data = f.read(length)
326+
if len(data) < length:
327+
raise EOFError
328+
if not data:
329+
return None # empty placeholder record, caller should skip
330+
return data
331+
258332
# ---------------------------------------------------------------------------
259333
# RAW file follower (same pattern as piezo-processor)
260334
# ---------------------------------------------------------------------------
@@ -264,6 +338,7 @@ def __init__(self, data_dir: Path):
264338
self.data_dir = data_dir
265339
self._file = None
266340
self._path = None
341+
self._last_pos = 0
267342

268343
def _find_latest(self):
269344
candidates = sorted(self.data_dir.glob("*.RAW"), key=lambda p: p.stat().st_mtime, reverse=True)
@@ -282,15 +357,21 @@ def read_records(self):
282357
self._file.close()
283358
self._file = open(latest, "rb")
284359
self._path = latest
360+
self._last_pos = 0
285361

286362
try:
287-
record = cbor2.load(self._file)
288-
inner = cbor2.loads(record["data"])
363+
data_bytes = _read_raw_record(self._file)
364+
if data_bytes is None:
365+
self._last_pos = self._file.tell()
366+
continue # empty placeholder record
367+
inner = cbor2.loads(data_bytes)
368+
self._last_pos = self._file.tell()
289369
yield inner
290370
except EOFError:
291371
time.sleep(0.5)
292372
except Exception as e:
293373
log.warning("Error reading RAW record: %s", e)
374+
self._file.seek(self._last_pos)
294375
time.sleep(1)
295376

296377
# Clean up file handle on shutdown

0 commit comments

Comments
 (0)