Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
RxPacketEntry,
} from '@/renderer/lib/meshcore/meshcoreHookTypes';
import * as meshcoreRepeaterRpcInFlight from '@/renderer/lib/meshcoreRepeaterRpcInFlight';
import { meshcoreChatStubNodeIdFromDisplayName } from '@/renderer/lib/meshcoreUtils';
import {
beginMeshcoreSilentBulkAttempt,
resetMeshcoreWaitingMessagesDrainState,
Expand Down Expand Up @@ -832,6 +833,100 @@ describe('attachMeshcoreConnSideEffects', () => {
});
});

it('preserves concurrent advert longName when flushing waiting-drain last_heard', async () => {
const prefix = new Uint8Array([0xaa, 0xbb]);
useNodeStore.setState({
nodes: {
[ID]: {
42: {
nodeId: 42,
longName: 'OldPeer',
shortName: 'P',
snr: 5,
rssi: -80,
lastHeardAt: 100,
source: 'rf',
},
},
},
});
const h = makeHarness();
h.ctx.pubKeyPrefixMapRef.current.set('aabb', 42);
vi.mocked(h.conn.getWaitingMessages).mockImplementation(() => {
// Concurrent on-air advert rename while drain still holds OldPeer in workingNodes.
useNodeStore.setState((s) => ({
nodes: {
...s.nodes,
[ID]: {
...s.nodes[ID],
42: {
...s.nodes[ID]?.[42],
nodeId: 42,
longName: 'NewPeer',
snr: 9,
rssi: -40,
},
},
},
}));
return Promise.resolve([
{
contactMessage: {
pubKeyPrefix: prefix,
text: 'hello from queue',
senderTimestamp: 1_700_000_100,
},
},
]);
});
detach = attachMeshcoreConnSideEffects(h.conn, h.ctx);

await h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: true });

expect(useNodeStore.getState().nodes[ID]?.[42]).toMatchObject({
longName: 'NewPeer',
snr: 9,
rssi: -40,
lastHeardAt: 1_700_000_100,
});
});

it('applies waiting-drain longName when live name is placeholder', async () => {
const nodeId = meshcoreChatStubNodeIdFromDisplayName('RealPeer');
const placeholder = `Node-${nodeId.toString(16).toUpperCase()}`;
useNodeStore.setState({
nodes: {
[ID]: {
[nodeId]: {
nodeId,
longName: placeholder,
shortName: '',
lastHeardAt: 100,
source: 'rf',
},
},
},
});
const h = makeHarness();
vi.mocked(h.conn.getWaitingMessages).mockResolvedValue([
{
channelMessage: {
channelIdx: 0,
text: 'RealPeer: queued channel message',
senderTimestamp: 1_700_000_200,
},
},
]);
detach = attachMeshcoreConnSideEffects(h.conn, h.ctx);

await h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: true });

expect(useNodeStore.getState().nodes[ID]?.[nodeId]).toMatchObject({
longName: 'RealPeer',
lastHeardAt: 1_700_000_200,
});
});

