From f3f53092259a6ec334c505a9efb6f0306e3842d2 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 1 Aug 2026 12:50:48 -0600 Subject: [PATCH 1/3] fix(meshcore): restore chat hop pills for packed pathLen and late RF Unpack companion flood pathLen (multibyte hash modes), correlate attributed TXT_MSG rows, and enrich chat hops from late event 136 with a brief UI cue when RF corrects a stored count. --- src/renderer/components/ChatPanel.tsx | 8 +- .../components/ChatRfHopLabel.test.tsx | 52 +++ src/renderer/components/ChatRfHopLabel.tsx | 65 ++++ .../lib/meshcore/meshcoreRfRxRuntime.ts | 16 + .../lib/meshcoreLateRfHopEnrichment.test.ts | 254 ++++++++++++++ .../lib/meshcoreLateRfHopEnrichment.ts | 310 ++++++++++++++++++ .../lib/meshcoreRawPacketCorrelate.test.ts | 13 +- .../lib/meshcoreRawPacketCorrelate.ts | 4 +- src/renderer/lib/meshcoreUtils.test.ts | 14 +- src/renderer/lib/meshcoreUtils.ts | 11 +- .../lib/protocols/MeshCoreProtocol.test.ts | 15 + src/renderer/locales/cs/translation.json | 3 +- src/renderer/locales/de/translation.json | 3 +- src/renderer/locales/en/translation.json | 1 + src/renderer/locales/es/translation.json | 3 +- src/renderer/locales/fr/translation.json | 3 +- src/renderer/locales/id/translation.json | 3 +- src/renderer/locales/it/translation.json | 3 +- src/renderer/locales/ja/translation.json | 3 +- src/renderer/locales/ko/translation.json | 3 +- src/renderer/locales/nl/translation.json | 3 +- src/renderer/locales/pl/translation.json | 3 +- src/renderer/locales/pt-BR/translation.json | 3 +- src/renderer/locales/ru/translation.json | 3 +- src/renderer/locales/tr/translation.json | 3 +- src/renderer/locales/uk/translation.json | 3 +- src/renderer/locales/zh/translation.json | 3 +- vitest.config.mts | 7 +- 28 files changed, 774 insertions(+), 41 deletions(-) create mode 100644 src/renderer/components/ChatRfHopLabel.test.tsx create mode 100644 src/renderer/components/ChatRfHopLabel.tsx create mode 100644 src/renderer/lib/meshcoreLateRfHopEnrichment.test.ts create mode 100644 src/renderer/lib/meshcoreLateRfHopEnrichment.ts diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index 41b3e99af..e5d3bc490 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -148,6 +148,7 @@ import { reticulumHashForNodeId, useReticulumPeerStore } from '../stores/reticul import { useTimeFormatStore } from '../stores/timeFormatStore'; import { ChatComposer, type ChatComposerSendOpts } from './ChatComposer'; import { ChatPayloadText } from './ChatPayloadText'; +import { ChatRfHopLabel } from './ChatRfHopLabel'; import { HelpTooltip } from './HelpTooltip'; import { MessageStatusBadge } from './MessageStatusBadge'; import { ChatDmRncpControl } from './remote/ChatDmRncpControl'; @@ -2717,12 +2718,7 @@ function ChatPanel({
{msg.rxHops != null && (msg.receivedVia === 'rf' || msg.receivedVia === 'both') && ( - - {t('nodeDetailModal.hopLabel', { count: msg.rxHops })} - + )} {msg.viaStoreForward && } {msg.receivedVia && ( diff --git a/src/renderer/components/ChatRfHopLabel.test.tsx b/src/renderer/components/ChatRfHopLabel.test.tsx new file mode 100644 index 000000000..8a166189f --- /dev/null +++ b/src/renderer/components/ChatRfHopLabel.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + markMeshcoreHopCorrected, + resetMeshcoreHopCorrectedMarksForTests, +} from '../lib/meshcoreLateRfHopEnrichment'; +import { ChatRfHopLabel, chatRfHopLabelPresentation } from './ChatRfHopLabel'; + +describe('chatRfHopLabelPresentation', () => { + it('uses amber accent only when corrected and motion is allowed', () => { + expect(chatRfHopLabelPresentation(false, false).className).toContain('text-gray-500'); + expect(chatRfHopLabelPresentation(true, false).className).toContain('text-amber-400'); + expect(chatRfHopLabelPresentation(true, true).className).toContain('text-gray-500'); + expect(chatRfHopLabelPresentation(true, true).refined).toBe(true); + expect(chatRfHopLabelPresentation(false, false).refined).toBe(false); + }); +}); + +describe('ChatRfHopLabel', () => { + afterEach(() => { + cleanup(); + resetMeshcoreHopCorrectedMarksForTests(); + }); + + it('renders hop count with default title when not corrected', () => { + render( + , + ); + expect(screen.getByText('3 hops')).toBeInTheDocument(); + expect(screen.getByText('3 hops')).toHaveAttribute( + 'title', + expect.stringMatching(/hop|routing/i), + ); + }); + + it('uses refined title when a correction mark is active', () => { + markMeshcoreHopCorrected('ch:0:2:x'); + render( + , + ); + expect(screen.getByText('4 hops')).toHaveAttribute('title', 'Updated from RF path'); + expect(screen.getByText('4 hops').className).toContain('text-amber-400'); + }); +}); diff --git a/src/renderer/components/ChatRfHopLabel.tsx b/src/renderer/components/ChatRfHopLabel.tsx new file mode 100644 index 000000000..59b55bef8 --- /dev/null +++ b/src/renderer/components/ChatRfHopLabel.tsx @@ -0,0 +1,65 @@ +import { useSyncExternalStore } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useReduceMotion } from '@/renderer/lib/icons/iconMotionContext'; +import { + isMeshcoreHopCorrected, + meshcoreChatHopUiKey, + subscribeMeshcoreHopCorrected, +} from '@/renderer/lib/meshcoreLateRfHopEnrichment'; + +export interface ChatRfHopLabelProps { + rxHops: number; + msg: { + storeId?: string; + id?: number; + sender_id: number; + timestamp: number; + channel: number; + }; +} + +/** Class/title for the hop pill when a late RF correction mark is active. */ +export function chatRfHopLabelPresentation( + corrected: boolean, + reduceMotion: boolean, +): { className: string; refined: boolean } { + if (!corrected) { + return { + className: 'text-[10px] text-gray-500 transition-colors duration-500', + refined: false, + }; + } + if (reduceMotion) { + return { + className: 'text-[10px] text-gray-500 transition-colors duration-500', + refined: true, + }; + } + return { + className: 'text-[10px] text-amber-400/80 transition-colors duration-500', + refined: true, + }; +} + +/** Incoming RF hop count; briefly accents when late event 136 corrected a stored value. */ +export function ChatRfHopLabel({ rxHops, msg }: ChatRfHopLabelProps) { + const { t } = useTranslation(); + const reduceMotion = useReduceMotion(); + const uiKey = meshcoreChatHopUiKey(msg); + const corrected = useSyncExternalStore( + subscribeMeshcoreHopCorrected, + () => isMeshcoreHopCorrected(uiKey), + () => false, + ); + const { className, refined } = chatRfHopLabelPresentation(corrected, reduceMotion); + const title = refined + ? t('chatPanel.hopCountRefinedFromRf') + : t('nodeDetailModal.hopsFromRoutingTitle'); + + return ( + + {t('nodeDetailModal.hopLabel', { count: rxHops })} + + ); +} diff --git a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts index 5c4836b3c..e810a0c19 100644 --- a/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts +++ b/src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts @@ -20,6 +20,7 @@ import { meshtasticSenderIdForRawLogFallback, type PacketClass, } from '../foreignLoraDetection'; +import { applyMeshcoreLateRfHopEnrichment } from '../meshcoreLateRfHopEnrichment'; import { meshcoreRawPacketLogFromBytesFallback, meshcoreRawPacketResolveFromParsed, @@ -642,6 +643,21 @@ export function handleMeshcoreRfRx(payload: MeshcoreRfRxPayload, deps: MeshcoreR const rxEntry = buildMeshcoreRfRawPacketEntry(ctx, effectiveFromNodeId, now, snr, rssi, rawU8); pushMeshcoreRfRawPacketLog(deps, rxEntry); + if ( + ctx.parseOk && + (ctx.payloadTypeString === 'TXT_MSG' || ctx.payloadTypeString === 'GRP_TXT') + ) { + applyMeshcoreLateRfHopEnrichment(deps.meshcoreIdentityIdRef.current, { + payloadTypeString: ctx.payloadTypeString, + hopCount: ctx.hopCount, + fromNodeId: effectiveFromNodeId, + messageFingerprintHex: ctx.messageFingerprintHex, + parseOk: true, + now, + myNodeNum: deps.myNodeNumRef.current, + }); + } + mqttFields = buildMeshcoreRfMqttPacketLogFields(ctx, rawU8); recordMeshcoreRfNoisePorts(ctx, effectiveFromNodeId); diff --git a/src/renderer/lib/meshcoreLateRfHopEnrichment.test.ts b/src/renderer/lib/meshcoreLateRfHopEnrichment.test.ts new file mode 100644 index 000000000..8c08d51c1 --- /dev/null +++ b/src/renderer/lib/meshcoreLateRfHopEnrichment.test.ts @@ -0,0 +1,254 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { upsertMessage, useMessageStore } from '../stores/messageStore'; +import { + applyMeshcoreLateRfHopEnrichment, + findMeshcoreLateRfHopEnrichmentTarget, + isMeshcoreHopCorrected, + markMeshcoreHopCorrected, + type MeshcoreLateHopCandidate, + resetMeshcoreHopCorrectedMarksForTests, + shouldApplyMeshcoreRfHopEnrichment, +} from './meshcoreLateRfHopEnrichment'; + +const ID = 'meshcore:test-late-hops'; + +function candidate( + partial: Partial & Pick, +): MeshcoreLateHopCandidate { + return { + sender_id: 0xabc, + channel: 0, + timestamp: Date.now(), + receivedVia: 'rf', + ...partial, + }; +} + +describe('shouldApplyMeshcoreRfHopEnrichment', () => { + it('applies when hops are missing or disagree; ignores parseOk false', () => { + expect(shouldApplyMeshcoreRfHopEnrichment(undefined, 2, true)).toBe(true); + expect(shouldApplyMeshcoreRfHopEnrichment(1, 2, true)).toBe(true); + expect(shouldApplyMeshcoreRfHopEnrichment(2, 2, true)).toBe(false); + expect(shouldApplyMeshcoreRfHopEnrichment(undefined, 2, false)).toBe(false); + }); +}); + +describe('findMeshcoreLateRfHopEnrichmentTarget', () => { + const now = 1_700_000_000_000; + + it('prefers fingerprint match over window heuristic', () => { + const candidates = [ + candidate({ + storeId: 'window', + timestamp: now - 100, + rxHops: undefined, + }), + candidate({ + storeId: 'fp', + timestamp: now - 2500, + rxHops: undefined, + rxPacketFingerprintHex: 'AABBCCDD', + }), + ]; + const hit = findMeshcoreLateRfHopEnrichmentTarget(candidates, { + payloadTypeString: 'GRP_TXT', + hopCount: 3, + fromNodeId: null, + messageFingerprintHex: 'aabbccdd', + parseOk: true, + now, + }); + expect(hit?.storeId).toBe('fp'); + }); + + it('selects most recent channel message needing hops', () => { + const candidates = [ + candidate({ storeId: 'old', timestamp: now - 2000, rxHops: undefined }), + candidate({ storeId: 'new', timestamp: now - 100, rxHops: undefined }), + ]; + const hit = findMeshcoreLateRfHopEnrichmentTarget(candidates, { + payloadTypeString: 'GRP_TXT', + hopCount: 2, + fromNodeId: null, + messageFingerprintHex: null, + parseOk: true, + now, + }); + expect(hit?.storeId).toBe('new'); + }); + + it('prefers DM sender matching fromNodeId', () => { + const candidates = [ + candidate({ + storeId: 'other', + channel: -1, + sender_id: 0x111, + timestamp: now - 50, + rxHops: undefined, + }), + candidate({ + storeId: 'match', + channel: -1, + sender_id: 0xabc, + timestamp: now - 200, + rxHops: undefined, + }), + ]; + const hit = findMeshcoreLateRfHopEnrichmentTarget(candidates, { + payloadTypeString: 'TXT_MSG', + hopCount: 1, + fromNodeId: 0xabc, + messageFingerprintHex: null, + parseOk: true, + now, + }); + expect(hit?.storeId).toBe('match'); + }); + + it('skips candidates that already have the same hop count', () => { + const candidates = [candidate({ storeId: 'same', timestamp: now - 100, rxHops: 2 })]; + expect( + findMeshcoreLateRfHopEnrichmentTarget(candidates, { + payloadTypeString: 'GRP_TXT', + hopCount: 2, + fromNodeId: null, + messageFingerprintHex: null, + parseOk: true, + now, + }), + ).toBeUndefined(); + }); + + it('ignores parseOk false', () => { + const candidates = [candidate({ storeId: 'a', timestamp: now - 100, rxHops: undefined })]; + expect( + findMeshcoreLateRfHopEnrichmentTarget(candidates, { + payloadTypeString: 'GRP_TXT', + hopCount: 1, + fromNodeId: null, + messageFingerprintHex: null, + parseOk: false, + now, + }), + ).toBeUndefined(); + }); +}); + +describe('applyMeshcoreLateRfHopEnrichment', () => { + const saveMeshcoreMessage = vi.fn().mockResolvedValue(undefined); + + beforeEach(() => { + useMessageStore.setState({ messages: {} }); + resetMeshcoreHopCorrectedMarksForTests(); + vi.spyOn(window.electronAPI.db, 'saveMeshcoreMessage').mockImplementation(saveMeshcoreMessage); + saveMeshcoreMessage.mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetMeshcoreHopCorrectedMarksForTests(); + }); + + it('fills missing hops without marking corrected', () => { + const now = Date.now(); + upsertMessage(ID, { + id: 'ch:0:1:hi', + from: 0xabc, + to: 0xffffffff, + payload: 'hi', + channelIndex: 0, + timestamp: now, + receivedVia: 'rf', + }); + const result = applyMeshcoreLateRfHopEnrichment(ID, { + payloadTypeString: 'GRP_TXT', + hopCount: 3, + fromNodeId: 0xabc, + messageFingerprintHex: null, + parseOk: true, + now, + myNodeNum: 1, + }); + expect(result).toMatchObject({ + storeId: 'ch:0:1:hi', + previousRxHops: undefined, + nextRxHops: 3, + corrected: false, + }); + expect(isMeshcoreHopCorrected('ch:0:1:hi')).toBe(false); + expect(useMessageStore.getState().messages[ID]['ch:0:1:hi'].rxHops).toBe(3); + expect(saveMeshcoreMessage).toHaveBeenCalled(); + }); + + it('replaces disagreeing hops and marks corrected', () => { + const now = Date.now(); + upsertMessage(ID, { + id: 'ch:0:2:hi', + from: 0xabc, + to: 0xffffffff, + payload: 'hi', + channelIndex: 0, + timestamp: now, + receivedVia: 'rf', + rxHops: 1, + hopCount: 1, + }); + const result = applyMeshcoreLateRfHopEnrichment(ID, { + payloadTypeString: 'GRP_TXT', + hopCount: 4, + fromNodeId: 0xabc, + messageFingerprintHex: null, + parseOk: true, + now, + myNodeNum: 1, + }); + expect(result).toMatchObject({ + previousRxHops: 1, + nextRxHops: 4, + corrected: true, + }); + expect(isMeshcoreHopCorrected('ch:0:2:hi')).toBe(true); + expect(useMessageStore.getState().messages[ID]['ch:0:2:hi'].rxHops).toBe(4); + }); + + it('no-ops when hops already match', () => { + const now = Date.now(); + upsertMessage(ID, { + id: 'ch:0:3:hi', + from: 0xabc, + to: 0xffffffff, + payload: 'hi', + channelIndex: 0, + timestamp: now, + receivedVia: 'rf', + rxHops: 2, + }); + expect( + applyMeshcoreLateRfHopEnrichment(ID, { + payloadTypeString: 'GRP_TXT', + hopCount: 2, + fromNodeId: null, + messageFingerprintHex: null, + parseOk: true, + now, + myNodeNum: 1, + }), + ).toBeNull(); + expect(saveMeshcoreMessage).not.toHaveBeenCalled(); + }); +}); + +describe('markMeshcoreHopCorrected', () => { + afterEach(() => { + resetMeshcoreHopCorrectedMarksForTests(); + }); + + it('expires after TTL', () => { + const t0 = 1_000_000; + markMeshcoreHopCorrected('msg-1', t0, 50); + expect(isMeshcoreHopCorrected('msg-1', t0 + 10)).toBe(true); + expect(isMeshcoreHopCorrected('msg-1', t0 + 60)).toBe(false); + }); +}); diff --git a/src/renderer/lib/meshcoreLateRfHopEnrichment.ts b/src/renderer/lib/meshcoreLateRfHopEnrichment.ts new file mode 100644 index 000000000..8a7fba552 --- /dev/null +++ b/src/renderer/lib/meshcoreLateRfHopEnrichment.ts @@ -0,0 +1,310 @@ +/** + * Late event-136 → chat-row hop enrichment. + * + * When companion events 7/8 ingest before raw RF RX (136), or companion pathLen is + * missing/wrong, patch the matching chat row from a successful RF parse. RF on-air + * path length is authoritative when it disagrees with a stored hop count. + */ +import { + MESHCORE_ROOM_MESSAGE_CHANNEL, + messageToDbRow, +} from '../hooks/meshcore/meshcoreHookPreamble'; +import { upsertMessage, useMessageStore } from '../stores/messageStore'; +import { errLikeToLogString } from './errLikeToLogString'; +import { MESHCORE_CHAT_CORRELATE_WINDOW_MS } from './meshcoreRawPacketCorrelate'; +import { effectiveMessageTimestampMs } from './nodeStatus'; +import { messageRecordToChatMessage } from './storeRecordAdapters'; +import type { ChatMessage, IdentityId } from './types'; + +/** How long the Chat hop label shows the “refined from RF” accent after a correction. */ +export const MESHCORE_HOP_CORRECTED_UI_TTL_MS = 2000; + +export interface MeshcoreLateHopCandidate { + storeId: string; + sender_id: number; + channel: number; + timestamp: number; + rxHops?: number; + receivedVia?: ChatMessage['receivedVia']; + roomServerId?: number; + rxPacketFingerprintHex?: string; + status?: ChatMessage['status']; +} + +export interface MeshcoreLateRfHopEnrichmentInput { + payloadTypeString: 'TXT_MSG' | 'GRP_TXT'; + hopCount: number; + fromNodeId: number | null; + messageFingerprintHex: string | null; + parseOk: boolean; + now?: number; + myNodeNum?: number; + windowMs?: number; +} + +export interface MeshcoreLateRfHopEnrichmentResult { + storeId: string; + previousRxHops: number | undefined; + nextRxHops: number; + /** True when a previously stored hop count was replaced (not first fill). */ + corrected: boolean; +} + +type HopCorrectedListener = () => void; + +const hopCorrectedUntilByStoreId = new Map(); +const hopCorrectedListeners = new Set(); +const hopCorrectedClearTimers = new Map>(); + +function notifyHopCorrectedListeners(): void { + for (const listener of hopCorrectedListeners) { + try { + listener(); + } catch (e) { + console.warn( + '[meshcoreLateRfHopEnrichment] hop-corrected listener failed ' + errLikeToLogString(e), + ); + } + } +} + +/** Session-only mark: Chat briefly highlights the hop label after a late RF correction. */ +export function markMeshcoreHopCorrected( + storeId: string, + now: number = Date.now(), + ttlMs: number = MESHCORE_HOP_CORRECTED_UI_TTL_MS, +): void { + if (!storeId) return; + const until = now + ttlMs; + hopCorrectedUntilByStoreId.set(storeId, until); + const prior = hopCorrectedClearTimers.get(storeId); + if (prior != null) clearTimeout(prior); + hopCorrectedClearTimers.set( + storeId, + setTimeout(() => { + hopCorrectedClearTimers.delete(storeId); + const exp = hopCorrectedUntilByStoreId.get(storeId); + if (exp != null && exp <= Date.now()) { + hopCorrectedUntilByStoreId.delete(storeId); + notifyHopCorrectedListeners(); + } + }, ttlMs + 25), + ); + notifyHopCorrectedListeners(); +} + +export function isMeshcoreHopCorrected( + storeId: string | null | undefined, + now: number = Date.now(), +): boolean { + if (!storeId) return false; + const until = hopCorrectedUntilByStoreId.get(storeId); + if (until == null) return false; + if (until <= now) { + hopCorrectedUntilByStoreId.delete(storeId); + return false; + } + return true; +} + +export function subscribeMeshcoreHopCorrected(listener: HopCorrectedListener): () => void { + hopCorrectedListeners.add(listener); + return () => { + hopCorrectedListeners.delete(listener); + }; +} + +/** Test helper — clears session correction marks. */ +export function resetMeshcoreHopCorrectedMarksForTests(): void { + for (const timer of hopCorrectedClearTimers.values()) clearTimeout(timer); + hopCorrectedClearTimers.clear(); + hopCorrectedUntilByStoreId.clear(); + notifyHopCorrectedListeners(); +} + +export function shouldApplyMeshcoreRfHopEnrichment( + existingRxHops: number | undefined, + rfHopCount: number, + parseOk: boolean, +): boolean { + if (!parseOk || !Number.isFinite(rfHopCount)) return false; + const hops = Math.trunc(rfHopCount); + if (hops < 0 || hops > 63) return false; + if (existingRxHops == null) return true; + return existingRxHops !== hops; +} + +function isBroadcastChannelCandidate(c: MeshcoreLateHopCandidate): boolean { + return c.channel >= 0 && c.roomServerId == null && c.channel !== MESHCORE_ROOM_MESSAGE_CHANNEL; +} + +function isDmCandidate(c: MeshcoreLateHopCandidate): boolean { + return c.channel === -1 && c.roomServerId == null; +} + +function normalizeFingerprint(hex: string | null | undefined): string | null { + if (typeof hex !== 'string') return null; + const t = hex.trim(); + if (!/^[0-9A-Fa-f]{8}$/.test(t)) return null; + return t.toUpperCase(); +} + +/** + * Pick the chat row that late event 136 should enrich. + * Prefer fingerprint match; else most recent matching channel/DM in the correlate window. + */ +export function findMeshcoreLateRfHopEnrichmentTarget( + candidates: readonly MeshcoreLateHopCandidate[], + input: MeshcoreLateRfHopEnrichmentInput, +): MeshcoreLateHopCandidate | undefined { + if (!input.parseOk || !Number.isFinite(input.hopCount)) return undefined; + const now = input.now ?? Date.now(); + const windowMs = input.windowMs ?? MESHCORE_CHAT_CORRELATE_WINDOW_MS; + const myNodeNum = input.myNodeNum ?? 0; + const rfHops = Math.trunc(input.hopCount); + const fp = normalizeFingerprint(input.messageFingerprintHex); + + const applicable = (c: MeshcoreLateHopCandidate): boolean => + shouldApplyMeshcoreRfHopEnrichment(c.rxHops, rfHops, true); + + if (fp) { + for (let i = candidates.length - 1; i >= 0; i--) { + const c = candidates[i]; + if (normalizeFingerprint(c.rxPacketFingerprintHex) !== fp) continue; + if (myNodeNum !== 0 && c.sender_id === myNodeNum) continue; + if (!applicable(c)) continue; + return c; + } + } + + const kindOk = + input.payloadTypeString === 'GRP_TXT' ? isBroadcastChannelCandidate : isDmCandidate; + + let best: MeshcoreLateHopCandidate | undefined; + let bestTs = -Infinity; + let bestFromMatch = false; + + for (let i = candidates.length - 1; i >= 0; i--) { + const c = candidates[i]; + if (!kindOk(c)) continue; + if (myNodeNum !== 0 && c.sender_id === myNodeNum) continue; + if (c.status === 'sending') continue; + const via = c.receivedVia; + if (via != null && via !== 'rf' && via !== 'both' && via !== 'mqtt') continue; + const tsMs = effectiveMessageTimestampMs(c.timestamp, now); + if (Math.abs(now - tsMs) > windowMs) continue; + if (!applicable(c)) continue; + + const fromMatch = + input.payloadTypeString === 'TXT_MSG' && + input.fromNodeId != null && + c.sender_id === input.fromNodeId; + + if (!best || (fromMatch && !bestFromMatch) || (fromMatch === bestFromMatch && tsMs > bestTs)) { + best = c; + bestTs = tsMs; + bestFromMatch = fromMatch; + } + } + + return best; +} + +function listLateHopCandidates(identityId: IdentityId): MeshcoreLateHopCandidate[] { + const byId = useMessageStore.getState().messages[identityId] ?? {}; + const out: MeshcoreLateHopCandidate[] = []; + for (const [storeId, record] of Object.entries(byId)) { + const msg = messageRecordToChatMessage(record); + out.push({ + storeId, + sender_id: msg.sender_id, + channel: msg.channel, + timestamp: msg.timestamp, + ...(msg.rxHops != null ? { rxHops: msg.rxHops } : {}), + ...(msg.receivedVia != null ? { receivedVia: msg.receivedVia } : {}), + ...(msg.roomServerId != null ? { roomServerId: msg.roomServerId } : {}), + ...(msg.rxPacketFingerprintHex != null + ? { rxPacketFingerprintHex: msg.rxPacketFingerprintHex } + : {}), + ...(msg.status != null ? { status: msg.status } : {}), + }); + } + return out; +} + +/** + * Patch store + SQLite when late RF RX can fill or correct hops. + * Returns null when nothing applied. + */ +export function applyMeshcoreLateRfHopEnrichment( + identityId: IdentityId | null | undefined, + input: MeshcoreLateRfHopEnrichmentInput, +): MeshcoreLateRfHopEnrichmentResult | null { + if (!identityId) return null; + if (!input.parseOk || !Number.isFinite(input.hopCount)) return null; + + const candidates = listLateHopCandidates(identityId); + const target = findMeshcoreLateRfHopEnrichmentTarget(candidates, input); + if (!target) return null; + + const nextRxHops = Math.trunc(input.hopCount); + const previousRxHops = target.rxHops; + const corrected = previousRxHops != null && previousRxHops !== nextRxHops; + + const byIdentity = useMessageStore.getState().messages[identityId]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Identity bucket may be absent at runtime. + if (!byIdentity) return null; + const existing = byIdentity[target.storeId]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Message may be absent when race-deleted. + if (!existing) return null; + + const nextReceivedVia = + existing.receivedVia === 'mqtt' || existing.receivedVia === 'both' + ? ('both' as const) + : ('rf' as const); + + upsertMessage(identityId, { + ...existing, + id: target.storeId, + rxHops: nextRxHops, + hopCount: nextRxHops, + receivedVia: nextReceivedVia, + }); + + const chat = messageRecordToChatMessage({ + ...existing, + id: target.storeId, + rxHops: nextRxHops, + hopCount: nextRxHops, + receivedVia: nextReceivedVia, + }); + void window.electronAPI.db.saveMeshcoreMessage(messageToDbRow(chat)).catch((e: unknown) => { + console.warn( + '[meshcoreLateRfHopEnrichment] saveMeshcoreMessage failed ' + errLikeToLogString(e), + ); + }); + + if (corrected) { + markMeshcoreHopCorrected(target.storeId, input.now ?? Date.now()); + } + + return { + storeId: target.storeId, + previousRxHops, + nextRxHops, + corrected, + }; +} + +/** Stable UI key for hop-corrected marks (matches enrichment storeId when possible). */ +export function meshcoreChatHopUiKey(msg: { + storeId?: string; + id?: number; + sender_id: number; + timestamp: number; + channel: number; +}): string { + if (msg.storeId) return msg.storeId; + if (msg.id != null) return String(msg.id); + return `${msg.sender_id}-${msg.timestamp}-${msg.channel}`; +} diff --git a/src/renderer/lib/meshcoreRawPacketCorrelate.test.ts b/src/renderer/lib/meshcoreRawPacketCorrelate.test.ts index eeac48954..2ef2dd755 100644 --- a/src/renderer/lib/meshcoreRawPacketCorrelate.test.ts +++ b/src/renderer/lib/meshcoreRawPacketCorrelate.test.ts @@ -202,20 +202,20 @@ describe('meshcoreFindRecentGrpTxtRawPacket', () => { describe('meshcoreFindRecentTxtMsgRawPacket', () => { const now = 20_000; - it('returns unattributed TXT_MSG within window', () => { + it('returns the most recent TXT_MSG within window (attributed or not)', () => { const packets: ChatCorrelateRxLike[] = [ - { ts: now - 400, payloadTypeString: 'TXT_MSG', fromNodeId: 0xabc, hopCount: 9 }, - { ts: now - 100, payloadTypeString: 'TXT_MSG', fromNodeId: null, hopCount: 2 }, + { ts: now - 400, payloadTypeString: 'TXT_MSG', fromNodeId: null, hopCount: 9 }, + { ts: now - 100, payloadTypeString: 'TXT_MSG', fromNodeId: 0xabc, hopCount: 2 }, ]; expect(meshcoreFindRecentTxtMsgRawPacket(packets, now)?.hopCount).toBe(2); }); - it('matches unattributed TXT_MSG in the widened correlation window', () => { + it('matches attributed TXT_MSG in the correlation window', () => { const packets: ChatCorrelateRxLike[] = [ { ts: now - (MESHCORE_CHAT_CORRELATE_WINDOW_MS - 500), payloadTypeString: 'TXT_MSG', - fromNodeId: null, + fromNodeId: 0x111, hopCount: 6, }, ]; @@ -234,10 +234,9 @@ describe('meshcoreFindRecentTxtMsgRawPacket', () => { expect(meshcoreFindRecentTxtMsgRawPacket(packets, now)).toBeUndefined(); }); - it('ignores GRP_TXT and attributed TXT_MSG rows', () => { + it('ignores GRP_TXT rows when looking for TXT_MSG', () => { const packets: ChatCorrelateRxLike[] = [ { ts: now - 100, payloadTypeString: 'GRP_TXT', fromNodeId: null, hopCount: 5 }, - { ts: now - 50, payloadTypeString: 'TXT_MSG', fromNodeId: 0x111, hopCount: 4 }, ]; expect(meshcoreFindRecentTxtMsgRawPacket(packets, now)).toBeUndefined(); }); diff --git a/src/renderer/lib/meshcoreRawPacketCorrelate.ts b/src/renderer/lib/meshcoreRawPacketCorrelate.ts index 2c9120d57..ec467335e 100644 --- a/src/renderer/lib/meshcoreRawPacketCorrelate.ts +++ b/src/renderer/lib/meshcoreRawPacketCorrelate.ts @@ -59,7 +59,7 @@ export function meshcoreFindRecentGrpTxtRawPacket return undefined; } -/** Most recent unattributed TXT_MSG raw log row within the chat correlation window (DM path). */ +/** Most recent TXT_MSG raw log row within the chat correlation window (any fromNodeId). */ export function meshcoreFindRecentTxtMsgRawPacket( prev: readonly T[], now: number, @@ -68,7 +68,7 @@ export function meshcoreFindRecentTxtMsgRawPacket for (let i = prev.length - 1; i >= 0; i--) { const e = prev[i]; if (now - e.ts > windowMs) break; - if (e.payloadTypeString === 'TXT_MSG' && e.fromNodeId === null) return e; + if (e.payloadTypeString === 'TXT_MSG') return e; } return undefined; } diff --git a/src/renderer/lib/meshcoreUtils.test.ts b/src/renderer/lib/meshcoreUtils.test.ts index 380c239f8..040ef3105 100644 --- a/src/renderer/lib/meshcoreUtils.test.ts +++ b/src/renderer/lib/meshcoreUtils.test.ts @@ -389,7 +389,7 @@ describe('meshcoreCompanionRxPathLenToHopCount', () => { expect(meshcoreCompanionRxPathLenToHopCount(255)).toBe(0); }); - it('returns flood hop count for other pathLen values', () => { + it('returns flood hop count for plain 0..63 pathLen values', () => { expect(meshcoreCompanionRxPathLenToHopCount(0)).toBe(0); expect(meshcoreCompanionRxPathLenToHopCount(1)).toBe(1); expect(meshcoreCompanionRxPathLenToHopCount(3)).toBe(3); @@ -397,6 +397,14 @@ describe('meshcoreCompanionRxPathLenToHopCount', () => { expect(meshcoreCompanionRxPathLenToHopCount(1.9)).toBe(1); }); + it('unpacks packed multibyte path-hash pathLen bytes (low 6 bits)', () => { + // 2-byte hash mode: pack(1,2)=65, pack(0,2)=64; 3-byte: pack(3,3)=131 + expect(meshcoreCompanionRxPathLenToHopCount(64)).toBe(0); + expect(meshcoreCompanionRxPathLenToHopCount(65)).toBe(1); + expect(meshcoreCompanionRxPathLenToHopCount(131)).toBe(3); + expect(meshcoreCompanionRxPathLenToHopCount(254)).toBe(62); + }); + it('returns undefined for missing or non-finite values', () => { expect(meshcoreCompanionRxPathLenToHopCount(undefined)).toBeUndefined(); expect(meshcoreCompanionRxPathLenToHopCount(null)).toBeUndefined(); @@ -404,12 +412,10 @@ describe('meshcoreCompanionRxPathLenToHopCount', () => { expect(meshcoreCompanionRxPathLenToHopCount(Number.NaN)).toBeUndefined(); }); - it('rejects negatives, oversized, and non-direct high bytes without wrapping', () => { + it('rejects negatives and oversized values without wrapping', () => { expect(meshcoreCompanionRxPathLenToHopCount(-1)).toBeUndefined(); expect(meshcoreCompanionRxPathLenToHopCount(256)).toBeUndefined(); expect(meshcoreCompanionRxPathLenToHopCount(511)).toBeUndefined(); - expect(meshcoreCompanionRxPathLenToHopCount(64)).toBeUndefined(); - expect(meshcoreCompanionRxPathLenToHopCount(254)).toBeUndefined(); }); }); diff --git a/src/renderer/lib/meshcoreUtils.ts b/src/renderer/lib/meshcoreUtils.ts index 4b0b540bd..9d6a55c9f 100644 --- a/src/renderer/lib/meshcoreUtils.ts +++ b/src/renderer/lib/meshcoreUtils.ts @@ -90,18 +90,17 @@ export function meshcoreMergeChannelDisplayNameOntoNode( /** * Companion ContactMsgRecv / ChannelMsgRecv `pathLen` → chat RF hop count. - * meshcore.js: ChannelMsgRecv uses 0xFF for direct; otherwise flood hop count. - * Rejects out-of-range values (no `& 0xff` wrap) so negatives / oversized bytes - * cannot become false "direct" or absurd hop badges. Flood counts above 63 are - * rejected (RF path length max uses 6 bits); 0xFF remains the direct sentinel. + * `0xFF` = direct (0 hops). Flood values use the same packed path_length byte as + * on-air RF (low 6 bits = hop count, high 2 bits = hash-size code) — including + * multibyte path-hash modes where the packed byte is ≥ 64. + * Rejects non-finite / out-of-byte-range inputs (no wrap). */ export function meshcoreCompanionRxPathLenToHopCount(pathLen: unknown): number | undefined { if (typeof pathLen !== 'number' || !Number.isFinite(pathLen)) return undefined; const n = Math.trunc(pathLen); if (n < 0 || n > 255) return undefined; if (n === 0xff) return 0; - if (n > 63) return undefined; - return n; + return meshcoreUnpackPathLenByte(n).hopCount; } /** diff --git a/src/renderer/lib/protocols/MeshCoreProtocol.test.ts b/src/renderer/lib/protocols/MeshCoreProtocol.test.ts index 838f73a75..f490f3867 100644 --- a/src/renderer/lib/protocols/MeshCoreProtocol.test.ts +++ b/src/renderer/lib/protocols/MeshCoreProtocol.test.ts @@ -142,6 +142,21 @@ describe('MeshCoreProtocol.subscribe', () => { teardown(); }); + it('unpacks packed multibyte pathLen on channel messages (e.g. 65 → 1 hop)', () => { + const conn = mockMeshCoreConnection(); + const events: DomainEvent[] = []; + const teardown = meshcoreProtocol.subscribe(conn, (e) => events.push(e)); + conn.emit(EVENT_CHANNEL_MESSAGE, { + channelIdx: 0, + text: 'packed hops', + senderTimestamp: 1_700_002, + pathLen: 65, // pack(1, 2-byte hashes) + }); + const text = events.find((e) => e.type === 'text_message'); + expect(text?.type === 'text_message' && text.payload.hopCount).toBe(1); + teardown(); + }); + it('maps DM pathLen to hopCount', () => { const publicKey = Uint8Array.from({ length: 32 }, (_, i) => i + 1); const nodeId = pubkeyToNodeId(publicKey); diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index f55940674..b35725895 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -645,7 +645,8 @@ "floodScopeOverrideUnscoped": "Unscoped", "emojiButton": "Emoji", "reticulumSendStoredAtPn": "Uloženo v propagačním uzlu", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "Aktualizováno z RF cesty" }, "chatPayload": { "mention": "Zmínit {{label}}", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 627791145..ae35a028a 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -643,7 +643,8 @@ "notificationMessageTitle": "Nachricht von {{sender}}", "emojiButton": "Emojis", "reticulumSendStoredAtPn": "Am Ausbreitungsknoten gespeichert", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "Aktualisiert von RF-Pfad" }, "chatPayload": { "mention": "Erwähne {{label}}", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index f30efed77..9b256ef33 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -470,6 +470,7 @@ "receivedViaRf": "Received via RF", "receivedViaMqtt": "Received via MQTT", "receivedViaRfAndMqtt": "Received via RF + MQTT", + "hopCountRefinedFromRf": "Updated from RF path", "receivedViaStoreForward": "Replayed from Store & Forward", "receivedViaTcp": "Received via TCP", "receivedViaNetwork": "Received via network", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index fadcb1cde..98869762f 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -643,7 +643,8 @@ "notificationMessageTitle": "Mensaje de {{sender}}", "emojiButton": "Emoji", "reticulumSendStoredAtPn": "Almacenado en el nodo de propagación", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "Actualizado desde la ruta de RF" }, "chatPayload": { "mention": "Mencionar {{label}}", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 25a948bbd..f8f681792 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -643,7 +643,8 @@ "notificationMessageTitle": "Message de {{sender}}", "emojiButton": "Emoji", "reticulumSendStoredAtPn": "Stocké au nœud de propagation", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "Mise à jour à partir du chemin RF" }, "chatPayload": { "mention": "Mention {{label}}", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index a3dd269ef..577de5492 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -643,7 +643,8 @@ "notificationDmTitle": "DM dari {{sender}}", "notificationMessageTitle": "Pesan dari {{sender}}", "reticulumSendStoredAtPn": "Disimpan di simpul propagasi", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "Diperbarui dari jalur RF" }, "chatPayload": { "mention": "Sebutkan {{label}}", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 733decbbb..913af1ad5 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -643,7 +643,8 @@ "notificationMessageTitle": "Messaggio da {{sender}}", "emojiButton": "Emoji", "reticulumSendStoredAtPn": "Memorizzato al nodo di propagazione", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "Aggiornato da percorso RF" }, "chatPayload": { "mention": "Menziona {{label}}", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 775da9023..0b0d8a2d2 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -643,7 +643,8 @@ "notificationDmTitle": "{{sender}}からのDM", "notificationMessageTitle": "{{sender}}からのメッセージ", "reticulumSendStoredAtPn": "伝播ノードに格納されています", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "RFパスから更新されました" }, "chatPayload": { "mention": "{{label}} について言及してください", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 312d53edb..934f93f8b 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -643,7 +643,8 @@ "notificationDmTitle": "{{sender}} 님의 DM", "notificationMessageTitle": "{{sender}} 님의 메시지", "reticulumSendStoredAtPn": "전파 노드에 저장됨", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "RF 경로에서 업데이트됨" }, "chatPayload": { "mention": "{{label}}을(를) 언급하세요", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 641163793..63db3debb 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -643,7 +643,8 @@ "emojiButton": "Emoji", "openDmByAddress": "Open", "reticulumSendStoredAtPn": "Opgeslagen op propagatieknooppunt", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "Bijgewerkt vanaf RF-pad" }, "chatPayload": { "mention": "Vermeld {{label}}", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index e392776c8..212380323 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -647,7 +647,8 @@ "notificationMessageTitle": "Wiadomość od {{sender}}", "emojiButton": "Emoji", "reticulumSendStoredAtPn": "Przechowywane w węźle propagacji", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "Zaktualizowano ze ścieżki RF" }, "chatPayload": { "mention": "Wspomnij o {{label}}", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index d72116075..9e120c05b 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -643,7 +643,8 @@ "notificationDmTitle": "DM de {{sender}}", "notificationMessageTitle": "Mensagem de {{sender}}", "reticulumSendStoredAtPn": "Armazenado no nó de propagação", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "Atualizado a partir do caminho de RF" }, "chatPayload": { "mention": "Mencionar {{label}}", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 499962ac0..c1965df28 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -645,7 +645,8 @@ "notificationDmTitle": "ДМ от {{sender}}", "notificationMessageTitle": "Сообщение от {{sender}}", "reticulumSendStoredAtPn": "Сохранено в узле распространения", - "reticulumPnAbbrev": "КН" + "reticulumPnAbbrev": "КН", + "hopCountRefinedFromRf": "Обновлено из радиочастотного тракта" }, "chatPayload": { "mention": "Упоминание {{label}}", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index adb9286a8..84d41e3b2 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -643,7 +643,8 @@ "notificationDmTitle": "{{sender}} 'dan DM", "notificationMessageTitle": "{{sender}} adlı kişiden mesaj", "reticulumSendStoredAtPn": "Yayılma düğümünde depolanır", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "RF yolundan güncellendi" }, "chatPayload": { "mention": "{{label}}'dan bahsedin", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index f8304d23f..305c337d0 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -645,7 +645,8 @@ "notificationDmTitle": "ДМ від {{sender}}", "notificationMessageTitle": "Повідомлення від {{sender}}", "reticulumSendStoredAtPn": "Зберігається на вузлі розповсюдження", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "Оновлено з RF PATH" }, "chatPayload": { "mention": "Згадайте {{label}}", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 8659c6f9c..7d6125e36 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -643,7 +643,8 @@ "notificationDmTitle": "来自{{sender}}的DM", "notificationMessageTitle": "来自{{sender}}的消息", "reticulumSendStoredAtPn": "存储在传播节点", - "reticulumPnAbbrev": "PN" + "reticulumPnAbbrev": "PN", + "hopCountRefinedFromRf": "从射频路径更新" }, "chatPayload": { "mention": "提及{{label}}", diff --git a/vitest.config.mts b/vitest.config.mts index ab26b0bd3..b01b63027 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -111,6 +111,7 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/meshtastic/transportDisplayNameCache.test.ts', 'src/renderer/lib/meshcoreDualNobleBleInit.test.ts', 'src/renderer/lib/meshcoreKeyBackupStorage.test.ts', + 'src/renderer/lib/meshcoreLateRfHopEnrichment.test.ts', 'src/renderer/lib/meshcoreMqttSettingsStorage.test.ts', 'src/renderer/lib/meshcoreContactAutoAdd.test.ts', 'src/renderer/lib/meshcoreDbCacheHydration.repair.test.ts', @@ -185,7 +186,11 @@ function collectRendererLibTestFiles(): string[] { if (ent.isDirectory()) { walk(path); } else if (ent.name.endsWith('.test.ts')) { - results.push(relative(import.meta.dirname, path).split('\\').join('/')); + results.push( + relative(import.meta.dirname, path) + .split('\\') + .join('/'), + ); } } }; From 7ca82e91e615a63642519b41f2dcb5e79888b2c9 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 1 Aug 2026 13:06:00 -0600 Subject: [PATCH 2/3] fix(meshcore): scope DM hop correlate to sender and axe hop label Match TXT_MSG raw-log hops by event sender or fingerprint so interleaved DMs cannot steal counts, and add vitest-axe coverage for ChatRfHopLabel. --- .../components/ChatRfHopLabel.test.tsx | 34 ++++++--- .../lib/ingest/meshcoreIngest.test.ts | 2 +- src/renderer/lib/ingest/meshcoreIngest.ts | 7 +- .../lib/meshcoreRawPacketCorrelate.test.ts | 72 +++++++++++++++++++ .../lib/meshcoreRawPacketCorrelate.ts | 54 +++++++++++++- 5 files changed, 153 insertions(+), 16 deletions(-) diff --git a/src/renderer/components/ChatRfHopLabel.test.tsx b/src/renderer/components/ChatRfHopLabel.test.tsx index 8a166189f..522412c06 100644 --- a/src/renderer/components/ChatRfHopLabel.test.tsx +++ b/src/renderer/components/ChatRfHopLabel.test.tsx @@ -1,13 +1,22 @@ // @vitest-environment jsdom import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; +import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import { markMeshcoreHopCorrected, resetMeshcoreHopCorrectedMarksForTests, } from '../lib/meshcoreLateRfHopEnrichment'; import { ChatRfHopLabel, chatRfHopLabelPresentation } from './ChatRfHopLabel'; +/** jsdom has no Tailwind CSS — set chat-like slate + label colors for axe contrast. */ +function prepareHopLabelForAxe(container: HTMLElement, label: HTMLElement, color: string): void { + container.style.backgroundColor = '#1e293b'; + label.style.color = color; + hydrateAxeThemeColors(container); +} + describe('chatRfHopLabelPresentation', () => { it('uses amber accent only when corrected and motion is allowed', () => { expect(chatRfHopLabelPresentation(false, false).className).toContain('text-gray-500'); @@ -24,29 +33,32 @@ describe('ChatRfHopLabel', () => { resetMeshcoreHopCorrectedMarksForTests(); }); - it('renders hop count with default title when not corrected', () => { - render( + it('renders hop count with default title when not corrected', async () => { + const { container } = render( , ); - expect(screen.getByText('3 hops')).toBeInTheDocument(); - expect(screen.getByText('3 hops')).toHaveAttribute( - 'title', - expect.stringMatching(/hop|routing/i), - ); + const label = screen.getByText('3 hops'); + expect(label).toBeInTheDocument(); + expect(label).toHaveAttribute('title', expect.stringMatching(/hop|routing/i)); + prepareHopLabelForAxe(container, label, '#6b7280'); // gray-500 + expect(await axe(container)).toHaveNoViolations(); }); - it('uses refined title when a correction mark is active', () => { + it('uses refined title when a correction mark is active', async () => { markMeshcoreHopCorrected('ch:0:2:x'); - render( + const { container } = render( , ); - expect(screen.getByText('4 hops')).toHaveAttribute('title', 'Updated from RF path'); - expect(screen.getByText('4 hops').className).toContain('text-amber-400'); + const label = screen.getByText('4 hops'); + expect(label).toHaveAttribute('title', 'Updated from RF path'); + expect(label.className).toContain('text-amber-400'); + prepareHopLabelForAxe(container, label, '#fbbf24'); // amber-400 + expect(await axe(container)).toHaveNoViolations(); }); }); diff --git a/src/renderer/lib/ingest/meshcoreIngest.test.ts b/src/renderer/lib/ingest/meshcoreIngest.test.ts index 07db8f546..64bb61b90 100644 --- a/src/renderer/lib/ingest/meshcoreIngest.test.ts +++ b/src/renderer/lib/ingest/meshcoreIngest.test.ts @@ -612,7 +612,7 @@ describe('meshcoreIngest hop correlation (driver path)', () => { it('correlates DM rxHops from TXT_MSG raw packet log when hopCount omitted', () => { const detach = attachMeshcoreIngest(ID, { rawPacketsForHopCorrelation: () => [ - { ts: now - 50, payloadTypeString: 'TXT_MSG', fromNodeId: null, hopCount: 1 }, + { ts: now - 50, payloadTypeString: 'TXT_MSG', fromNodeId: 0xabcd, hopCount: 1 }, ], }); upsertMessage(ID, { diff --git a/src/renderer/lib/ingest/meshcoreIngest.ts b/src/renderer/lib/ingest/meshcoreIngest.ts index bbcf944fe..194dde64a 100644 --- a/src/renderer/lib/ingest/meshcoreIngest.ts +++ b/src/renderer/lib/ingest/meshcoreIngest.ts @@ -181,7 +181,12 @@ function handleTextMessage( const isChannel = event.payload.id.startsWith('ch:'); const hopCount = event.payload.hopCount ?? - resolveMeshcoreIngestRxHops(options.rawPacketsForHopCorrelation?.() ?? [], isChannel); + resolveMeshcoreIngestRxHops( + options.rawPacketsForHopCorrelation?.() ?? [], + isChannel, + Date.now(), + isChannel ? undefined : { fromNodeId: event.payload.from }, + ); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Identity bucket may be absent at runtime. const fromNode = useNodeStore.getState().nodes[identityId]?.[event.payload.from]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Node may be absent when its identity bucket is missing. diff --git a/src/renderer/lib/meshcoreRawPacketCorrelate.test.ts b/src/renderer/lib/meshcoreRawPacketCorrelate.test.ts index 2ef2dd755..b3ae52087 100644 --- a/src/renderer/lib/meshcoreRawPacketCorrelate.test.ts +++ b/src/renderer/lib/meshcoreRawPacketCorrelate.test.ts @@ -240,6 +240,63 @@ describe('meshcoreFindRecentTxtMsgRawPacket', () => { ]; expect(meshcoreFindRecentTxtMsgRawPacket(packets, now)).toBeUndefined(); }); + + it('scopes to the event sender when interleaved TXT_MSG rows share the window', () => { + const packets: ChatCorrelateRxLike[] = [ + { + ts: now - 300, + payloadTypeString: 'TXT_MSG', + fromNodeId: 0xaaa, + hopCount: 1, + parseOk: true, + }, + { + ts: now - 100, + payloadTypeString: 'TXT_MSG', + fromNodeId: 0xbbb, + hopCount: 5, + parseOk: true, + }, + ]; + // Most recent overall is 0xbbb @ 5 hops — but ingesting 0xaaa must not adopt that. + expect( + meshcoreFindRecentTxtMsgRawPacket(packets, now, MESHCORE_CHAT_CORRELATE_WINDOW_MS, { + fromNodeId: 0xaaa, + })?.hopCount, + ).toBe(1); + expect( + meshcoreFindRecentTxtMsgRawPacket(packets, now, MESHCORE_CHAT_CORRELATE_WINDOW_MS, { + fromNodeId: 0xbbb, + })?.hopCount, + ).toBe(5); + }); + + it('matches TXT_MSG by fingerprint when fromNodeId differs or is null', () => { + const packets: ChatCorrelateRxLike[] = [ + { + ts: now - 200, + payloadTypeString: 'TXT_MSG', + fromNodeId: null, + hopCount: 4, + parseOk: true, + messageFingerprintHex: 'deadbeef', + }, + { + ts: now - 50, + payloadTypeString: 'TXT_MSG', + fromNodeId: 0x999, + hopCount: 9, + parseOk: true, + messageFingerprintHex: 'cafebabe', + }, + ]; + expect( + meshcoreFindRecentTxtMsgRawPacket(packets, now, MESHCORE_CHAT_CORRELATE_WINDOW_MS, { + fromNodeId: 0x111, + messageFingerprintHex: 'DEADBEEF', + })?.hopCount, + ).toBe(4); + }); }); describe('resolveMeshcoreIngestRxHops', () => { @@ -254,6 +311,21 @@ describe('resolveMeshcoreIngestRxHops', () => { expect(resolveMeshcoreIngestRxHops(packets, false, now)).toBe(1); }); + it('does not adopt another sender DM hops within the correlation window', () => { + const packets: ChatCorrelateRxLike[] = [ + { + ts: now - 400, + payloadTypeString: 'TXT_MSG', + fromNodeId: 0x111, + hopCount: 2, + parseOk: true, + }, + { ts: now - 50, payloadTypeString: 'TXT_MSG', fromNodeId: 0x222, hopCount: 7, parseOk: true }, + ]; + expect(resolveMeshcoreIngestRxHops(packets, false, now, { fromNodeId: 0x111 })).toBe(2); + expect(resolveMeshcoreIngestRxHops(packets, false, now, { fromNodeId: 0x333 })).toBeUndefined(); + }); + it('returns undefined when matched row has no hopCount', () => { const packets: ChatCorrelateRxLike[] = [ { ts: now - 100, payloadTypeString: 'GRP_TXT', fromNodeId: null }, diff --git a/src/renderer/lib/meshcoreRawPacketCorrelate.ts b/src/renderer/lib/meshcoreRawPacketCorrelate.ts index ec467335e..86626c3f8 100644 --- a/src/renderer/lib/meshcoreRawPacketCorrelate.ts +++ b/src/renderer/lib/meshcoreRawPacketCorrelate.ts @@ -12,6 +12,41 @@ export interface ChatCorrelateRxLike { hopCount?: number; /** When false, hopCount is unreliable (failed parse / synthetic chat row). Absent = trusted. */ parseOk?: boolean; + /** CRC-32 packet fingerprint (8 hex chars) when known from RF parse. */ + messageFingerprintHex?: string | null; +} + +/** Optional DM hop-correlation match against the ingesting event's sender. */ +export interface MeshcoreTxtMsgHopMatch { + /** Companion / PacketRouter sender node id (`event.payload.from`). */ + fromNodeId?: number | null; + /** Optional RF packet fingerprint when available on the chat path. */ + messageFingerprintHex?: string | null; +} + +function normalizeCorrelateFingerprint(hex: string | null | undefined): string | null { + if (typeof hex !== 'string') return null; + const t = hex.trim(); + if (!/^[0-9A-Fa-f]{8}$/.test(t)) return null; + return t.toUpperCase(); +} + +function meshcoreTxtMsgRawPacketMatchesSender( + entry: ChatCorrelateRxLike, + match?: MeshcoreTxtMsgHopMatch, +): boolean { + if (!match) return true; + const wantFp = normalizeCorrelateFingerprint(match.messageFingerprintHex); + if (wantFp && normalizeCorrelateFingerprint(entry.messageFingerprintHex) === wantFp) { + return true; + } + const wantFrom = match.fromNodeId; + if (wantFrom != null && wantFrom !== 0 && entry.fromNodeId === wantFrom) { + return true; + } + // No usable sender constraint → keep prior any-TXT_MSG behavior. + if ((wantFrom == null || wantFrom === 0) && !wantFp) return true; + return false; } /** @@ -59,16 +94,23 @@ export function meshcoreFindRecentGrpTxtRawPacket return undefined; } -/** Most recent TXT_MSG raw log row within the chat correlation window (any fromNodeId). */ +/** + * Most recent TXT_MSG raw log row within the chat correlation window. + * When `match` includes a sender id or fingerprint, only that sender's row is used + * so interleaved DMs in the window cannot steal hop counts. + */ export function meshcoreFindRecentTxtMsgRawPacket( prev: readonly T[], now: number, windowMs: number = MESHCORE_CHAT_CORRELATE_WINDOW_MS, + match?: MeshcoreTxtMsgHopMatch, ): T | undefined { for (let i = prev.length - 1; i >= 0; i--) { const e = prev[i]; if (now - e.ts > windowMs) break; - if (e.payloadTypeString === 'TXT_MSG') return e; + if (e.payloadTypeString !== 'TXT_MSG') continue; + if (!meshcoreTxtMsgRawPacketMatchesSender(e, match)) continue; + return e; } return undefined; } @@ -78,10 +120,16 @@ export function resolveMeshcoreIngestRxHops( rawPackets: readonly ChatCorrelateRxLike[], isChannel: boolean, now: number = Date.now(), + txtMsgMatch?: MeshcoreTxtMsgHopMatch, ): number | undefined { const match = isChannel ? meshcoreFindRecentGrpTxtRawPacket(rawPackets, now) - : meshcoreFindRecentTxtMsgRawPacket(rawPackets, now); + : meshcoreFindRecentTxtMsgRawPacket( + rawPackets, + now, + MESHCORE_CHAT_CORRELATE_WINDOW_MS, + txtMsgMatch, + ); // Failed parses / synthetic chat rows default hopCount to 0 — do not adopt those. if (!match || match.parseOk === false) return undefined; const hops = match.hopCount; From 41323b77e739ac91ce213b6d84680191fca13afd Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 1 Aug 2026 13:15:42 -0600 Subject: [PATCH 3/3] fix(meshcore): harden hop label contrast and one-shot DM hop correlate Use gray-400 for small hop text on slate chat chrome, and consume matched TXT_MSG raw rows so one RF packet cannot enrich multiple DMs (no from=0 fallback). --- .../components/ChatRfHopLabel.test.tsx | 16 ++++-- src/renderer/components/ChatRfHopLabel.tsx | 5 +- .../lib/meshcoreRawPacketCorrelate.test.ts | 43 +++++++++++++++- .../lib/meshcoreRawPacketCorrelate.ts | 51 +++++++++++++++++-- 4 files changed, 103 insertions(+), 12 deletions(-) diff --git a/src/renderer/components/ChatRfHopLabel.test.tsx b/src/renderer/components/ChatRfHopLabel.test.tsx index 522412c06..e7678b687 100644 --- a/src/renderer/components/ChatRfHopLabel.test.tsx +++ b/src/renderer/components/ChatRfHopLabel.test.tsx @@ -10,18 +10,23 @@ import { } from '../lib/meshcoreLateRfHopEnrichment'; import { ChatRfHopLabel, chatRfHopLabelPresentation } from './ChatRfHopLabel'; +/** Production Tailwind gray-400 / amber-400 on chat slate-800 for axe contrast. */ +const HOP_LABEL_BG_SLATE_800 = '#1e293b'; +const HOP_LABEL_GRAY_400 = '#9ca3af'; +const HOP_LABEL_AMBER_400 = '#fbbf24'; + /** jsdom has no Tailwind CSS — set chat-like slate + label colors for axe contrast. */ function prepareHopLabelForAxe(container: HTMLElement, label: HTMLElement, color: string): void { - container.style.backgroundColor = '#1e293b'; + container.style.backgroundColor = HOP_LABEL_BG_SLATE_800; label.style.color = color; hydrateAxeThemeColors(container); } describe('chatRfHopLabelPresentation', () => { it('uses amber accent only when corrected and motion is allowed', () => { - expect(chatRfHopLabelPresentation(false, false).className).toContain('text-gray-500'); + expect(chatRfHopLabelPresentation(false, false).className).toContain('text-gray-400'); expect(chatRfHopLabelPresentation(true, false).className).toContain('text-amber-400'); - expect(chatRfHopLabelPresentation(true, true).className).toContain('text-gray-500'); + expect(chatRfHopLabelPresentation(true, true).className).toContain('text-gray-400'); expect(chatRfHopLabelPresentation(true, true).refined).toBe(true); expect(chatRfHopLabelPresentation(false, false).refined).toBe(false); }); @@ -43,7 +48,8 @@ describe('ChatRfHopLabel', () => { const label = screen.getByText('3 hops'); expect(label).toBeInTheDocument(); expect(label).toHaveAttribute('title', expect.stringMatching(/hop|routing/i)); - prepareHopLabelForAxe(container, label, '#6b7280'); // gray-500 + expect(label.className).toContain('text-gray-400'); + prepareHopLabelForAxe(container, label, HOP_LABEL_GRAY_400); expect(await axe(container)).toHaveNoViolations(); }); @@ -58,7 +64,7 @@ describe('ChatRfHopLabel', () => { const label = screen.getByText('4 hops'); expect(label).toHaveAttribute('title', 'Updated from RF path'); expect(label.className).toContain('text-amber-400'); - prepareHopLabelForAxe(container, label, '#fbbf24'); // amber-400 + prepareHopLabelForAxe(container, label, HOP_LABEL_AMBER_400); expect(await axe(container)).toHaveNoViolations(); }); }); diff --git a/src/renderer/components/ChatRfHopLabel.tsx b/src/renderer/components/ChatRfHopLabel.tsx index 59b55bef8..8847820e6 100644 --- a/src/renderer/components/ChatRfHopLabel.tsx +++ b/src/renderer/components/ChatRfHopLabel.tsx @@ -24,15 +24,16 @@ export function chatRfHopLabelPresentation( corrected: boolean, reduceMotion: boolean, ): { className: string; refined: boolean } { + // gray-400 (#9ca3af) on chat slate-800 (#1e293b) keeps 4.5:1+ for text-[10px]. if (!corrected) { return { - className: 'text-[10px] text-gray-500 transition-colors duration-500', + className: 'text-[10px] text-gray-400 transition-colors duration-500', refined: false, }; } if (reduceMotion) { return { - className: 'text-[10px] text-gray-500 transition-colors duration-500', + className: 'text-[10px] text-gray-400 transition-colors duration-500', refined: true, }; } diff --git a/src/renderer/lib/meshcoreRawPacketCorrelate.test.ts b/src/renderer/lib/meshcoreRawPacketCorrelate.test.ts index b3ae52087..1a0e8f262 100644 --- a/src/renderer/lib/meshcoreRawPacketCorrelate.test.ts +++ b/src/renderer/lib/meshcoreRawPacketCorrelate.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { type ChatCorrelateRxLike, @@ -6,10 +6,15 @@ import { meshcoreCorrelateOrSynthesizeChatEntry, meshcoreFindRecentGrpTxtRawPacket, meshcoreFindRecentTxtMsgRawPacket, + resetMeshcoreTxtMsgHopCorrelateConsumedForTests, resolveMeshcoreIngestRxHops, } from './meshcoreRawPacketCorrelate'; import { MAX_RAW_PACKET_LOG_ENTRIES } from './rawPacketLogConstants'; +afterEach(() => { + resetMeshcoreTxtMsgHopCorrelateConsumedForTests(); +}); + function entry( partial: Partial & Pick, ): ChatCorrelateRxLike { @@ -326,6 +331,42 @@ describe('resolveMeshcoreIngestRxHops', () => { expect(resolveMeshcoreIngestRxHops(packets, false, now, { fromNodeId: 0x333 })).toBeUndefined(); }); + it('consumes a TXT_MSG row so duplicate same-sender DMs cannot reuse it', () => { + const packets: ChatCorrelateRxLike[] = [ + { + ts: now - 200, + payloadTypeString: 'TXT_MSG', + fromNodeId: 0xabc, + hopCount: 1, + parseOk: true, + }, + { + ts: now - 100, + payloadTypeString: 'TXT_MSG', + fromNodeId: 0xabc, + hopCount: 3, + parseOk: true, + }, + ]; + expect(resolveMeshcoreIngestRxHops(packets, false, now, { fromNodeId: 0xabc })).toBe(3); + expect(resolveMeshcoreIngestRxHops(packets, false, now, { fromNodeId: 0xabc })).toBe(1); + expect(resolveMeshcoreIngestRxHops(packets, false, now, { fromNodeId: 0xabc })).toBeUndefined(); + }); + + it('does not fall back to another sender when fromNodeId is 0', () => { + const packets: ChatCorrelateRxLike[] = [ + { + ts: now - 50, + payloadTypeString: 'TXT_MSG', + fromNodeId: 0x222, + hopCount: 7, + parseOk: true, + }, + ]; + expect(resolveMeshcoreIngestRxHops(packets, false, now, { fromNodeId: 0 })).toBeUndefined(); + expect(resolveMeshcoreIngestRxHops(packets, false, now, { fromNodeId: null })).toBeUndefined(); + }); + it('returns undefined when matched row has no hopCount', () => { const packets: ChatCorrelateRxLike[] = [ { ts: now - 100, payloadTypeString: 'GRP_TXT', fromNodeId: null }, diff --git a/src/renderer/lib/meshcoreRawPacketCorrelate.ts b/src/renderer/lib/meshcoreRawPacketCorrelate.ts index 86626c3f8..912eaff70 100644 --- a/src/renderer/lib/meshcoreRawPacketCorrelate.ts +++ b/src/renderer/lib/meshcoreRawPacketCorrelate.ts @@ -3,6 +3,9 @@ import { MAX_RAW_PACKET_LOG_ENTRIES } from './rawPacketLogConstants'; /** Correlation window: event 7/8 must arrive within this many ms of the matching event 136. */ export const MESHCORE_CHAT_CORRELATE_WINDOW_MS = 3000; +/** Cap session-scoped consumed TXT_MSG hop-correlation keys (FIFO). */ +const MAX_CONSUMED_TXT_MSG_HOP_KEYS = 256; + /** Minimal shape needed for chat-entry correlation (avoids importing RxPacketEntry from useMeshcoreRuntime). */ export interface ChatCorrelateRxLike { ts: number; @@ -24,6 +27,9 @@ export interface MeshcoreTxtMsgHopMatch { messageFingerprintHex?: string | null; } +const consumedTxtMsgHopKeys: string[] = []; +const consumedTxtMsgHopKeySet = new Set(); + function normalizeCorrelateFingerprint(hex: string | null | undefined): string | null { if (typeof hex !== 'string') return null; const t = hex.trim(); @@ -31,6 +37,38 @@ function normalizeCorrelateFingerprint(hex: string | null | undefined): string | return t.toUpperCase(); } +/** Stable key for session-scoped consumption of a correlated TXT_MSG raw row. */ +export function meshcoreTxtMsgHopCorrelateKey(entry: ChatCorrelateRxLike): string { + const fp = normalizeCorrelateFingerprint(entry.messageFingerprintHex) ?? ''; + return `${entry.ts}|${entry.fromNodeId ?? 'n'}|${fp}|${entry.hopCount ?? ''}`; +} + +function isTxtMsgHopCorrelateConsumed(entry: ChatCorrelateRxLike): boolean { + return consumedTxtMsgHopKeySet.has(meshcoreTxtMsgHopCorrelateKey(entry)); +} + +function markTxtMsgHopCorrelateConsumed(entry: ChatCorrelateRxLike): void { + const key = meshcoreTxtMsgHopCorrelateKey(entry); + if (consumedTxtMsgHopKeySet.has(key)) return; + consumedTxtMsgHopKeySet.add(key); + consumedTxtMsgHopKeys.push(key); + while (consumedTxtMsgHopKeys.length > MAX_CONSUMED_TXT_MSG_HOP_KEYS) { + const old = consumedTxtMsgHopKeys.shift(); + if (old != null) consumedTxtMsgHopKeySet.delete(old); + } +} + +/** Test helper — clears session consumed-row markers. */ +export function resetMeshcoreTxtMsgHopCorrelateConsumedForTests(): void { + consumedTxtMsgHopKeys.length = 0; + consumedTxtMsgHopKeySet.clear(); +} + +/** + * Match a raw TXT_MSG row to the ingesting DM event. + * Requires a non-zero fromNodeId match or fingerprint match — never falls back to + * another sender's row when fromNodeId is 0 / missing. + */ function meshcoreTxtMsgRawPacketMatchesSender( entry: ChatCorrelateRxLike, match?: MeshcoreTxtMsgHopMatch, @@ -44,8 +82,6 @@ function meshcoreTxtMsgRawPacketMatchesSender( if (wantFrom != null && wantFrom !== 0 && entry.fromNodeId === wantFrom) { return true; } - // No usable sender constraint → keep prior any-TXT_MSG behavior. - if ((wantFrom == null || wantFrom === 0) && !wantFp) return true; return false; } @@ -97,7 +133,8 @@ export function meshcoreFindRecentGrpTxtRawPacket /** * Most recent TXT_MSG raw log row within the chat correlation window. * When `match` includes a sender id or fingerprint, only that sender's row is used - * so interleaved DMs in the window cannot steal hop counts. + * so interleaved DMs in the window cannot steal hop counts. Already-consumed rows + * (session-scoped) are skipped so one RF row cannot enrich multiple DMs. */ export function meshcoreFindRecentTxtMsgRawPacket( prev: readonly T[], @@ -109,6 +146,7 @@ export function meshcoreFindRecentTxtMsgRawPacket const e = prev[i]; if (now - e.ts > windowMs) break; if (e.payloadTypeString !== 'TXT_MSG') continue; + if (isTxtMsgHopCorrelateConsumed(e)) continue; if (!meshcoreTxtMsgRawPacketMatchesSender(e, match)) continue; return e; } @@ -133,5 +171,10 @@ export function resolveMeshcoreIngestRxHops( // Failed parses / synthetic chat rows default hopCount to 0 — do not adopt those. if (!match || match.parseOk === false) return undefined; const hops = match.hopCount; - return hops != null && Number.isFinite(hops) ? hops : undefined; + if (hops == null || !Number.isFinite(hops)) return undefined; + // Consume TXT_MSG rows only (channel GRP_TXT correlation stays shared / best-effort). + if (!isChannel && match.payloadTypeString === 'TXT_MSG') { + markTxtMsgHopCorrelateConsumed(match); + } + return hops; }