fix(ui): multi-byte path rendering across contact list, DM modal, retry

Same root cause as the previous console fix: meshcore lib 2.x stores
out_path_len as the masked hop count and out_path_hash_mode separately.
Several UI surfaces and the DM retry logic were still decoding the
hash-size mode from the upper bits of out_path_len, which always yields
1 for in-memory contact data and silently truncates multi-byte paths.

Fixed sites:
- /api/contacts/detailed: path_or_mode and outgoing payload now use
  out_path_hash_mode; the field is included in /api/contacts too.
- dm.js: Contact Info modal computes hashSize for the import button
  from out_path_hash_mode.
- console "contacts" command: same correction as "path".
- device_manager._paths_match / _extract_path_hex: accept hash mode as
  a parameter; callers (_dm_retry_task, _delayed_path_backfill, Phase 2
  rotation dedup) pass contact.out_path_hash_mode.
- PATH event handlers: derive hash_size from path_hash_mode instead of
  decoding it from an already-masked path_len.
This commit is contained in:
MarekWo
2026-06-05 08:54:29 +02:00
parent fecf8cdccb
commit 4effa47fe1
4 changed files with 61 additions and 38 deletions
+38 -21
View File
@@ -973,11 +973,11 @@ class DeviceManager:
# Store delivery info — use path from PATH event (actual discovered route)
ctx = self._retry_context.pop(dm_id, None)
discovered_path = data.get('path', '')
# Derive hash_size from PATH event's path_len, fallback to ctx
path_len_val = data.get('path_len')
# Derive hash_size from PATH event's path_hash_mode, fallback to ctx
disc_hash_size = ctx.get('hash_size', 1) if ctx else 1
if path_len_val is not None and path_len_val != 0xFF:
disc_hash_size = (path_len_val >> 6) + 1
path_hash_mode = data.get('path_hash_mode')
if isinstance(path_hash_mode, int) and path_hash_mode >= 0:
disc_hash_size = path_hash_mode + 1
if ctx:
self.db.update_dm_delivery_info(
dm_id, ctx['attempt'], ctx['max_attempts'], discovered_path)
@@ -1007,11 +1007,11 @@ class DeviceManager:
# Update delivery_path for recently-delivered DMs where _on_ack
# stored empty path (FLOOD mode) before PATH_UPDATE could provide it
discovered_path = data.get('path', '')
# Derive hash_size from PATH event's path_len
# Derive hash_size from PATH event's path_hash_mode
backfill_hash_size = 1
backfill_path_len = data.get('path_len')
if backfill_path_len is not None and backfill_path_len != 0xFF:
backfill_hash_size = (backfill_path_len >> 6) + 1
backfill_hash_mode = data.get('path_hash_mode')
if isinstance(backfill_hash_mode, int) and backfill_hash_mode >= 0:
backfill_hash_size = backfill_hash_mode + 1
if pubkey:
if discovered_path:
recent = self.db.get_recent_delivered_dm_with_empty_path(pubkey)
@@ -1593,26 +1593,36 @@ class DeviceManager:
@staticmethod
def _paths_match(contact_out_path: str, contact_out_path_len: int,
contact_out_path_hash_mode: int,
configured_path: dict) -> bool:
"""Check if device's current path matches a configured path."""
"""Check if device's current path matches a configured path.
contact_out_path_len holds the hop count (meshcore lib 2.x already
masks the upper bits). contact_out_path_hash_mode is the hash-size
mode: 0=1B, 1=2B, 2=3B per hop.
"""
if contact_out_path_len <= 0:
return False
cfg_hash_size = configured_path['hash_size']
device_hash_size = (contact_out_path_len >> 6) + 1
device_hash_size = max(1, contact_out_path_hash_mode + 1) if contact_out_path_hash_mode >= 0 else 1
if device_hash_size != cfg_hash_size:
return False
hop_count = contact_out_path_len & 0x3F
hop_count = contact_out_path_len
meaningful_len = hop_count * device_hash_size * 2
return (contact_out_path.lower()[:meaningful_len] ==
configured_path['path_hex'].lower()[:meaningful_len])
@staticmethod
def _extract_path_hex(out_path: str, out_path_len: int) -> str:
"""Extract meaningful hex portion from a device contact path."""
def _extract_path_hex(out_path: str, out_path_len: int, out_path_hash_mode: int = 0) -> str:
"""Extract meaningful hex portion from a device contact path.
out_path_len holds the hop count. out_path_hash_mode is the hash-size
mode: 0=1B, 1=2B, 2=3B per hop.
"""
if out_path_len <= 0 or not out_path:
return ''
hop_count = out_path_len & 0x3F
hash_size = (out_path_len >> 6) + 1
hop_count = out_path_len
hash_size = max(1, out_path_hash_mode + 1) if out_path_hash_mode >= 0 else 1
meaningful_len = hop_count * hash_size * 2
return out_path[:meaningful_len].lower() if meaningful_len > 0 else ''
@@ -1627,8 +1637,9 @@ class DeviceManager:
return
out_path = contact.get('out_path', '')
out_path_len = contact.get('out_path_len', -1)
path_hex = self._extract_path_hex(out_path, out_path_len)
bf_hash_size = ((out_path_len >> 6) + 1) if out_path_len > 0 else 1
out_path_hash_mode = contact.get('out_path_hash_mode', 0)
path_hex = self._extract_path_hex(out_path, out_path_len, out_path_hash_mode)
bf_hash_size = max(1, out_path_hash_mode + 1) if (out_path_len > 0 and out_path_hash_mode >= 0) else 1
if not path_hex:
logger.debug(f"Delayed path backfill: still no path for dm_id={dm_id}")
return
@@ -1687,6 +1698,7 @@ class DeviceManager:
# Capture original device path for dedup (contact dict may mutate)
original_out_path = contact.get('out_path', '').lower()
original_out_path_len = contact.get('out_path_len', -1)
original_out_path_hash_mode = contact.get('out_path_hash_mode', 0)
# Load user-configured paths and no_auto_flood flag
configured_paths = self.db.get_contact_paths(contact_pubkey) if contact_pubkey else []
@@ -1737,15 +1749,19 @@ class DeviceManager:
+ len(rotation_order) * retries_per_path)
else: # S4
deduped = sum(1 for p in rotation_order
if self._paths_match(original_out_path, original_out_path_len, p))
if self._paths_match(original_out_path, original_out_path_len,
original_out_path_hash_mode, p))
effective_sd = len(rotation_order) - deduped
max_attempts = 1 + cfg['direct_max_retries'] + effective_sd * retries_per_path
if not no_auto_flood:
max_attempts += cfg['flood_max_retries']
# Track current path hex and hash_size for delivery info
path_desc = self._extract_path_hex(original_out_path, original_out_path_len) if has_path else ''
path_hash_size = ((original_out_path_len >> 6) + 1) if has_path and original_out_path_len > 0 else 1
path_desc = self._extract_path_hex(original_out_path, original_out_path_len,
original_out_path_hash_mode) if has_path else ''
path_hash_size = (max(1, original_out_path_hash_mode + 1)
if has_path and original_out_path_len > 0
and original_out_path_hash_mode >= 0 else 1)
logger.info(f"DM retry task started: dm_id={dm_id}, scenario={scenario}, "
f"configured_paths={len(configured_paths)}, no_auto_flood={no_auto_flood}, "
@@ -1870,7 +1886,8 @@ class DeviceManager:
for path_info in rotation_order:
# Dedup: skip if this configured path matches original device path
if self._paths_match(original_out_path, original_out_path_len, path_info):
if self._paths_match(original_out_path, original_out_path_len,
original_out_path_hash_mode, path_info):
logger.debug(f"DM retry: skipping path '{path_info.get('label', '')}' "
f"({path_info['path_hex']}) — matches current device path")
continue
+6 -5
View File
@@ -486,12 +486,13 @@ def _execute_console_command(args: list) -> str:
pk_short = pk[:12]
opl = c.get('out_path_len', -1)
if opl > 0:
# Decode path: lower 6 bits = hop count, upper 2 bits = hash_size-1
hop_count = opl & 0x3F
hash_size = (opl >> 6) + 1
raw = c.get('out_path', '')
meaningful = raw[:hop_count * hash_size * 2]
# meshcore lib 2.x: out_path_len holds hop count; mode is in out_path_hash_mode
hop_count = opl
hash_mode = c.get('out_path_hash_mode', 0)
hash_size = max(1, hash_mode + 1) if hash_mode >= 0 else 1
chunk = hash_size * 2
raw = c.get('out_path', '')
meaningful = raw[:hop_count * chunk]
hops = [meaningful[i:i+chunk].upper() for i in range(0, len(meaningful), chunk)]
path_str = ','.join(hops) if hops else f'len:{opl}'
elif opl == 0:
+14 -10
View File
@@ -990,6 +990,7 @@ def preview_cleanup_contacts():
'lastmod': details.get('lastmod'),
'out_path_len': out_path_len,
'out_path': details.get('out_path', ''),
'out_path_hash_mode': details.get('out_path_hash_mode', 0),
'adv_lat': details.get('adv_lat'),
'adv_lon': details.get('adv_lon')
})
@@ -2901,8 +2902,9 @@ def get_contacts_detailed_api():
"type": 2, // 1=COM, 2=REP, 3=ROOM, 4=SENS
"type_label": "REP", // Human-readable type
"flags": 0,
"out_path_len": -1, // -1 = Flood mode
"out_path": "", // Path string
"out_path_len": -1, // -1 = Flood, 0 = Direct, >0 = hop count
"out_path": "", // Path bytes as hex
"out_path_hash_mode": 0, // 0=1B/hop, 1=2B/hop, 2=3B/hop
"last_advert": 1735429453, // Unix timestamp
"adv_lat": 50.866005, // GPS latitude
"adv_lon": 20.669308, // GPS longitude
@@ -2937,18 +2939,19 @@ def get_contacts_detailed_api():
blocked_keys = db.get_blocked_keys() if db else set()
for public_key, details in contacts_detailed.items():
# Compute path display string
# out_path_len encodes hop count (lower 6 bits) and hash_size (upper 2 bits)
# In MeshCore V1: hash_size=1 byte per hop, so each hop = 2 hex chars
# Compute path display string.
# meshcore lib 2.x: out_path_len already holds the hop count (6 LSB)
# and the hash-size mode is stored separately in out_path_hash_mode.
out_path_len = details.get('out_path_len', -1)
out_path_raw = details.get('out_path', '')
out_path_hash_mode = details.get('out_path_hash_mode', 0)
if out_path_len > 0 and out_path_raw:
hop_count = out_path_len & 0x3F
hash_size = (out_path_len >> 6) + 1 # 1, 2, or 3 bytes per hop
# Truncate to meaningful bytes (firmware buffer may have trailing garbage)
meaningful_hex = out_path_raw[:hop_count * hash_size * 2]
# Format as HEX→HEX→HEX (each hop is hash_size*2 hex chars)
hop_count = out_path_len
hash_size = max(1, out_path_hash_mode + 1) if out_path_hash_mode >= 0 else 1
chunk = hash_size * 2
# Truncate to meaningful bytes (firmware buffer may have trailing garbage)
meaningful_hex = out_path_raw[:hop_count * chunk]
# Format as HEX→HEX→HEX (each hop is hash_size*2 hex chars)
hops = [meaningful_hex[i:i+chunk].upper() for i in range(0, len(meaningful_hex), chunk)]
path_or_mode = ''.join(hops) if hops else out_path_raw
elif out_path_len == 0:
@@ -2963,6 +2966,7 @@ def get_contacts_detailed_api():
'flags': details.get('flags'),
'out_path_len': out_path_len,
'out_path': out_path_raw,
'out_path_hash_mode': out_path_hash_mode,
'last_advert': details.get('last_advert'),
'adv_lat': details.get('adv_lat'),
'adv_lon': details.get('adv_lon'),
+3 -2
View File
@@ -1057,8 +1057,9 @@ function populateContactInfoModal() {
} else {
const hops = mode.split('→').length;
const outPathLen = contact.out_path_len || 0;
const hashSize = outPathLen > 0 ? ((outPathLen >> 6) + 1) : 1;
const hopCount = outPathLen & 0x3F;
const hashMode = contact.out_path_hash_mode;
const hashSize = (Number.isInteger(hashMode) && hashMode >= 0) ? hashMode + 1 : 1;
const hopCount = outPathLen > 0 ? outPathLen : 0;
const pathHex = contact.out_path ? contact.out_path.substring(0, hopCount * hashSize * 2) : '';
div.innerHTML = `