it('tears down the session and requests reconnect on disconnect', async () => {
const h = makeHarness();
detach = attachMeshcoreConnSideEffects(h.conn, h.ctx);
Expand Down
20 changes: 16 additions & 4 deletions src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { processMeshcoreWaitingMessageItem } from '../../lib/meshcoreProcessWait
import { resetMeshcoreRepeaterRpcInFlightOnDisconnect } from '../../lib/meshcoreRepeaterRpcInFlight';
import { meshcoreSortedStorePrior } from '../../lib/meshcoreStoreDedup';
import { resetMeshcoreTracePathMultiplexOnDisconnect } from '../../lib/meshcoreTracePathMultiplex';
import { meshcoreIsPlaceholderNodeLongName } from '../../lib/meshcoreUtils';
import {
normalizeMeshcoreWaitingMessageBatch,
normalizeMeshcoreWaitingMessageItem,
Expand Down Expand Up @@ -118,8 +119,9 @@ interface MeshcoreWaitingMessagesDrainState {

/**
* Waiting-drain mutations are last_heard (+ optional channel display-name). Rebuild patches
* against the live store row so concurrent RF SNR/RSSI writes aren't overwritten by the
* start-of-drain `workingNodes` snapshot.
* against the live store row so concurrent RF SNR/RSSI / advert-name writes aren't overwritten
* by the start-of-drain `workingNodes` snapshot. Only apply snapshot names when live is empty
* or a Node-HEX placeholder (channel enrichment upgrades placeholders only).
*/
function collectDirtyWaitingNodeRecords(
identityId: string,
Expand All @@ -140,10 +142,20 @@ function collectDirtyWaitingNodeRecords(
const patch: NodeRecord = { nodeId, lastHeardAt: nextLastHeard };
const workingLong = working.long_name?.trim();
const workingShort = working.short_name?.trim();
if (workingLong && workingLong !== (live.longName ?? '').trim()) {
const liveLong = (live.longName ?? '').trim();
const liveShort = (live.shortName ?? '').trim();
if (
workingLong &&
workingLong !== liveLong &&
(!liveLong || meshcoreIsPlaceholderNodeLongName(liveLong, nodeId))
) {
patch.longName = workingLong;
}
if (workingShort && workingShort !== (live.shortName ?? '').trim()) {
if (
workingShort &&
workingShort !== liveShort &&
(!liveShort || meshcoreIsPlaceholderNodeLongName(liveShort, nodeId))
) {
patch.shortName = workingShort;
}
if (
Expand Down
25 changes: 22 additions & 3 deletions src/renderer/lib/meshcoreUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,13 +594,13 @@ describe('meshcoreMergeContactAdvNameFromPrevious', () => {
).toBe('Bob');
});

it('takes radio name when radio lastAdvert is strictly newer', () => {
it('keeps live advert rename when radio lastAdvert is newer but advName is still old', () => {
expect(
meshcoreMergeContactAdvNameFromPrevious('FirmwareRename', 'OldName', nodeId, {
meshcoreMergeContactAdvNameFromPrevious('OldRoom', 'NewRoom', nodeId, {
prevLastHeard: 1_700_000_000,
radioLastAdvert: 1_700_000_500,
}),
).toBe('FirmwareRename');
).toBe('NewRoom');
});

it('keeps previous when radio lastAdvert is 0', () => {
Expand Down Expand Up @@ -663,6 +663,25 @@ describe('buildNodesFromContacts advert-name merge (path-updated rebuild)', () =
expect(merged).toBe('NewRoom');
});

it('keeps live rename when getContacts bumps lastAdvert without updating advName', () => {
const contact = {
publicKey: key32,
type: 3,
advName: 'OldRoom',
lastAdvert: 1_700_000_500,
advLat: 0,
advLon: 0,
};
const radio = meshcoreContactToMeshNode(contact);
const merged = meshcoreMergeContactAdvNameFromPrevious(
radio.long_name,
'NewRoom',
radio.node_id,
{ prevLastHeard: 1_700_000_100, radioLastAdvert: contact.lastAdvert },
);
expect(merged).toBe('NewRoom');
});

it('keeps stored advert name when nickname overlays long name and radio is stale or placeholder', () => {
const contact = {
publicKey: key32,
Expand Down
25 changes: 8 additions & 17 deletions src/renderer/lib/meshcoreUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,15 +647,17 @@ export function meshcorePreviousAdvertNameForRebuild(

/**
* When rebuilding from `getContacts`, companion firmware often keeps the name from when the
* contact was first stored. Prefer a live advert name already in the UI unless the radio dump
* is strictly newer (connect/refresh after firmware actually renamed the contact).
* contact was first stored and may bump `lastAdvert` without renaming. Prefer a live advert
* name already in the UI whenever both names are real and differ; on-air adverts / local
* setAdvertName are the authoritative rename paths (`opts` kept for call-site compat).
*/
export function meshcoreMergeContactAdvNameFromPrevious(
radioAdvName: string | undefined,
prevLongName: string | undefined,
nodeId: number,
opts?: MeshcoreMergeContactAdvNameOpts,
_opts?: MeshcoreMergeContactAdvNameOpts,
): string {
void _opts;
const radioTrim = (radioAdvName ?? '').trim();
const prevTrim = (prevLongName ?? '').trim();
const radioReal = radioTrim.length > 0 && !meshcoreIsPlaceholderNodeLongName(radioTrim, nodeId);
Expand All @@ -665,20 +667,9 @@ export function meshcoreMergeContactAdvNameFromPrevious(
if (!radioReal && prevReal) return prevTrim;
if (!prevReal) return radioTrim || prevTrim || hexFallback;
if (radioTrim === prevTrim) return radioTrim;

const radioAdvert =
opts?.radioLastAdvert != null &&
Number.isFinite(opts.radioLastAdvert) &&
opts.radioLastAdvert > 0
? opts.radioLastAdvert
: 0;
const prevHeard =
opts?.prevLastHeard != null && Number.isFinite(opts.prevLastHeard) ? opts.prevLastHeard : 0;
// Tie or missing radio time: companion often updates lastAdvert without renaming.
if (radioAdvert === 0 || prevHeard >= radioAdvert) {
return prevTrim;
}
return radioTrim;
// Companion dump disagrees with a real live/previous name — keep the live name. lastAdvert
// is not a reliable name version (path hears often bump time without updating advName).
return prevTrim;
}

/** Result of mapping a heard RF advert (push 0x80) into UI + DB when the node is not yet a contact. */
Expand Down