@@ -97,6 +97,49 @@ def _compute_pkt_payload(channel_secret_hex, sender_timestamp, txt_type, text, a
9797 return (chan_hash + mac + ciphertext ).hex ()
9898
9999
100+ # GRP_TXT raw-packet construction for raw resend (CMD_SEND_RAW_PACKET, 0x41).
101+ # Wire layout (Packet::writeTo in MeshCore Core/src/Packet.cpp):
102+ # header(1) [transport_codes(4) if TRANSPORT_FLOOD] path_len(1) payload(N)
103+ # Packet hash (SHA256 over payload_type||payload) depends only on payload, so
104+ # resending identical bytes lets repeaters dedupe via Mesh::hasSeen.
105+ _PAYLOAD_TYPE_GRP_TXT = 0x05
106+ _ROUTE_TYPE_FLOOD = 0x01
107+ _ROUTE_TYPE_TRANSPORT_FLOOD = 0x00
108+
109+
110+ def _build_grp_txt_raw_packet (pkt_payload_hex , scope_key_hex = None , path_hash_size = 1 ):
111+ """Build raw wire bytes for a GRP_TXT packet, suitable for CMD_SEND_RAW_PACKET.
112+
113+ Replicates the firmware's TransportKey::calcTransportCode when a region
114+ scope key is provided (HMAC-SHA256 over payload_type||payload, first 2
115+ bytes, with 0x0000 and 0xFFFF reserved per TransportKeyStore.cpp).
116+
117+ Returns hex string for storage in channel_messages.raw_packet, or None if
118+ pkt_payload_hex is missing.
119+ """
120+ if not pkt_payload_hex :
121+ return None
122+ payload = bytes .fromhex (pkt_payload_hex )
123+ use_transport = bool (scope_key_hex )
124+ route_type = _ROUTE_TYPE_TRANSPORT_FLOOD if use_transport else _ROUTE_TYPE_FLOOD
125+ header = ((_PAYLOAD_TYPE_GRP_TXT & 0x0F ) << 2 ) | (route_type & 0x03 )
126+ path_len_byte = ((path_hash_size - 1 ) & 0x03 ) << 6 # hash_count = 0 on fresh send
127+
128+ out = bytes ([header ])
129+ if use_transport :
130+ mac_input = bytes ([_PAYLOAD_TYPE_GRP_TXT ]) + payload
131+ digest = hmac_mod .new (bytes .fromhex (scope_key_hex ), mac_input , hashlib .sha256 ).digest ()
132+ code = digest [:2 ]
133+ if code == b'\x00 \x00 ' :
134+ code = b'\x01 \x00 '
135+ elif code == b'\xff \xff ' :
136+ code = b'\xfe \xff '
137+ out += code + b'\x00 \x00 ' # transport_codes[1] is always 0 (set by sendFloodScoped)
138+ out += bytes ([path_len_byte ])
139+ out += payload
140+ return out .hex ()
141+
142+
100143def parse_meshcore_uri (uri : str ) -> Optional [Dict ]:
101144 """Parse meshcore://contact/add?name=...&public_key=...&type=... URI.
102145
@@ -1182,6 +1225,35 @@ async def _on_rx_log_data(self, event):
11821225 except Exception as e :
11831226 logger .error (f"Error handling RX_LOG_DATA: { e } " )
11841227
1228+ def _refresh_raw_packet_if_drifted (self , pe : dict , actual_pkt_payload : str ) -> None :
1229+ """Rebuild raw_packet when the echo's pkt_payload doesn't match our ts+0 guess.
1230+
1231+ Called from _process_echo under _echo_lock once a sent message is
1232+ correlated with its echo. If firmware ended up using a different
1233+ sender_timestamp than our local clock predicted, the raw_packet stored
1234+ at send time would resend a packet with a different hash than the
1235+ original. We rebuild from the actual pkt_payload so resend dedupes
1236+ cleanly at the repeaters.
1237+ """
1238+ guess = pe .get ('guess_pkt_payload' )
1239+ if guess == actual_pkt_payload :
1240+ return # ts+0 guess was correct, nothing to refresh
1241+ try :
1242+ scope = self .db .get_channel_scope (pe ['channel_idx' ])
1243+ except Exception as e :
1244+ logger .warning (f"Failed to fetch scope for raw_packet refresh: { e } " )
1245+ return
1246+ try :
1247+ raw_packet = _build_grp_txt_raw_packet (
1248+ actual_pkt_payload ,
1249+ scope_key_hex = scope ['key_hex' ] if scope else None ,
1250+ )
1251+ if raw_packet :
1252+ self .db .update_message_raw_packet (pe ['msg_id' ], raw_packet )
1253+ logger .debug (f"Refreshed raw_packet for msg #{ pe ['msg_id' ]} (clock-drift correction)" )
1254+ except Exception as e :
1255+ logger .warning (f"Failed to refresh raw_packet for msg #{ pe ['msg_id' ]} : { e } " )
1256+
11851257 def _get_channel_hash (self , channel_idx : int ) -> str :
11861258 """Get the expected channel hash byte (hex) for a channel index."""
11871259 import hashlib
@@ -1217,6 +1289,7 @@ def _process_echo(self, pkt_payload: str, path: str, snr: float = None,
12171289 pe ['pkt_payload' ] = pkt_payload
12181290 direction = 'sent'
12191291 self .db .update_message_pkt_payload (pe ['msg_id' ], pkt_payload )
1292+ self ._refresh_raw_packet_if_drifted (pe , pkt_payload )
12201293 logger .info (f"Echo: matched pkt_payload with sent msg #{ pe ['msg_id' ]} , path={ path } " )
12211294 else :
12221295 logger .debug (f"Echo: pkt_payload doesn't match expected candidates — not our sent msg" )
@@ -1228,6 +1301,7 @@ def _process_echo(self, pkt_payload: str, path: str, snr: float = None,
12281301 pe ['pkt_payload' ] = pkt_payload
12291302 direction = 'sent'
12301303 self .db .update_message_pkt_payload (pe ['msg_id' ], pkt_payload )
1304+ self ._refresh_raw_packet_if_drifted (pe , pkt_payload )
12311305 logger .info (f"Echo: correlated pkt_payload with sent msg #{ pe ['msg_id' ]} (channel hash fallback), path={ path } " )
12321306 elif expected_hash and echo_hash and expected_hash != echo_hash :
12331307 logger .debug (f"Echo: channel hash mismatch (expected { expected_hash } , got { echo_hash } ) — not our sent msg" )
@@ -1600,16 +1674,32 @@ def send_channel_message(self, channel_idx: int, text: str) -> Dict:
16001674 # may map this idx to a different (or removed) channel.
16011675 secret = self ._refresh_channel_secret (channel_idx )
16021676 expected_payloads = set ()
1677+ guess_pkt_payload = None
16031678 if secret and self .device_name :
16041679 full_text = f"{ self .device_name } : { text } "
16051680 for dt in range (- 3 , 4 ):
16061681 try :
1607- expected_payloads .add (
1608- _compute_pkt_payload (secret , ts + dt , 0 , full_text )
1609- )
1682+ candidate = _compute_pkt_payload (secret , ts + dt , 0 , full_text )
1683+ expected_payloads .add (candidate )
1684+ if dt == 0 :
1685+ guess_pkt_payload = candidate
16101686 except Exception :
16111687 pass
16121688
1689+ # Capture raw_packet for raw resend. We use the ts+0 guess up front;
1690+ # if echo correlation later matches a different ±dt candidate, the
1691+ # _process_echo path rebuilds raw_packet from the actual pkt_payload.
1692+ if guess_pkt_payload :
1693+ try :
1694+ raw_packet = _build_grp_txt_raw_packet (
1695+ guess_pkt_payload ,
1696+ scope_key_hex = scope ['key_hex' ] if scope else None ,
1697+ )
1698+ if raw_packet :
1699+ self .db .update_message_raw_packet (msg_id , raw_packet )
1700+ except Exception as e :
1701+ logger .warning (f"Failed to build raw_packet for msg #{ msg_id } : { e } " )
1702+
16131703 # Register for echo correlation
16141704 with self ._echo_lock :
16151705 self ._pending_echo = {
@@ -1618,6 +1708,7 @@ def send_channel_message(self, channel_idx: int, text: str) -> Dict:
16181708 'msg_id' : msg_id ,
16191709 'pkt_payload' : None ,
16201710 'expected_payloads' : expected_payloads or None ,
1711+ 'guess_pkt_payload' : guess_pkt_payload ,
16211712 }
16221713
16231714 # Emit SocketIO event so sender's UI updates immediately
0 commit comments