Verify history depth with a 51840 read

The tag accepted 51840 but sent a header of 51792 and 50807 units of data,
about 69.5 days. Add the capture as a vector and correct the spec: the end
marker's length tracks delivered data, and at least one bit error changed
between reads, so the errors aren't all fixed in storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 20:28:54 -07:00
parent 053897c287
commit 43032dd845
3 changed files with 1324 additions and 9 deletions

View File

@@ -95,9 +95,9 @@ history_count(since):
`history_count` is in units. The `missing / 50` margin covers stop records, which were 1.6% of records in the capture, and the `+ 10` overlap is from the app. Example: `missing = 1000` gives 1030.
`MAX_UNITS`: the app uses 22320 (31 days) or 51840 (72 days, for one device variant only). Use 22320. A 22320 read returned 21974 records.
`MAX_UNITS`: the app uses 22320 (31 days) or 51840 (72 days, for one device variant only). 22320 is the safe default. The tested tag also accepted 51840, so use that for a first read if you want everything a tag holds. A 22320 read returned 21974 records, and a 51840 read returned 50070.
The timeouts and the reconnect delay are this spec's choices, not app values. 22320 units took 9.5 s over 564 notifications, so the 10-minute history timeout is very generous.
The timeouts and the reconnect delay are this spec's choices, not app values. 22320 units took 9.5 s and a 51840 request took 21.7 s, so the 10-minute history timeout is very generous.
A quick reconnect can fail. One attempt started about 19 s after a disconnect and did not find the tag within its 30 s connect timeout. The cause isn't known, so keep retrying.
@@ -176,7 +176,9 @@ The tag sends one live frame per second.
Records are 4 bytes, 8 hex digits: `VVV SS TT F`. Record 0 is the **newest**, and records are 2 minutes apart. They carry no timestamp, so times are derived from when the transfer starts.
A transfer is a header frame (`D15505` + 2-byte unit count), then data frames, then an end frame: `FFFEFE` + 3 bytes + zero padding. Data is zero-padded to fill its last 160-byte frame, which the zero-voltage rule below discards. The 3 bytes after `FFFEFE` equalled `units * 4 + 9` in both captured reads, so logging a mismatch is a cheap completeness check. The `+ 9` is unexplained, so don't reject a transfer on it.
A transfer is a header frame (`D15505` + 2-byte unit count), then data frames, then an end frame: `FFFEFE` + 3 bytes + zero padding. Data is zero-padded to fill its last 160-byte frame, which the zero-voltage rule below discards.
Don't rely on getting what you asked for. The tag can return fewer units than requested, and fewer than its own header says: a request for 51840 got a header of 51792 and 50807 units of data. End on `FFFEFE`, not on a count. The 3 bytes after `FFFEFE` equal `delivered_units * 4 + 9` in all captured reads, so logging a mismatch is a cheap completeness check. The `+ 9` is unexplained, so don't reject a transfer on it.
```
history_base_time(t):
@@ -212,7 +214,7 @@ parse_history(data, base):
Things to design for:
- **Range-check history.** The decoder returns values as stored, and tags can hold bit errors: `0x4E0` (12.48 V) stored as `0x0E0` (2.24 V). In the consumer, flag or skip a record with voltage under 5 or state of charge over 100. See [Hardware validation](#hardware-validation).
- **Range-check history.** The decoder returns values as read, and tags can return bit errors: `0x4E0` (12.48 V) stored as `0x0E0` (2.24 V). In the consumer, flag or skip a record with voltage under 5 or state of charge over 100. See [Hardware validation](#hardware-validation).
- **Timestamps are approximate.** They assume the newest record was taken at the base time, so the same record can come out a slot different between reads. Do not use `(time, voltage)` as a strict unique key. Filter with `since`.
- **Leading zero records.** This spec strips all of them. The app's loop has a quirk that can also skip real records after them, which is not reproduced here.
- **The tag's clock is not used.** A tag that was out of range for longer than its history depth has an unrecoverable gap.
@@ -260,7 +262,7 @@ History, requesting 10 units, with `received_at = 1793502396.67` for every frame
### Full capture replay
`findings/captures/*.jsonl` holds every frame of two real sessions, one JSON object per line, so any language can replay them:
`findings/captures/*.jsonl` holds every frame of three real sessions, one JSON object per line, so any language can replay them:
| Field | Meaning |
|---|---|
@@ -269,7 +271,7 @@ History, requesting 10 units, with `received_at = 1793502396.67` for every frame
| `cipher`, `plain` | Frame as hex, encrypted and decrypted |
| `events` | Expected events for the frame: `type` plus the fields above |
Feed `rx` rows to one decoder in order and compare with `events`. Times in the file look like `2026-11-01 03:05:00+00:00`. Skip `time` on `LiveReading`, because it was stamped a moment before `t`. Use UTC or another timezone with a whole-hour offset. The last block in `full-history` must have 21974 records and 346 stops, from `2026-10-01T14:45:00Z` to `2026-11-01T03:11:00Z`.
Feed `rx` rows to one decoder in order and compare with `events`. Times in the file look like `2026-11-01 03:05:00+00:00`. Skip `time` on `LiveReading`, because it was stamped a moment before `t`. Use UTC or another timezone with a whole-hour offset. The last block in `full-history` must have 21974 records and 346 stops, from `2026-10-01T14:45:00Z` to `2026-11-01T03:11:00Z`. The last block in `depth-51840` must have 50070 records and 737 stops, from `2026-08-23T14:27:00Z` to `2026-11-01T03:25:00Z`.
These `events` came from this repo's Python decoder, so they catch disagreement with it but aren't independent proof. They include the bit errors, as stored.
@@ -344,15 +346,17 @@ Tested 2026-10-31 on one tag (firmware 1.8) fitted to a Prius, from macOS. For t
- **Session:** the section 4 order worked. `LIVE` streams at 1 frame per second without `D15503` or `D15508`.
- **Crypto:** padding produces the same ciphertext block repeatedly within a 160-byte frame, which confirms the IV resets per block.
- **History units:** 22320 units returned 21974 records and 346 stops, which sum exactly to the request.
- **Observed history values:** 2026-10-01 to 2026-10-31, median 12.55 V, temperature 10 to 28.
- **Bit errors:** 110 of 21974 records had an impossible voltage or state of charge, all between 2026-10-04 and 2026-10-05. A cipher error in transit would garble a whole block, so these values were stored that way. Errors in that window can also produce plausible wrong values, and a flipped flag bit can create a false stop mark, which would swallow the next record and shift older timestamps by one slot.
- **History depth:** a request for 51840 returned 50070 records and 737 stops, 50807 units, reaching back about 69.5 days. Because the tag returned less than requested, a larger request is unlikely to add anything, and none was tried. It isn't known whether that is the tag's capacity or just how long it has been logging.
- **Repeat reads:** two reads 14 minutes apart agreed on 21973 of the 21974 overlapping records. Derived times for the same record differed by one slot (2 minutes) across the whole overlap, as section 5 warns.
- **Observed history values:** 2026-08-23 to 2026-11-01 UTC, median voltage 12.55 to 12.64 V for each of August to October, temperature 10 to 36.
- **Bit errors:** 110 records in the 22320 read had an impossible voltage or state of charge, all between 2026-10-04 and 2026-10-05, and none appeared in the rest of the 69.5 days. A cipher error in transit would garble a whole block, so the errors arise on the tag. 109 repeated identically on the second read, but one changed from 2.30 V to 12.54 V (`0x0E6` to `0x4E6`), so they aren't all fixed in storage. Errors in that window can also produce plausible wrong values, and a flipped flag bit can create a false stop mark, which would swallow the next record and shift older timestamps by one slot.
**Hybrids.** On a Prius, voltage doesn't show vehicle state. It has no alternator, and the DC-DC converter can drop to about 12 V while active. Don't present `status` or `start`/`stop` marks as engine state: they are untested against known vehicle state, and 73% of start marks were at 13.5 V or above, which suggests they may follow voltage. State of charge is the tag's own estimate and hasn't been validated on this car.
## Unverified
1. **Whether `ACCEPT` is needed.** It is included because the app sends it when offline.
2. **History depth**, and whether this tag accepts 51840. The one attempt failed because the tag was not found on reconnect, so the command itself was never sent.
2. **Why a read stops short of the request**, and what the header's 51792 means.
3. **Live `status` and acceleration counts while driving**, and the temperature unit.
4. **Negative temperatures.** The captured range was 10 to 28.
5. **Single connection.** Assume the tag serves one central at a time, so close the vendor app during testing.

File diff suppressed because one or more lines are too long

View File

@@ -20,6 +20,7 @@ from bm2.encryption import decrypt, encrypt
CAPTURES = Path(__file__).parent.parent / "captures"
FULL = CAPTURES / "2026-10-31-full-history.jsonl"
LIVE = CAPTURES / "2026-10-31-live-120s.jsonl"
DEPTH = CAPTURES / "2026-10-31-depth-51840.jsonl"
def load(path):
@@ -83,6 +84,9 @@ class Replay(unittest.TestCase):
def test_full_history_capture_replays(self):
self.replay(FULL)
def test_depth_capture_replays(self):
self.replay(DEPTH)
class TagFacts(unittest.TestCase):
def test_commands_on_the_wire(self):
@@ -90,6 +94,26 @@ class TagFacts(unittest.TestCase):
want = [CMD_VERSION, CMD_ACCEPT, history_command(10), history_command(22320), CMD_LIVE]
self.assertEqual(sent, [encrypt(c, BM200_KEY) for c in want])
def test_tag_can_return_less_than_requested(self):
rows = load(DEPTH)
sent = [bytes.fromhex(r["cipher"]) for r in rows if r["dir"] == "tx"]
self.assertIn(encrypt(history_command(51840), BM200_KEY), sent)
requested, body, trailer = list(history_transfers(rows))[-1]
self.assertEqual(requested, 51792) # the header, not the 51840 asked for
records = final_block(rows)
stops = sum(r["mark"] == "stop" for r in records)
self.assertEqual((len(records), stops), (50070, 737))
self.assertEqual(trailer, (len(records) + stops) * 4 + 9)
self.assertEqual(len(body) % 320, 0)
def test_repeat_reads_agree_except_one_unstable_record(self):
key = lambda r: (r["voltage"], r["soc"], r["temperature"], r["mark"])
first, second = final_block(load(FULL)), final_block(load(DEPTH))
offset = len(second) - len(first) - 8 # 8 records were logged between the reads
differ = [(a, b) for a, b in zip(first, second[offset:]) if key(a) != key(b)]
self.assertEqual(len(differ), 1)
self.assertEqual((differ[0][0]["voltage"], differ[0][1]["voltage"]), (2.3, 12.54))
def test_history_count_is_in_units(self):
since = datetime.now(timezone.utc) - timedelta(seconds=120 * 1000 + 60)
self.assertEqual(BM200Client("x", since=since).history_count(), 1030)