Skip to content

Commit fa0b1c9

Browse files
MarekWoclaude
andcommitted
feat(channels): capture raw_packet at send time for raw resend
PR #2 of 5. Builds the full GRP_TXT wire bytes (header + transport_codes if scoped + path_len + encrypted payload) from the ts+0 pkt_payload guess and stores it in channel_messages.raw_packet right after the send. When echo correlation later identifies the actual pkt_payload (potentially using a different ±dt candidate due to host/firmware clock drift), the raw_packet is rebuilt from the actual one so a future resend matches the original packet hash and dedupes at the repeaters. Transport-scope codes are computed in Python via HMAC-SHA256(scope_key, payload_type||payload)[:2], mirroring TransportKey::calcTransportCode in MeshCore Core (including the 0x0000/0xFFFF reservations). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent bed838c commit fa0b1c9

2 files changed

Lines changed: 102 additions & 3 deletions

File tree

app/database.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,14 @@ def update_message_pkt_payload(self, msg_id: int, pkt_payload: str) -> None:
993993
(pkt_payload, msg_id)
994994
)
995995

996+
def update_message_raw_packet(self, msg_id: int, raw_packet: str) -> None:
997+
"""Set raw_packet on a channel message (full wire bytes for raw resend)."""
998+
with self._connect() as conn:
999+
conn.execute(
1000+
"UPDATE channel_messages SET raw_packet = ? WHERE id = ?",
1001+
(raw_packet, msg_id)
1002+
)
1003+
9961004
# ================================================================
9971005
# Paths
9981006
# ================================================================

app/device_manager.py

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
100143
def 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

Comments
 (0)