Skip to content

feat(ble): reassemble multi-fragment frames for every session command (#42) - #48

Open
kb1ibt wants to merge 1 commit into
flip-dots:mainfrom
kb1ibt:pr/reassembly
Open

feat(ble): reassemble multi-fragment frames for every session command (#42)#48
kb1ibt wants to merge 1 commit into
flip-dots:mainfrom
kb1ibt:pr/reassembly

Conversation

@kb1ibt

@kb1ibt kb1ibt commented Jul 20, 2026

Copy link
Copy Markdown

Split out of #45 per review feedback — the first of several smaller, focused PRs.

Reassembles multi-fragment BLE frames for every session command before the cipher, so telemetry and unknown session frames share one reassembler instead of only reassembling telemetry. Fixes truncated/dropped multi-packet frames on commands whose payloads exceed the MTU.

  • device.py: unify fragment reassembly across all session commands — per-command buffers, single-vs-fragment classification, and a full-length threshold derived from _FRAME_OVERHEAD (not hardcoded).
  • tests/test_reassembly.py: classification, the length threshold, 3-fragment runs, and interleaved per-command buffers.

Closes #42.

This is the base cut of the #45 split; the c490 summary decode, Prime negotiation, Prime device support, and docs will follow as separate PRs.

…flip-dots#42)

Fragment reassembly lived inside _process_telemetry_packet and only ran for
_TELEMETRY_COMMANDS, so any other multi-fragment session frame (e.g. the C2000 G2
c490 device-info blob) had only its first fragment decrypted and the rest dropped
(flip-dots#42).

Extract it into a shared _reassemble()/_join_fragments() that runs in
_process_notification ahead of the cipher split, so telemetry and unknown session
frames share one reassembler regardless of the AES variant (GCM vs CBC). Single vs
fragment is decided by the live notification length (ATT_MTU - 3, via the ff09
_FRAME_OVERHEAD) rather than the frag byte, so families that put no frag byte on
singles (the A91B2 station) need no per-device override; a short single keeps a
0x11 frag byte only when it is a valid single marker. Runs start only on index 1
and terminate on the <index><total> count (so an exact multiple of the cap, with
no short tail, still completes); a partial/cold fragment that cannot decrypt is
dropped rather than crashing the notification handler.

Adds tests/test_reassembly.py (single-no-frag, 0x11 single, two-fragment,
exact-multiple-no-tail, cold index!=1) and gives the mock client a realistic
256-byte MTU so the length gate exercises as it does on device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@flip-dots flip-dots left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not tested this yet but it looks pretty good, I have just a few questions and improvements I would like to see to the tests and comments.

Comment thread tests/test_reassembly.py
return dev


def test_short_single_without_frag_byte_kept_whole() -> None:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use test parametrization here

Comment thread SolixBLE/device.py
#: (e.g the C1000 Gen 2 uses ``c421``/``c900`` instead of ``c402``/``c405``).
_TELEMETRY_COMMANDS: tuple[str, ...] = ("c402", "4300", "c405")

#: Fixed ff09-frame overhead between the on-wire notification value and the

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is not very useful for someone who does not know thew context of this PR. It should ideally make sense on its own.

Comment thread SolixBLE/device.py
_LOGGER.debug(
f"Initializing Solix device '{ble_device.name}' with"
f"address '{ble_device.address}' and details '{ble_device.details}'"
f"address '{ble_device.address}' and details '{ble_device.details}'",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally these unrelated formatting changes would not be present in the PR since it makes reviewing more difficult but I am fine with them being left in since the tooling complains if you don't and I have not properly formatted the codebase yet.

Comment thread SolixBLE/device.py
telemetry.
def _reassemble(self, cmd: bytes, payload: bytes) -> bytes | None:
"""Reassemble a possibly-fragmented session frame into one payload.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does a good job of explaining how the fragmentation works but not what it is.

I.e maybe start the main comment body with something like:

Some Anker devices split the payload across multiple packets, this function is used to combine fragmented payloads or pass through non-fragmented payloads before they can be further processed and/or decrypted.

Comment thread SolixBLE/device.py
``None`` while fragments are still outstanding.
"""
if not payload:
return payload

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this reachable?

Comment thread SolixBLE/device.py
self._fragment_buffers[cmd_key][index] = payload[1:]
return self._join_fragments(cmd_key)

# A run only starts on a full-length first fragment. The length guard stops a

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally leave out specific terminology like a "run", something like: "subsequent fragments of a fragmented payload will always be preceded by a fragment which is the maximum size and this is used for x and the maximum size is determined by y" would get the same message across without needing to know additional context.

Comment thread SolixBLE/device.py
self._fragment_totals[cmd_key] = total
return self._join_fragments(cmd_key)

# Standalone single notification. Strip the frag byte only when it is a valid

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to stick to pre-existing terminology like fragmented, non-fragmented, etc.

Comment thread SolixBLE/device.py
return payload

def _join_fragments(self, cmd_key: bytes) -> bytes | None:
"""Join a completed fragment run, or ``None`` if more fragments are due."""

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to avoid the use of run, something like: Return the merged payload if all fragments are present in the buffer and reset or return None if fragments are still missing.

Comment thread SolixBLE/device.py
if len(self._fragment_buffers[cmd_key]) < fragment_total:
_LOGGER.debug("Waiting for remaining fragments...")
return
async def _process_telemetry_packet(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes more sense to move the _process_telemetry_packet() override from prime_device.py to device.py. Catching exceptions here isn’t super useful.

Comment thread SolixBLE/device.py

# Reassemble multi-fragment frames before the cipher, so telemetry
# and unknown session frames share one reassembler (SolixBLE #42).
payload = self._reassemble(cmd, payload)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it not make more sense to put the re-assembler before the payload is used anywhere? That way _listen_for_packet() can be used to listen for fragmented payloads?

Comment thread tests/helpers.py
mock_bleak_client.start_notify.side_effect = self.start_notify
# Emulate an Anker 256-byte ATT MTU so fragment reassembly (which gates on
# the live ``mtu_size - 3`` notification cap) behaves as it does on device.
mock_bleak_client.mtu_size = 256

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are going to need tests for different MTU sizes, someone reports the MTU size for their F2000 is 247 rather than 256.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unknown session frames aren't reassembled — multi-fragment device posts (e.g. c490) decode only their first fragment

2 participants