1- """
2- Implements APS fragmentation reassembly on the EZSP Host side,
1+ """Implements APS fragmentation reassembly on the EZSP Host side,
32mirroring the logic from fragmentation.c in the EmberZNet stack.
43"""
54
65import asyncio
76import logging
8- from collections import defaultdict
9- from typing import Optional , Dict , Tuple
7+ from typing import Dict , Optional , Tuple
108
119LOGGER = logging .getLogger (__name__ )
1210
1715# store partial data keyed by (sender, aps_sequence, profile_id, cluster_id)
1816FragmentKey = Tuple [int , int , int , int ]
1917
18+
2019class _FragmentEntry :
2120 def __init__ (self , fragment_count : int ):
2221 self .fragment_count = fragment_count
2322 self .fragments_received = 0
2423 self .fragment_data = {}
2524 self .start_time = asyncio .get_event_loop ().time ()
26-
25+
2726 def add_fragment (self , index : int , data : bytes ) -> None :
2827 if index not in self .fragment_data :
2928 self .fragment_data [index ] = data
@@ -33,32 +32,41 @@ def is_complete(self) -> bool:
3332 return self .fragments_received == self .fragment_count
3433
3534 def assemble (self ) -> bytes :
36- return b'' .join (self .fragment_data [i ] for i in sorted (self .fragment_data .keys ()))
35+ return b"" .join (
36+ self .fragment_data [i ] for i in sorted (self .fragment_data .keys ())
37+ )
38+
3739
3840class FragmentManager :
3941 def __init__ (self ):
4042 self ._partial : Dict [FragmentKey , _FragmentEntry ] = {}
41-
42- def handle_incoming_fragment (self , sender_nwk : int , aps_sequence : int , profile_id : int , cluster_id : int ,
43- group_id : int , payload : bytes ) -> Tuple [bool , Optional [bytes ], int , int ]:
44- """
45- Handle a newly received fragment. The group_id field
46- encodes high byte = total fragment count, low byte = current fragment index.
43+ self ._cleanup_timers : Dict [FragmentKey , asyncio .TimerHandle ] = {}
44+
45+ def handle_incoming_fragment (
46+ self ,
47+ sender_nwk : int ,
48+ aps_sequence : int ,
49+ profile_id : int ,
50+ cluster_id : int ,
51+ fragment_count : int ,
52+ fragment_index : int ,
53+ payload : bytes ,
54+ ) -> Tuple [bool , Optional [bytes ], int , int ]:
55+ """Handle a newly received fragment.
4756
4857 :param sender_nwk: NWK address or the short ID of the sender.
4958 :param aps_sequence: The APS sequence from the incoming APS frame.
5059 :param profile_id: The APS frame's profileId.
5160 :param cluster_id: The APS frame's clusterId.
52- :param group_id: The APS frame's groupId (used to store fragment # / total).
61+ :param fragment_count: The total number of expected message fragments.
62+ :param fragment_index: The index of the current fragment being processed.
5363 :param payload: The fragment of data for this message.
5464 :return: (complete, reassembled_data, fragment_count, fragment_index)
5565 complete = True if we have all fragments now, else False
5666 reassembled_data = the final complete payload (bytes) if complete is True
5767 fragment_coutn = the total number of fragments holding the complete packet
5868 fragment_index = the index of the current received fragment
5969 """
60- fragment_count = (group_id >> 8 ) & 0xFF
61- fragment_index = group_id & 0xFF
6270
6371 key : FragmentKey = (sender_nwk , aps_sequence , profile_id , cluster_id )
6472
@@ -69,30 +77,44 @@ def handle_incoming_fragment(self, sender_nwk: int, aps_sequence: int, profile_i
6977 else :
7078 entry = self ._partial [key ]
7179
72- LOGGER .debug ("Received fragment %d/%d from %s (APS seq=%d, cluster=0x%04X)" ,
73- fragment_index , fragment_count , sender_nwk , aps_sequence , cluster_id )
80+ LOGGER .debug (
81+ "Received fragment %d/%d from %s (APS seq=%d, cluster=0x%04X)" ,
82+ fragment_index + 1 ,
83+ fragment_count ,
84+ sender_nwk ,
85+ aps_sequence ,
86+ cluster_id ,
87+ )
7488
7589 entry .add_fragment (fragment_index , payload )
7690
91+ loop = asyncio .get_running_loop ()
92+ self ._cleanup_timers [key ] = loop .call_later (
93+ FRAGMENT_TIMEOUT , self .cleanup_partial , key
94+ )
95+
7796 if entry .is_complete ():
7897 reassembled = entry .assemble ()
7998 del self ._partial [key ]
80- LOGGER .debug ("Message reassembly complete. Total length=%d" , len (reassembled ))
99+ timer = self ._cleanup_timers .pop (key , None )
100+ if timer :
101+ timer .cancel ()
102+ LOGGER .debug (
103+ "Message reassembly complete. Total length=%d" , len (reassembled )
104+ )
81105 return (True , reassembled , fragment_count , fragment_index )
82106 else :
83107 return (False , None , fragment_count , fragment_index )
84108
85- def cleanup_expired (self ) -> None :
109+ def cleanup_partial (self , key : FragmentKey ):
110+ # Called when FRAGMENT_TIMEOUT passes with no new fragments for that key.
111+ LOGGER .debug (
112+ "Timeout for partial reassembly of fragmented message, discarding key=%s" ,
113+ key ,
114+ )
115+ self ._partial .pop (key , None )
116+ self ._cleanup_timers .pop (key , None )
86117
87- now = asyncio .get_event_loop ().time ()
88- to_remove = []
89- for k , entry in self ._partial .items ():
90- if now - entry .start_time > FRAGMENT_TIMEOUT :
91- to_remove .append (k )
92- for k in to_remove :
93- del self ._partial [k ]
94- LOGGER .debug ("Removed stale fragment reassembly for key=%s" , k )
95118
96119# Create a single global manager instance
97120fragment_manager = FragmentManager ()
98-
0 commit comments