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..e7678b687
--- /dev/null
+++ b/src/renderer/components/ChatRfHopLabel.test.tsx
@@ -0,0 +1,70 @@
+// @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';
+
+/** 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 = 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-400');
+ expect(chatRfHopLabelPresentation(true, false).className).toContain('text-amber-400');
+ expect(chatRfHopLabelPresentation(true, true).className).toContain('text-gray-400');
+ 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', async () => {
+ const { container } = render(
+ ,
+ );
+ const label = screen.getByText('3 hops');
+ expect(label).toBeInTheDocument();
+ expect(label).toHaveAttribute('title', expect.stringMatching(/hop|routing/i));
+ expect(label.className).toContain('text-gray-400');
+ prepareHopLabelForAxe(container, label, HOP_LABEL_GRAY_400);
+ expect(await axe(container)).toHaveNoViolations();
+ });
+
+ it('uses refined title when a correction mark is active', async () => {
+ markMeshcoreHopCorrected('ch:0:2:x');
+ const { container } = render(
+ ,
+ );
+ const label = screen.getByText('4 hops');
+ expect(label).toHaveAttribute('title', 'Updated from RF path');
+ expect(label.className).toContain('text-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
new file mode 100644
index 000000000..8847820e6
--- /dev/null
+++ b/src/renderer/components/ChatRfHopLabel.tsx
@@ -0,0 +1,66 @@
+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 } {
+ // gray-400 (#9ca3af) on chat slate-800 (#1e293b) keeps 4.5:1+ for text-[10px].
+ if (!corrected) {
+ return {
+ className: 'text-[10px] text-gray-400 transition-colors duration-500',
+ refined: false,
+ };
+ }
+ if (reduceMotion) {
+ return {
+ className: 'text-[10px] text-gray-400 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/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/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..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 {
@@ -202,20 +207,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,13 +239,69 @@ 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();
});
+
+ 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', () => {
@@ -255,6 +316,57 @@ 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('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 2c9120d57..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;
@@ -12,6 +15,74 @@ 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;
+}
+
+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();
+ if (!/^[0-9A-Fa-f]{8}$/.test(t)) return null;
+ 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,
+): 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;
+ }
+ return false;
}
/**
@@ -59,16 +130,25 @@ 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.
+ * 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. Already-consumed rows
+ * (session-scoped) are skipped so one RF row cannot enrich multiple DMs.
+ */
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' && e.fromNodeId === null) return e;
+ if (e.payloadTypeString !== 'TXT_MSG') continue;
+ if (isTxtMsgHopCorrelateConsumed(e)) continue;
+ if (!meshcoreTxtMsgRawPacketMatchesSender(e, match)) continue;
+ return e;
}
return undefined;
}
@@ -78,12 +158,23 @@ 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;
- 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;
}
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('/'),
+ );
}
}
};