From e8fa4cf77625c800e61c40f9bcc5b3646a3a39f2 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 14:27:32 -0600 Subject: [PATCH 1/4] ci: advance-test WoA smoke on windows-11-vs2026-arm Switch packaging-smoke arm64 NSIS install jobs to GitHub's VS 2026 preview runner ahead of the windows-11-arm image migration. --- .github/actionlint.yaml | 4 ++++ .github/workflows/build.yaml | 4 ++-- .github/workflows/release.yaml | 4 ++-- docs/ci-cd.md | 2 +- docs/release-process.md | 2 +- src/main/windows-packaging.contract.test.ts | 4 ++-- 6 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 .github/actionlint.yaml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 000000000..3c2eee747 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,4 @@ +# GitHub-hosted labels not yet in actionlint's built-in list. +self-hosted-runner: + labels: + - windows-11-vs2026-arm diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 63faf1e5b..3e45c9be9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -230,7 +230,7 @@ jobs: label: x64 NSIS install artifact: mesh-client-windows-${{ github.sha }} smoke_run: node scripts/test-win-nsis-install.mjs --arch x64 - - os: windows-11-arm + - os: windows-11-vs2026-arm label: arm64 NSIS install (WoA) artifact: mesh-client-windows-${{ github.sha }} smoke_run: node scripts/test-win-nsis-install.mjs --arch arm64 --probe-7z @@ -262,7 +262,7 @@ jobs: retention-days: 7 - name: Upload Windows arm64 install failure logs - if: failure() && matrix.os == 'windows-11-arm' + if: failure() && matrix.os == 'windows-11-vs2026-arm' uses: actions/upload-artifact@v7 with: name: mesh-client-win-arm64-install-logs-${{ github.sha }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 323393aa3..920e06414 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -345,7 +345,7 @@ jobs: label: x64 NSIS install artifact: mesh-client-windows-${{ github.sha }} smoke_run: node scripts/test-win-nsis-install.mjs --arch x64 - - os: windows-11-arm + - os: windows-11-vs2026-arm label: arm64 NSIS install (WoA) artifact: mesh-client-windows-${{ github.sha }} smoke_run: node scripts/test-win-nsis-install.mjs --arch arm64 --probe-7z @@ -381,7 +381,7 @@ jobs: retention-days: 7 - name: Upload Windows arm64 install failure logs - if: failure() && matrix.os == 'windows-11-arm' + if: failure() && matrix.os == 'windows-11-vs2026-arm' uses: actions/upload-artifact@v7 with: name: mesh-client-win-arm64-install-logs-${{ github.sha }} diff --git a/docs/ci-cd.md b/docs/ci-cd.md index e25b4a125..20e19527f 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -268,7 +268,7 @@ node scripts/run-act.mjs ci -- -n | Windows `.exe` | — | — | — | `pnpm run dist:win` | | Flatpak x86_64 | `act:flatpak` | use local Flatpak docs | same | same | -**Not run locally via act:** `docs.yml` (`mkdocs gh-deploy`), release publish legs, `macos-latest` / `windows-latest` / `windows-11-arm` matrix jobs, and `ubuntu-24.04-arm` Flatpak builds (no faithful local emulation). +**Not run locally via act:** `docs.yml` (`mkdocs gh-deploy`), release publish legs, `macos-latest` / `windows-latest` / `windows-11-vs2026-arm` matrix jobs, and `ubuntu-24.04-arm` Flatpak builds (no faithful local emulation). Note: The test results artifact upload step is automatically skipped when running under `act` (detected by actor `nektos/act` in [`tests.yaml`](../.github/workflows/tests.yaml)). diff --git a/docs/release-process.md b/docs/release-process.md index da90c4890..b93b98221 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -183,7 +183,7 @@ After builds finish, **`packaging-smoke`** runs on: - macOS — `verify-mac-packaging.mjs` (includes bundled Reticulum sidecar in `.app`) - Linux — `verify-linux-packaging.mjs` plus `test-linux-appimage-reticulum-sidecar.mjs` (extracts x64/arm64 AppImages and asserts sidecar). **`verify-linux-packaging.mjs`** also asserts each `.deb` **Description** field is ASCII-only (no mojibake `??`) via `dpkg-deb -f` — non-ASCII control metadata breaks some package managers and mirrors. - Windows x64 — NSIS install smoke test (`test-win-nsis-install.mjs`, asserts sidecar after install) -- **`windows-11-arm`** — arm64 NSIS install smoke test with 7z probe (asserts sidecar inside installer payload and after install) +- **`windows-11-vs2026-arm`** — arm64 NSIS install smoke test with 7z probe (asserts sidecar inside installer payload and after install). Uses GitHub's advance-testing WoA runner ahead of the Sept 2026 VS 2026 rollout on `windows-11-arm`. Build jobs also run `verify-reticulum-sidecar-staged.mjs` after staging sidecars and before `electron-builder`. diff --git a/src/main/windows-packaging.contract.test.ts b/src/main/windows-packaging.contract.test.ts index d9809207f..a16eb52c7 100644 --- a/src/main/windows-packaging.contract.test.ts +++ b/src/main/windows-packaging.contract.test.ts @@ -159,7 +159,7 @@ describe('Windows packaging (contract)', () => { expect(buildWorkflow).toContain('label: x64 NSIS install'); expect(buildWorkflow).toContain('node scripts/test-win-nsis-install.mjs --arch x64'); expect(buildWorkflow).toContain('label: arm64 NSIS install (WoA)'); - expect(buildWorkflow).toContain('- os: windows-11-arm'); + expect(buildWorkflow).toContain('- os: windows-11-vs2026-arm'); expect(buildWorkflow).toContain( 'node scripts/test-win-nsis-install.mjs --arch arm64 --probe-7z', ); @@ -215,7 +215,7 @@ describe('Windows packaging (contract)', () => { expect(releaseWorkflow).toContain('label: x64 NSIS install'); expect(releaseWorkflow).toContain('node scripts/test-win-nsis-install.mjs --arch x64'); expect(releaseWorkflow).toContain('label: arm64 NSIS install (WoA)'); - expect(releaseWorkflow).toContain('- os: windows-11-arm'); + expect(releaseWorkflow).toContain('- os: windows-11-vs2026-arm'); expect(releaseWorkflow).toContain( 'node scripts/test-win-nsis-install.mjs --arch arm64 --probe-7z', ); From 37d396a5197afdf061c72323ea98cda5ff3e0537 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 15:34:00 -0600 Subject: [PATCH 2/4] fix(meshtastic): stop NodeDB configure replay from marking all nodes as just heard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During BLE device.configure(), the Meshtastic SDK replays the radio NodeDB as node_info frames and may emit synthetic UserPacket/Position events with rxTime=now. PacketRouter updates nodeStore before side effects run; upsertNode was routing every node_info through computeNodeInfoLastHeardMs (Unix seconds), so ms-valued UserPacket timestamps were clamped to Date.now() and persisted. Side-effect configure guards then preserved the poisoned store value. Root fix: - Add meshtasticConfigurePhase shared flag wired from runtime wire effects - Branch upsertNode on fromUserPacket → mergeMeshtasticUserPacketLastHeard - Respect configure phase in meshtasticLastHeardPatch (position/telemetry/trace) - Reset BLE configure stall watchdog on each replayed node_info (60s idle cap) Follow-ups in the same change: - Normalize topology JSON export last_heard to Unix seconds - Fix isMeshPeerOnline and raw-packet stale checks for sec/ms mixed storage - Hydrate DB last_heard as epoch ms in memory for Meshtastic nodes - Document configure guard ownership in docs/agents/meshtastic.md Tests: nodeStore.configureReplay, PacketRouter configure replay integration, stall-timeout reset, hydration normalization, and regression cases. --- docs/agents/meshtastic.md | 2 +- src/renderer/components/NodeListPanel.tsx | 10 +- .../lib/buildMeshPeerTopologyGraph.test.ts | 6 + .../lib/buildMeshPeerTopologyGraph.ts | 5 +- .../meshtastic/meshtasticConfigurePhase.ts | 30 +++ .../meshtasticNodeSideEffects.test.ts | 126 ++++++++++++ .../meshtasticRawPacketSideEffects.test.ts | 29 +++ .../meshtasticRawPacketSideEffects.ts | 4 +- ...sticRuntimeWireEffects.post-reboot.test.ts | 28 ++- .../meshtasticRuntimeWireEffects.ts | 33 +++- .../lib/meshtasticDbCacheHydration.test.ts | 24 +++ .../lib/meshtasticDbCacheHydration.ts | 3 +- src/renderer/lib/meshtasticLastHeard.test.ts | 9 + src/renderer/lib/timeConstants.ts | 4 +- ...htasticRuntime.reconnect-hardening.test.ts | 2 +- src/renderer/runtime/useMeshtasticRuntime.ts | 3 + .../stores/nodeStore.configureReplay.test.ts | 184 ++++++++++++++++++ src/renderer/stores/nodeStore.ts | 28 ++- 18 files changed, 508 insertions(+), 22 deletions(-) create mode 100644 src/renderer/lib/meshtastic/meshtasticConfigurePhase.ts create mode 100644 src/renderer/stores/nodeStore.configureReplay.test.ts diff --git a/docs/agents/meshtastic.md b/docs/agents/meshtastic.md index 064bdc4be..2b91313f2 100644 --- a/docs/agents/meshtastic.md +++ b/docs/agents/meshtastic.md @@ -9,5 +9,5 @@ Deep subsystem reference for AI assistants. Open this when a task touches Meshta - **S&F chat history:** `src/renderer/lib/meshtasticBacklogUtils.ts` — `CLIENT_HISTORY` on primary router heartbeat after RF configure (auto: 50-msg cap, 120 min window cap, 15 min per-server cooldown, 5 min offline gate; `storeForwardAutoFetchHistory` opt-out; `storeForwardHistoryProfile: 'conservative' | 'aggressive'` in `defaultAppSettings.ts` tunes offline gate / cooldown / cap aggressiveness; manual catch-up in Chat). Protobuf decode for replayed text, `via_store_forward` on messages; do not await SDK queue for history (async replay). - **MQTT broker clientId:** `src/main/mqtt-broker-client-id.ts` — stable per-install IDs in `app_settings` (`meshtasticMqttClientId`, `meshcoreMqttClientId`); MeshCore LetsMesh `v1_` username unchanged as clientId. - **PKC remote admin (firmware 2.5+):** `meshtasticRemoteAdmin.ts` — PKI-wrapped `AdminMessage` via `MeshDevice.sendRaw()` (`pkiEncrypted: true`, channel omitted on wire); session passkeys (~300s); tab-scoped snapshot routes in `meshtasticRemoteAdminSnapshot.ts` (Channels-first LoRa load). Per-node keys: `meshtasticRemoteAdminKeyStorage.ts` (`meshtasticRemoteAdminKey:` in `app_settings`; base64 / `base64:` / 64-char hex paste). Dest public key: NodeDB hex first, stored admin-key base64 fallback. `useMeshtasticRuntime`: `configureTargetNodeNum`, `remoteConfigSnapshot`, `runRemoteAdminOp` (errors → UI + toast); serialize admin reads with S&F (`remoteAdminReadsActiveCount` in `meshtasticBacklogUtils.ts`). **Requires connected local radio** (MQTT-only cannot admin). UI: `ConfigureNodeSelector.tsx`; NodeDetailModal admin key + **Configure node remotely**; SecurityPanel **Copy** public key. Persist last target in `meshtasticConfigureTargetNodeNum`. Gate with `hasRemoteAdmin`. Legacy admin channel (PSK + `"admin"`) out of scope. -- **Meshtastic last heard:** `meshtasticLastHeard.ts` — bump `last_heard` on live RF packets (not only text); `computeNodeInfoLastHeardMs` prevents configure replay from regressing fresher client timestamps. +- **Meshtastic last heard:** `meshtasticLastHeard.ts` — bump `last_heard` on live RF packets (not only text); `computeNodeInfoLastHeardMs` merges radio NodeDB timestamps with client-side values (max wins). **Configure replay guard** must apply in `nodeStore` (`upsertNode`, `updatePosition`, `meshtasticLastHeardPatch`) via `meshtasticConfigurePhase.ts`, not only in `meshtasticNodeSideEffects` — PacketRouter updates the store before side effects run. During `device.configure()`, the Meshtastic SDK replays NodeDB as `node_info` frames and may emit synthetic `onUserPacket` / `onPositionPacket` with `rxTime = now`; guards skip those bumps. UserPacket path uses `mergeMeshtasticUserPacketLastHeard` (ms); NodeDB path uses `computeNodeInfoLastHeardMs` (sec). BLE configure uses a **stall watchdog** (`MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS`, reset on each replayed `node_info`) so large NodeDBs are not cut off mid-stream. - **Static GPS:** `src/renderer/lib/gpsSource.ts` — App tab static coordinates sync to self-node, map, and radio `setPosition`. diff --git a/src/renderer/components/NodeListPanel.tsx b/src/renderer/components/NodeListPanel.tsx index cbde9fa34..7b8256407 100644 --- a/src/renderer/components/NodeListPanel.tsx +++ b/src/renderer/components/NodeListPanel.tsx @@ -75,7 +75,12 @@ import { } from '../lib/meshtasticSourceIcons'; import { nodeHealthScore, nodeHealthTier } from '../lib/nodeHealthScore'; import { getNodeTypeIcon } from '../lib/nodeIcons'; -import { getNodeStatus, haversineDistanceKm, normalizeLastHeardMs } from '../lib/nodeStatus'; +import { + getNodeStatus, + haversineDistanceKm, + lastHeardToUnixSeconds, + normalizeLastHeardMs, +} from '../lib/nodeStatus'; import { getOfflineIdentityIdForProtocol } from '../lib/offlineProtocolIdentities'; import { useRadioProvider } from '../lib/radio/providerFactory'; import { RoleDisplay } from '../lib/roleInfo'; @@ -816,7 +821,8 @@ export default function NodeListPanel({ rssi: n.rssi, battery: n.battery, voltage: n.voltage, - last_heard: n.last_heard, + last_heard: lastHeardToUnixSeconds(n.last_heard), + last_heard_unit: 'unix_sec', latitude: n.latitude, longitude: n.longitude, altitude: n.altitude, diff --git a/src/renderer/lib/buildMeshPeerTopologyGraph.test.ts b/src/renderer/lib/buildMeshPeerTopologyGraph.test.ts index b6d1958ed..188b096c8 100644 --- a/src/renderer/lib/buildMeshPeerTopologyGraph.test.ts +++ b/src/renderer/lib/buildMeshPeerTopologyGraph.test.ts @@ -176,6 +176,12 @@ describe('isMeshPeerOnline', () => { it('treats recently heard nodes as online', () => { expect(isMeshPeerOnline(node(1, { last_heard: Date.now() - 1000 }))).toBe(true); }); + + it('treats sec-valued last_heard from DB hydration as recently heard', () => { + const nowMs = Date.now(); + const secHeard = Math.floor((nowMs - 30 * 60_000) / 1000); + expect(isMeshPeerOnline(node(1, { last_heard: secHeard }), nowMs)).toBe(true); + }); }); function peerMap(peerCount: number, hopsAway: number | undefined): Map { diff --git a/src/renderer/lib/buildMeshPeerTopologyGraph.ts b/src/renderer/lib/buildMeshPeerTopologyGraph.ts index 5554a6e3f..4ee2854be 100644 --- a/src/renderer/lib/buildMeshPeerTopologyGraph.ts +++ b/src/renderer/lib/buildMeshPeerTopologyGraph.ts @@ -1,5 +1,6 @@ import type { ForceEdge } from './forceDirectedGraphLayout'; import { nodeHealthScore, type NodeHealthTier, nodeHealthTier } from './nodeHealthScore'; +import { effectiveLastHeardMs } from './nodeStatus'; import { MS_PER_HOUR } from './timeConstants'; import { MESH_TOPOLOGY_NEARBY_MAX_HOPS, @@ -75,7 +76,9 @@ export function isMeshPeerOnline(node: MeshNode, nowMs: number = Date.now()): bo if (hops != null && hops >= 0) return true; const lastHeard = node.last_heard; if (lastHeard <= 0) return false; - return nowMs - lastHeard < MS_PER_HOUR; + const effectiveMs = effectiveLastHeardMs(lastHeard, nowMs); + if (!effectiveMs) return false; + return nowMs - effectiveMs < MS_PER_HOUR; } function nodeLabel(node: MeshNode): string { diff --git a/src/renderer/lib/meshtastic/meshtasticConfigurePhase.ts b/src/renderer/lib/meshtastic/meshtasticConfigurePhase.ts new file mode 100644 index 000000000..537f4b516 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticConfigurePhase.ts @@ -0,0 +1,30 @@ +/** Meshtastic device.configure / NodeDB replay phase — shared by nodeStore and wireEffects. */ + +let configuring = false; +let onConfigureProgress: (() => void) | null = null; + +export function setMeshtasticConfigurePhase(value: boolean): void { + configuring = value; + if (!value) onConfigureProgress = null; +} + +export function getMeshtasticConfigurePhase(): boolean { + return configuring; +} + +/** Register BLE configure stall watchdog reset (wireEffects only). */ +export function setMeshtasticConfigureProgressHandler(handler: (() => void) | null): void { + onConfigureProgress = handler; +} + +/** Reset configure stall timer while NodeDB / FromRadio frames still arrive. */ +export function touchMeshtasticConfigureProgress(): void { + if (!configuring) return; + onConfigureProgress?.(); +} + +/** Test-only reset. */ +export function resetMeshtasticConfigurePhaseForTests(): void { + configuring = false; + onConfigureProgress = null; +} diff --git a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts index d6e3be055..7964710dc 100644 --- a/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticNodeSideEffects.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setConnection } from '../../stores/connectionStore'; import { useDiagnosticsStore } from '../../stores/diagnosticsStore'; import { addIdentity, useIdentityStore } from '../../stores/identityStore'; import { upsertNodeRecord, useNodeStore } from '../../stores/nodeStore'; @@ -10,10 +11,16 @@ import { } from '../connectedMeshcoreBleMac'; import { packetRouter } from '../drivers/PacketRouter'; import { getIdentityNode } from '../identityStoreReads'; +import { getNodeStatus } from '../nodeStatus'; import { meshtasticProtocol } from '../protocols/MeshtasticProtocol'; import { MESH_PROTOCOL_STORAGE_KEY } from '../storedMeshProtocol'; import { meshNodeToNodeRecord } from '../storeRecordAdapters'; +import { MS_PER_DAY } from '../timeConstants'; import type { MeshNode } from '../types'; +import { + resetMeshtasticConfigurePhaseForTests, + setMeshtasticConfigurePhase, +} from './meshtasticConfigurePhase'; import { attachMeshtasticNodeSideEffects, type MeshtasticNodeSideEffectsDeps, @@ -240,6 +247,125 @@ describe('attachMeshtasticNodeSideEffects', () => { detach(); }); + describe('configure replay last_heard', () => { + const STALE_MS = Date.now() - 7 * MS_PER_DAY; + const RADIO_SEC = Math.floor(STALE_MS / 1000); + + beforeEach(() => { + resetMeshtasticConfigurePhaseForTests(); + setMeshtasticConfigurePhase(true); + setConnection(IDENTITY, { myNodeNum: MY_NODE, status: 'connected', connectionType: 'ble' }); + upsertNodeRecord( + IDENTITY, + meshNodeToNodeRecord({ + ...emptyNode(PEER), + long_name: 'Peer Node', + short_name: 'PEER', + last_heard: STALE_MS, + }), + ); + vi.mocked(window.electronAPI.db.saveNode).mockClear(); + }); + + afterEach(() => { + resetMeshtasticConfigurePhaseForTests(); + }); + + it('keeps hydrated timestamp when NodeDB then synthetic UserPacket arrive during configure', () => { + const { deps } = makeDeps({ getIsConfiguring: () => true }); + const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'node_info', + payload: { + nodeId: PEER, + longName: 'Peer Node', + shortName: 'PEER', + lastHeardAt: RADIO_SEC, + fromUserPacket: false, + }, + }, + IDENTITY, + ); + packetRouter.dispatch( + { + type: 'node_info', + payload: { + nodeId: PEER, + longName: 'Peer Node', + shortName: 'PEER', + fromUserPacket: true, + lastHeardAt: Date.now(), + }, + }, + IDENTITY, + ); + const node = getIdentityNode(IDENTITY, PEER); + expect(node?.last_heard).toBe(STALE_MS); + expect(getNodeStatus(node?.last_heard ?? 0)).not.toBe('online'); + const saveCalls = vi.mocked(window.electronAPI.db.saveNode).mock.calls; + expect(saveCalls.length).toBeGreaterThan(0); + const lastSaved = saveCalls[saveCalls.length - 1]?.[0]; + expect(lastSaved.last_heard).toBe(node?.last_heard); + detach(); + }); + + it('saves position without bumping last_heard to position timestamp during configure', () => { + const { deps } = makeDeps({ getIsConfiguring: () => true }); + const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'node_info', + payload: { + nodeId: PEER, + longName: 'Peer Node', + shortName: 'PEER', + lastHeardAt: RADIO_SEC, + latitude: 39.7, + longitude: -105, + fromUserPacket: false, + }, + }, + IDENTITY, + ); + packetRouter.dispatch( + { + type: 'position', + payload: { + nodeId: PEER, + latitude: 39.74, + longitude: -104.99, + timestamp: Date.now(), + }, + }, + IDENTITY, + ); + const node = getIdentityNode(IDENTITY, PEER); + expect(node?.latitude).toBe(39.74); + expect(node?.last_heard).toBe(STALE_MS); + detach(); + }); + + it('leaves week-old seed offline after configure replay sequence', () => { + const { deps } = makeDeps({ getIsConfiguring: () => true }); + const detach = attachMeshtasticNodeSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'node_info', + payload: { + nodeId: PEER, + longName: 'Peer Node', + fromUserPacket: true, + lastHeardAt: Date.now(), + }, + }, + IDENTITY, + ); + expect(getNodeStatus(getIdentityNode(IDENTITY, PEER)?.last_heard ?? 0)).not.toBe('online'); + detach(); + }); + }); + describe('MeshCore BLE ghost suppression', () => { const priorHeard = 1_700_000_000_000; diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts index 229d06fe3..7a846d037 100644 --- a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts @@ -169,6 +169,35 @@ describe('attachMeshtasticRawPacketSideEffects', () => { detach(); }); + it('updates hops for sec-valued last_heard from DB hydration when node is not stale', () => { + const heardSec = Math.floor((Date.now() - 30 * 60_000) / 1000); + const nodeMirror = new Map([ + [PEER, { ...emptyNode(PEER), last_heard: heardSec, snr: 1 }], + ]); + syncNodesMapToIdentityStore(IDENTITY, nodeMirror); + const { deps } = makeDeps(); + const detach = attachMeshtasticRawPacketSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'raw_packet', + payload: { + ts: Date.now(), + snr: 8, + rssi: -85, + raw: new Uint8Array([0xaa]), + fromNodeId: PEER, + portLabel: 'NODEINFO_APP', + viaMqtt: false, + hopsAway: 2, + packetId: 9, + }, + }, + IDENTITY, + ); + expect(getIdentityNode(IDENTITY, PEER)?.hops_away).toBe(2); + detach(); + }); + it('ignores events routed for a different identity', () => { const { deps } = makeDeps(); const detach = attachMeshtasticRawPacketSideEffects(IDENTITY, deps); diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts index 2441f5ac2..69b93081d 100644 --- a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.ts @@ -20,6 +20,7 @@ import { errLikeToLogString } from '../errLikeToLogString'; import { getIdentityNode } from '../identityStoreReads'; import { shouldSuppressMeshtasticNodeHear } from '../meshcoreBleMacMeshtasticNodeId'; import { mergeMeshtasticLivePacketLastHeard } from '../meshtasticLastHeard'; +import { effectiveLastHeardMs } from '../nodeStatus'; import type { RawPacketEntry } from '../protocols/Protocol'; import { MESHTASTIC_CAPABILITIES } from '../radio/BaseRadioProvider'; import { @@ -89,7 +90,8 @@ function applySignalAndHops( const isStale = existing.last_heard > 0 && - Date.now() - existing.last_heard > MESHTASTIC_CAPABILITIES.nodeStaleThresholdMs; + Date.now() - effectiveLastHeardMs(existing.last_heard) > + MESHTASTIC_CAPABILITIES.nodeStaleThresholdMs; const node: MeshNode = { ...existing, ...(payload.snr ? { snr: payload.snr } : {}), diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts index ec6a10a49..b177d6feb 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts @@ -3,6 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS } from '../timeConstants'; import type { ConnectionType, DeviceState } from '../types'; +import { + resetMeshtasticConfigurePhaseForTests, + touchMeshtasticConfigureProgress, +} from './meshtasticConfigurePhase'; import { attachMeshtasticRuntimeWireEffects } from './meshtasticRuntimeWireEffects'; /** DeviceConfiguring — see Types.DeviceStatusEnum */ @@ -171,6 +175,7 @@ function attachBleWithStatusSubscribers( }, }), setHeartbeatInterval: vi.fn(), + heartbeat: vi.fn().mockResolvedValue(undefined), } as unknown as MeshDevice; attachMeshtasticRuntimeWireEffects(device, 'ble', { driverIdentityId: 'id-1' }, deps); return statusSubscribers; @@ -254,12 +259,14 @@ describe('meshtasticRuntimeWireEffects DeviceRestarting', () => { describe('meshtasticRuntimeWireEffects BLE configure timeout arming', () => { beforeEach(() => { vi.useFakeTimers(); + resetMeshtasticConfigurePhaseForTests(); }); afterEach(() => { vi.useRealTimers(); + resetMeshtasticConfigurePhaseForTests(); }); - it('arms 30s timeout on DeviceConfiguring when reconnect is inactive', () => { + it('arms stall timeout on DeviceConfiguring when reconnect is inactive', () => { const { deps, configureTimeoutRef } = makeDeps({ isBleReconnectAttemptActive: () => false, }); @@ -281,7 +288,7 @@ describe('meshtasticRuntimeWireEffects BLE configure timeout arming', () => { expect(configureTimeoutRef.current).toBeNull(); }); - it('fires handleConnectionLost after BLE configure timeout when armed', () => { + it('fires handleConnectionLost after BLE configure stall timeout when armed', () => { const { deps, configureTimeoutRef } = makeDeps({ isBleReconnectAttemptActive: () => false, }); @@ -296,4 +303,21 @@ describe('meshtasticRuntimeWireEffects BLE configure timeout arming', () => { expect(onLost).toHaveBeenCalledTimes(1); expect(configureTimeoutRef.current).toBeNull(); }); + + it('resets stall timer when configure progress arrives mid-stream', () => { + const { deps, configureTimeoutRef } = makeDeps({ + isBleReconnectAttemptActive: () => false, + }); + const onLost = vi.mocked(deps.handleConnectionLostRef.current); + const statusSubscribers = attachBleWithStatusSubscribers(deps); + + for (const cb of statusSubscribers) cb(DEVICE_CONFIGURING); + vi.advanceTimersByTime(MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS - 5_000); + touchMeshtasticConfigureProgress(); + vi.advanceTimersByTime(MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS - 5_000); + expect(onLost).not.toHaveBeenCalled(); + vi.advanceTimersByTime(10_000); + expect(onLost).toHaveBeenCalledTimes(1); + expect(configureTimeoutRef.current).toBeNull(); + }); }); diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts index 46361c8dc..e32072320 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts @@ -41,6 +41,10 @@ import type { TelemetryPoint, } from '../types'; import { recordMeshtasticClientNotification } from './meshtasticClientNotification'; +import { + setMeshtasticConfigurePhase, + setMeshtasticConfigureProgressHandler, +} from './meshtasticConfigurePhase'; import { meshtasticDeviceStatusForCode } from './meshtasticDeviceStatus'; import { shouldFetchLocalLoraConfigAfterConfigure } from './meshtasticLocalLoraConfig'; import type { ModulePortEvent, PaxCounterPoint } from './meshtasticModuleEvents'; @@ -267,6 +271,24 @@ export function attachMeshtasticRuntimeWireEffects( clearTimeout(metadataRetryTimerRef.current); metadataRetryTimerRef.current = null; } + setMeshtasticConfigureProgressHandler(null); + }); + + const armBleConfigureStallTimeout = (): void => { + if (type !== 'ble' || isBleReconnectAttemptActive()) return; + clearConfigureTimeout(); + configureTimeoutRef.current = setTimeout(() => { + console.warn( + `[useMeshtasticRuntime] configure stall timeout (BLE ${MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS / 1000}s) — forcing disconnect`, + ); + clearConfigureTimeout(); + handleConnectionLostRef.current(); + }, MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS); + }; + + setMeshtasticConfigureProgressHandler(() => { + if (!isConfiguringRef.current || type !== 'ble' || isBleReconnectAttemptActive()) return; + armBleConfigureStallTimeout(); }); const { @@ -411,6 +433,7 @@ export function attachMeshtasticRuntimeWireEffects( if (status === DeviceStatusEnum.DeviceRestarting) { deviceConfiguredRef.current = false; isConfiguringRef.current = true; + setMeshtasticConfigurePhase(true); meshtasticIngestSessionRef.current?.setConfiguring(true); schedulePostCommitRebootRecoveryRef.current('DeviceRestarting'); } @@ -422,6 +445,7 @@ export function attachMeshtasticRuntimeWireEffects( status === DeviceStatusEnum.DeviceConfiguring ) { isConfiguringRef.current = true; + setMeshtasticConfigurePhase(true); meshtasticIngestSessionRef.current?.setConfiguring(true); // Initial BLE connect only — during reconnect the 90s attempt budget owns stall detection. if ( @@ -430,11 +454,7 @@ export function attachMeshtasticRuntimeWireEffects( !configureTimeoutRef.current && !isBleReconnectAttemptActive() ) { - configureTimeoutRef.current = setTimeout(() => { - console.warn('[useMeshtasticRuntime] configure timeout (BLE 30s) — forcing disconnect'); - clearConfigureTimeout(); - handleConnectionLostRef.current(); - }, MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS); + armBleConfigureStallTimeout(); } } @@ -443,6 +463,7 @@ export function attachMeshtasticRuntimeWireEffects( clearPostCommitRebootRecoveryRef.current(); clearConfigureTimeout(); isConfiguringRef.current = false; + setMeshtasticConfigurePhase(false); meshtasticIngestSessionRef.current?.setConfiguring(false); lastDataReceivedRef.current = Date.now(); startWatchdog(); @@ -497,6 +518,8 @@ export function attachMeshtasticRuntimeWireEffects( lastNodeInfoRequestAtRef.current.clear(); clearConfigureTimeout(); isConfiguringRef.current = false; + setMeshtasticConfigurePhase(false); + meshtasticIngestSessionRef.current?.setConfiguring(false); stopWatchdog(); stopGpsInterval(); cleanupSubscriptions(); diff --git a/src/renderer/lib/meshtasticDbCacheHydration.test.ts b/src/renderer/lib/meshtasticDbCacheHydration.test.ts index 1e6a5963d..4d84036de 100644 --- a/src/renderer/lib/meshtasticDbCacheHydration.test.ts +++ b/src/renderer/lib/meshtasticDbCacheHydration.test.ts @@ -63,6 +63,30 @@ describe('buildMeshtasticNodeMapFromDbRows', () => { expect(map.has(0xabc123)).toBe(false); expect(map.has(42)).toBe(true); }); + + it('normalizes SQLite last_heard seconds to epoch ms in memory', () => { + const map = buildMeshtasticNodeMapFromDbRows([ + { + node_id: 42, + long_name: 'T-Beam', + short_name: 'TB', + hw_model: 'TBEAM', + battery: 0, + snr: 0, + rssi: 0, + last_heard: 1_700_000_000, + latitude: null, + longitude: null, + role: 0, + favorited: 0, + source: 'rf', + hops: null, + path: null, + hops_away: 0, + } as never, + ]); + expect(map.get(42)?.last_heard).toBe(1_700_000_000_000); + }); }); describe('dedupeMeshtasticHydrationOrphanSends', () => { diff --git a/src/renderer/lib/meshtasticDbCacheHydration.ts b/src/renderer/lib/meshtasticDbCacheHydration.ts index 45edfda67..250990bde 100644 --- a/src/renderer/lib/meshtasticDbCacheHydration.ts +++ b/src/renderer/lib/meshtasticDbCacheHydration.ts @@ -7,6 +7,7 @@ import { MAX_IN_MEMORY_CHAT_MESSAGES, trimChatMessagesToMax } from './chatInMemo import { meshtasticHwModelName } from './hardwareModels'; import { meshcoreHwModelIsContactTypeLabel } from './meshcoreUtils'; import { getMeshtasticMessageLoadLimit } from './meshtasticMessageLoadLimit'; +import { normalizeLastHeardMs } from './nodeStatus'; import type { ChatMessage, MeshNode } from './types'; const LEGACY_ROLE_STRINGS: Record = { @@ -76,7 +77,7 @@ export function buildMeshtasticNodeMapFromDbRows( snr: n.snr ?? 0, rssi: n.rssi ?? undefined, battery: n.battery ?? 0, - last_heard: n.last_heard ?? 0, + last_heard: normalizeLastHeardMs(n.last_heard ?? 0), latitude: n.latitude, longitude: n.longitude, role: parseNodeRole(n.role), diff --git a/src/renderer/lib/meshtasticLastHeard.test.ts b/src/renderer/lib/meshtasticLastHeard.test.ts index 8cdef2ed7..f16c10f9e 100644 --- a/src/renderer/lib/meshtasticLastHeard.test.ts +++ b/src/renderer/lib/meshtasticLastHeard.test.ts @@ -105,6 +105,15 @@ describe('computeNodeInfoLastHeardMs', () => { expect(lastHeardMs).toBeGreaterThanOrEqual(before); expect(lastHeardMs).toBeLessThanOrEqual(after); }); + + it('clamps ms-sized input mistaken for NodeDB seconds (UserPacket must not use this path)', () => { + const before = Date.now(); + const msInput = 1_756_000_000_000; + const lastHeardMs = computeNodeInfoLastHeardMs(msInput, 0, false); + const after = Date.now(); + expect(lastHeardMs).toBeGreaterThanOrEqual(before); + expect(lastHeardMs).toBeLessThanOrEqual(after); + }); }); describe('meshtasticTracerouteLastHeardNodeIds', () => { diff --git a/src/renderer/lib/timeConstants.ts b/src/renderer/lib/timeConstants.ts index 04af6f32e..4c0d43a81 100644 --- a/src/renderer/lib/timeConstants.ts +++ b/src/renderer/lib/timeConstants.ts @@ -221,8 +221,8 @@ export const MESHCORE_ROOM_SYNC_ROUTE_RESOLVE_FAST_MS = 15_000; /** Delay before one retry of getMetadata after configure (NodeDB flood can starve BLE). */ export const MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS = 8_000; -/** BLE configure stall watchdog — force disconnect if DeviceConfigured never arrives. */ -export const MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS = 30 * MS_PER_SECOND; +/** BLE configure stall watchdog — force disconnect if FromRadio progress stalls. */ +export const MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS = 60 * MS_PER_SECOND; /** * Hard ceiling for one LoRa reconnect open+configure/attach attempt (Meshtastic + MeshCore), diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index b521a14ab..e3b5848c7 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -290,7 +290,7 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { ); expect(wireSource).toContain('!isBleReconnectAttemptActive()'); expect(wireSource).toMatch( - /configure timeout \(BLE 30s\)[\s\S]*?handleConnectionLostRef\.current\(\)/, + /configure stall timeout \(BLE [\s\S]*?handleConnectionLostRef\.current\(\)/, ); }); diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 6cbbd2ce0..ad2c55ffb 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -117,6 +117,7 @@ import { } from '../lib/meshcoreDualNobleBleInit'; import { meshtasticTransportParams } from '../lib/meshIdentityBridge'; import { setMeshtasticRemoteConfigTarget } from '../lib/meshtastic/meshtasticConfigIngressGuard'; +import { setMeshtasticConfigurePhase } from '../lib/meshtastic/meshtasticConfigurePhase'; import { configureMeshtasticDeviceWithRetry } from '../lib/meshtastic/meshtasticConfigureRetry'; import type { ModulePortEvent, PaxCounterPoint } from '../lib/meshtastic/meshtasticModuleEvents'; import { normalizeMeshtasticMqttChatMessage } from '../lib/meshtastic/meshtasticMqttChatNormalize'; @@ -976,6 +977,7 @@ export function useMeshtasticRuntime() { postRebootRecoveryScheduledRef.current = true; deviceConfiguredRef.current = false; isConfiguringRef.current = true; + setMeshtasticConfigurePhase(true); meshtasticIngestSessionRef.current?.setConfiguring(true); stopWatchdog(); stopGpsInterval(); @@ -2040,6 +2042,7 @@ export function useMeshtasticRuntime() { clearPostCommitRebootRecovery(); deviceConfiguredRef.current = false; isConfiguringRef.current = false; + setMeshtasticConfigurePhase(false); meshtasticIngestSessionRef.current?.setConfiguring(false); // Tear down GATT/SDK before unsubscribing so toDevice stays defined for disconnect. clearConfigureTimeout(); diff --git a/src/renderer/stores/nodeStore.configureReplay.test.ts b/src/renderer/stores/nodeStore.configureReplay.test.ts new file mode 100644 index 000000000..6ffc31430 --- /dev/null +++ b/src/renderer/stores/nodeStore.configureReplay.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + resetMeshtasticConfigurePhaseForTests, + setMeshtasticConfigurePhase, +} from '../lib/meshtastic/meshtasticConfigurePhase'; +import { getNodeStatus } from '../lib/nodeStatus'; +import { meshtasticProtocol } from '../lib/protocols/MeshtasticProtocol'; +import { MS_PER_DAY } from '../lib/timeConstants'; +import { setConnection } from './connectionStore'; +import { addIdentity } from './identityStore'; +import { + bumpMeshtasticNodesLastHeardAt, + updatePosition, + updateTelemetry, + upsertNode, + useNodeStore, +} from './nodeStore'; + +const ID_MT = 'id-mt-config-replay'; +const PEER = 42; +const MY_NODE = 1; +const NOW = new Date('2026-08-19T20:48:30.000Z').getTime(); + +function seedPeer(lastHeardAt: number): void { + useNodeStore.setState({ + nodes: { [ID_MT]: { [PEER]: { nodeId: PEER, lastHeardAt } } }, + traceRoutes: {}, + waypoints: {}, + neighborInfo: {}, + }); +} + +describe('nodeStore configure replay last_heard', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + resetMeshtasticConfigurePhaseForTests(); + useNodeStore.setState({ nodes: {}, traceRoutes: {}, waypoints: {}, neighborInfo: {} }); + addIdentity({ + id: ID_MT, + protocol: meshtasticProtocol, + signature: 'meshtastic:config-replay', + transports: [], + createdAt: NOW, + lastSeenAt: NOW, + }); + setConnection(ID_MT, { myNodeNum: MY_NODE, status: 'connected', connectionType: 'ble' }); + }); + + afterEach(() => { + vi.useRealTimers(); + resetMeshtasticConfigurePhaseForTests(); + }); + + it('does not bump UserPacket last_heard during configure', () => { + const staleMs = NOW - 7 * MS_PER_DAY; + seedPeer(staleMs); + setMeshtasticConfigurePhase(true); + upsertNode(ID_MT, { + nodeId: PEER, + fromUserPacket: true, + lastHeardAt: NOW, + longName: 'Peer', + }); + expect(useNodeStore.getState().nodes[ID_MT][PEER].lastHeardAt).toBe(staleMs); + }); + + it('bumps UserPacket last_heard after configure', () => { + const staleMs = NOW - 7 * MS_PER_DAY; + seedPeer(staleMs); + setMeshtasticConfigurePhase(false); + upsertNode(ID_MT, { + nodeId: PEER, + fromUserPacket: true, + lastHeardAt: NOW, + longName: 'Peer', + }); + expect(useNodeStore.getState().nodes[ID_MT][PEER].lastHeardAt).toBe(NOW); + }); + + it('UserPacket path preserves epoch ms (does not treat as NodeDB seconds)', () => { + const staleMs = NOW - 7 * MS_PER_DAY; + seedPeer(staleMs); + setMeshtasticConfigurePhase(false); + const rxMs = NOW - 60_000; + upsertNode(ID_MT, { + nodeId: PEER, + fromUserPacket: true, + lastHeardAt: rxMs, + longName: 'Peer', + }); + expect(useNodeStore.getState().nodes[ID_MT][PEER].lastHeardAt).toBe(rxMs); + }); + + it('applies NodeDB radio lastHeard during configure', () => { + const staleMs = NOW - 7 * MS_PER_DAY; + seedPeer(staleMs); + setMeshtasticConfigurePhase(true); + const radioSec = Math.floor((NOW - 3_600_000) / 1000); + upsertNode(ID_MT, { + nodeId: PEER, + fromUserPacket: false, + lastHeardAt: radioSec, + longName: 'Peer', + }); + expect(useNodeStore.getState().nodes[ID_MT][PEER].lastHeardAt).toBe(radioSec * 1000); + }); + + it('NodeDB preserves fresher client last_heard over stale radio', () => { + const clientMs = NOW - 60_000; + seedPeer(clientMs); + setMeshtasticConfigurePhase(true); + const radioSec = Math.floor((NOW - 7 * MS_PER_DAY) / 1000); + upsertNode(ID_MT, { + nodeId: PEER, + fromUserPacket: false, + lastHeardAt: radioSec, + longName: 'Peer', + }); + expect(useNodeStore.getState().nodes[ID_MT][PEER].lastHeardAt).toBe(clientMs); + }); + + it('does not bump position last_heard during configure', () => { + const staleMs = NOW - 7 * MS_PER_DAY; + seedPeer(staleMs); + setMeshtasticConfigurePhase(true); + updatePosition(ID_MT, { + nodeId: PEER, + latitude: 39.7, + longitude: -105, + timestamp: NOW, + }); + const node = useNodeStore.getState().nodes[ID_MT][PEER]; + expect(node.lastHeardAt).toBe(staleMs); + expect(node.latitude).toBe(39.7); + }); + + it('bumps position last_heard after configure', () => { + seedPeer(0); + setMeshtasticConfigurePhase(false); + updatePosition(ID_MT, { + nodeId: PEER, + latitude: 39.7, + longitude: -105, + timestamp: NOW, + }); + expect(useNodeStore.getState().nodes[ID_MT][PEER].lastHeardAt).toBe(NOW); + }); + + it('does not bump telemetry last_heard during configure', () => { + const staleMs = NOW - 7 * MS_PER_DAY; + seedPeer(staleMs); + setMeshtasticConfigurePhase(true); + updateTelemetry(ID_MT, { + nodeId: PEER, + timestamp: NOW, + batteryLevel: 80, + variantCase: 'deviceMetrics', + }); + expect(useNodeStore.getState().nodes[ID_MT][PEER].lastHeardAt).toBe(staleMs); + }); + + it('does not bump traceroute last_heard during configure', () => { + const staleMs = NOW - 7 * MS_PER_DAY; + seedPeer(staleMs); + setMeshtasticConfigurePhase(true); + bumpMeshtasticNodesLastHeardAt(ID_MT, [PEER], NOW); + expect(useNodeStore.getState().nodes[ID_MT][PEER].lastHeardAt).toBe(staleMs); + }); + + it('self node NodeDB with zero lastHeard still falls back to now during configure', () => { + setMeshtasticConfigurePhase(true); + upsertNode(ID_MT, { + nodeId: MY_NODE, + fromUserPacket: false, + lastHeardAt: 0, + longName: 'Self', + }); + const lastHeardAt = useNodeStore.getState().nodes[ID_MT][MY_NODE].lastHeardAt; + expect(lastHeardAt).toBe(NOW); + expect(getNodeStatus(lastHeardAt!)).toBe('online'); + }); +}); diff --git a/src/renderer/stores/nodeStore.ts b/src/renderer/stores/nodeStore.ts index 0702051b9..5ce9a664c 100644 --- a/src/renderer/stores/nodeStore.ts +++ b/src/renderer/stores/nodeStore.ts @@ -2,9 +2,14 @@ import { create } from 'zustand'; import { preferNonEmptyTrimmedString } from '@/shared/nodeNameUtils'; +import { + getMeshtasticConfigurePhase, + touchMeshtasticConfigureProgress, +} from '../lib/meshtastic/meshtasticConfigurePhase'; import { computeNodeInfoLastHeardMs, mergeMeshtasticLivePacketLastHeard, + mergeMeshtasticUserPacketLastHeard, } from '../lib/meshtasticLastHeard'; import { mergeMeshcoreLastHeardFromAdvert } from '../lib/nodeStatus'; import type { @@ -196,11 +201,22 @@ export function upsertNode(identityId: IdentityId, event: NodeInfoEvent): void { } else if (protocolType === 'meshtastic') { const selfNum = getConnection(identityId)?.myNodeNum ?? 0; const isSelf = selfNum > 0 && nodeId === selfNum; - lastHeardAt = computeNodeInfoLastHeardMs( - eventLastHeardAt, - existing?.lastHeardAt ?? 0, - isSelf, - ); + if (event.fromUserPacket) { + lastHeardAt = mergeMeshtasticUserPacketLastHeard( + existing?.lastHeardAt ?? 0, + eventLastHeardAt ?? 0, + getMeshtasticConfigurePhase(), + ); + } else { + lastHeardAt = computeNodeInfoLastHeardMs( + eventLastHeardAt, + existing?.lastHeardAt ?? 0, + isSelf, + ); + } + if (getMeshtasticConfigurePhase()) { + touchMeshtasticConfigureProgress(); + } } const identityFields = nodeIdentityPatch( existing, @@ -272,7 +288,7 @@ function meshtasticLastHeardPatch( const merged = mergeMeshtasticLivePacketLastHeard( existingLastHeardAt ?? 0, packetTimestampMs, - false, + getMeshtasticConfigurePhase(), ); return merged > 0 ? merged : undefined; } From 0e7ce7dac7180b383d6f179a522245ca1efe68ce Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 15:42:31 -0600 Subject: [PATCH 3/4] fix(meshtastic): harden configure replay stall watchdog and phase guards Keep the BLE configure progress handler alive across refresh cycles, set configure phase before device.configure(), unify on getMeshtasticConfigurePhase, and reset the stall timer only on NodeDB replay paths. --- docs/agents/ble-serial.md | 3 +- docs/agents/common-issues.md | 91 ++++++++++--------- docs/agents/meshtastic.md | 2 +- .../meshtasticConfigurePhase.test.ts | 37 ++++++++ .../meshtastic/meshtasticConfigurePhase.ts | 2 +- .../meshtasticRawPacketSideEffects.test.ts | 38 ++++++++ ...sticRuntimeWireEffects.post-reboot.test.ts | 22 +++++ .../meshtasticRuntimeWireEffects.ts | 11 ++- src/renderer/runtime/useMeshtasticRuntime.ts | 11 ++- .../stores/nodeStore.configureReplay.test.ts | 22 +++++ src/renderer/stores/nodeStore.ts | 10 +- 11 files changed, 194 insertions(+), 55 deletions(-) create mode 100644 src/renderer/lib/meshtastic/meshtasticConfigurePhase.test.ts diff --git a/docs/agents/ble-serial.md b/docs/agents/ble-serial.md index d401fc64f..2eb2595c4 100644 --- a/docs/agents/ble-serial.md +++ b/docs/agents/ble-serial.md @@ -9,7 +9,8 @@ Meshtastic and MeshCore share LoRa BLE reconnect contracts whenever possible (pl - **`rfReconnectController`** (`lib/rfReconnectController.ts`): single-owner link-lost / schedule / endAttempt for both runtimes (MeshCore TCP uses the same owner; conn side effects must not call `handleConnectionLost` for TCP). - **`gattSetupInflight`** in `noble-ble-manager` (both session ids): after `connectAsync`, mid-GATT disconnect rejects in-flight `connect()` promptly (Noble only). - **Deferred Noble disconnect** while connect/reconnect open is in flight; **flush in reconnect `finally`** in `useMeshtasticRuntime` and `useMeshcoreRuntime` so edge-of-range drops keep retrying. -- **`NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS`** (`timeConstants.ts`) + `raceWithDeadline` (`bleReconnectHelper.ts`): hard ceiling per BLE reconnect open+handshake attempt on **all platforms** (unsticks configure/attach hangs; does not add a Linux cross-protocol mutex). +- **`NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS`** (`timeConstants.ts`, 2× `MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS`) + `raceWithDeadline` (`bleReconnectHelper.ts`): hard ceiling per BLE reconnect open+handshake attempt on **all platforms** (unsticks configure/attach hangs; does not add a Linux cross-protocol mutex). +- **Meshtastic BLE configure stall watchdog:** `MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS` (60s) in `meshtasticRuntimeWireEffects.ts`; timer resets on NodeDB replay progress via `touchMeshtasticConfigureProgress()` from `nodeStore` (`node_info`, position, telemetry — not synthetic UserPackets). - **Mutex:** Noble IPC connects still go through `withNobleBleConnectMutex()` — budget must not leave that mutex held (timeout clears in-flight flags; late open losers are ignored via attempt-active / generation guards). - MeshCore must **not** start the runtime reconnect loop on disconnect before the first successful configure — ConnectionPanel `reconnectBleWithScan` owns initial retries (`meshcoreEverConfiguredRef`). **Manual disconnect** (`connectionStore.disconnectIntent`) must **not** auto-reconnect — covered by `useMeshcoreRuntime.reconnect.test.ts`, `useMeshtasticRuntime.reconnect-hardening.test.ts`, and `useReticulumRuntime.reconnect-hardening.test.ts`. diff --git a/docs/agents/common-issues.md b/docs/agents/common-issues.md index ee344f1c3..570f6f62d 100644 --- a/docs/agents/common-issues.md +++ b/docs/agents/common-issues.md @@ -2,48 +2,49 @@ Deep subsystem reference for AI assistants. Symptom → where-to-check index. Hard rules live in [`AGENTS.md`](../../AGENTS.md). -| Symptom | Where to check | -| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Connection fails | `ConnectionDriver`, `useProtocolConnection.ts`, `runtime/useMeshtasticRuntime.ts`, `runtime/useMeshcoreRuntime.ts` | -| Empty chat/nodes offline | `hydrateIdentityStoresFromDb`, connect-time cache in runtimes, `useDbRefresh`; identity split — [troubleshooting](../troubleshooting.md#chat-stuck-new-traffic-in-logsdb-but-messages-do-not-appear) | -| Chat stuck / badge moves, no new rows | `identityByProtocol`, `useActiveMeshIdentity`, `mergeOfflineIdentityStore`; **Export for GitHub** — [troubleshooting](../troubleshooting.md#reporting-bugs-export-for-github-app-tab) | -| BLE timeout | `noble-ble-manager.ts`, `bleConnectErrors` | -| Reticulum sidecar won't start | `reticulum-sidecar-manager.ts`, `ipc/reticulum-handlers.ts`, [troubleshooting](../troubleshooting.md#reticulum-sidecar-wont-start-or-health-poll-times-out) | -| Nomad hosting enabled but not serving | `nomadServingApi.ts`, `reticulum-sidecar/src/stack/nomad_server.rs` / `nomad_content_source.rs`, `[nomad-serving]` logs, `nomadPageErrorHumanize.ts` — [troubleshooting](../troubleshooting.md#nomad-my-pages-hosting-enabled-but-not-serving) | -| Reticulum interface CRUD fails | `ReticulumInterfacesPanel.tsx` / `ReticulumStackPanel.tsx`, `proxyPut`/`proxyDelete` — [troubleshooting](../troubleshooting.md#reticulum-interface-addeditdelete-fails) | -| Reticulum Remote transfer / inbound policy | `RemoteTransferSection.tsx` / `RemoteSettingsSection.tsx`, `rncpTransferStore.ts` / `reticulumInboundPolicyStore.ts`, `pushRncpListenerPolicy.ts` — [troubleshooting](../troubleshooting.md#reticulum-remote-transfer-fails-or-path_constrained) | -| Reticulum LXST voice fails / silent | `reticulumVoiceSession.ts`, `reticulumVoiceStore.ts`, sidecar `voice_session.rs`; [troubleshooting](../troubleshooting.md#reticulum-lxst-voice-call-fails-or-is-silent) | -| Reticulum LXMF hangs with Auto + LAN hub | sidecar `auto_path_policy.rs` / `lxmf_outbound.rs`; [troubleshooting](../troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub) | -| Reticulum public hub TCP fast-flap block | `reticulumStackSessionTracker.ts` (five stack starts in 12h), `ReticulumLocalInterfaceAlertsBlock.tsx` (`tcp_fast_flap` / unreachable hint), `reticulumTcpInterfaceRecovery.ts`; [troubleshooting](../troubleshooting.md#reticulum-public-hub-tcp-blocked-fast-flapping-client) | -| PN Sync PATH_UNKNOWN / stuck retrieve busy | `PROPAGATION_PATH_UNKNOWN` gate in `live.rs`; Cancel → `abort_transfer` overlay; Host silent `/get` latch ownership; [troubleshooting](../troubleshooting.md#reticulum-remote-propagation-sync-fails-or-never-completes) | -| Auto picks a bad Discovered PN | Network → Propagation **Ignore for Auto** (`propagation_auto_blacklist`); filters Auto sync + deposit only | -| Proxy IPC storm after wake | `useReticulumInterfaceSnapshot` must not refresh on `announce.received`; shared proxy rate-limit soft envelope | -| Serial port auto-rediscovery | `serialPortAutoRediscovery.ts` (60 s window, 5 s poll) — [troubleshooting](../troubleshooting.md#serial-port-auto-rediscovery-after-reconnect-exhaustion) | -| Meshtastic MQTT text on wrong channel tab | `mqtt-manager.ts` (`resolveMqttInboundTextChannelIndex`), debug snapshot `meshtastic.channelPills` / `channelConfigsSummary` / `mqttChannelKeyEntryCount` — [troubleshooting](../troubleshooting.md#meshtastic-inbound-messages-on-the-wrong-channel-tab) | -| Chat export fails | `chat:export` handler in `src/main/index.ts` | -| Support export fails | `support:exportBundle` in `src/main/support-bundle.ts`; App tab **Export for GitHub** / **Export for Developer** | -| Draft not restored | `chatPanelProtocolStorage.ts`, `viewKey` logic | -| MeshCore send blocked (message too long) | `chatComposerLimits.ts` (`getMaxChunks` = 1, `splitChatMessage` → `null`, `overMaxSingle`), `ChatComposer.tsx` — single-packet, no outbound `[i/N]` split; also gates room posts (`getMeshcoreRoomPayloadLimit`). See [chat.md](chat.md), [parity](../meshcore-meshtastic-parity.md) | -| MeshCore "sending too fast" advisory | `meshcoreSendRateNotice.ts` (`recordMeshcoreSend` / `isMeshcoreSendTooFast`, `MESHCORE_FAST_SEND_WARN_INTERVAL_MS`), `ChatComposer.tsx`; non-blocking `role="status"`, clock also fed by GIF / share-location / outbox drain (`useChatOutbox.ts`) | -| Mention picker missing | `MentionAutocomplete.tsx`, `buildMentionCandidates` | -| Link preview missing | `fetchLinkPreview.ts`, `chat:fetchLinkPreview` IPC; also check direct-image extension/MIME, YouTube oEmbed, and magic-byte sniff failures; previews always fetch (including while reading history) | -| Duplicate RF+MQTT msg | `meshtasticMessageDedup.ts`, Meshtastic runtime ingest | -| MeshCore duplicate/echo | `meshcoreStoreDedup.ts`, `useMeshcoreRuntime.ts` | -| Room login/post fails | `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, [troubleshooting](../troubleshooting.md#meshcore-room-server-login-posts-and-windows-10) | -| Rooms unread vs Chat | `meshcoreRoomsUnread.ts` — Rooms tab badge only; orphan room SQL filtered by known Room contacts; contact delete cascades room messages (`deleteMeshcoreContactOn`); tombstones in `meshcoreLocallyDeletedContacts.ts` | -| MQTT decrypt / sender | `mqtt-manager.ts`, `meshtasticMqttIdentity.ts` | -| Remote admin fails | `meshtasticRemoteAdmin.ts`, key storage | -| S&F history garbled | `meshtasticBacklogUtils.ts` decode, heartbeat trigger | -| Garbled TEXT_MESSAGE | `meshtasticBacklogUtils.ts` readable-text filter | -| Channel URL apply | `meshtasticChannelApply.ts`, `meshtasticUrlEncoder.ts` | -| Header red on loss | `connectionHeaderStatus.ts`, `mqttDisconnectIntent.ts` | -| Sleep/wake reconnect | `usePowerRecovery`, `systemPowerState`, `bleReconnectHelper`, `rfReconnectHelper`, runtimes; Meshtastic ~4s + MeshCore ~8s stagger + up to 30s dual-Noble settle | -| MeshCore contact prune | `meshcoreContactAgeCutoff.ts`, `database.ts` (`last_advert` seconds); favorited exempt | -| MQTT transient after wake | `src/shared/networkTransientErrors.ts`, `mqtt:powerSuspend` / `mqtt:powerResume` IPC | -| MeshCore ping no route / priming | `meshcoreTraceRoutePrime.ts`, `meshcoreHookPreamble.ts`, `meshcore.errors.pingNoRoute`; [troubleshooting](../troubleshooting.md#meshcore-trace-route-or-ping-trace-times-out) | -| Repeater CLI danger / auto-ping | `meshcoreRepeaterCliDanger.ts`, `RepeatersPanel.tsx` (`ensureCliRoutePrimed`); `repeatersPanel.cliMultiHopHint` | -| Room vs repeater LoginFail | `meshcoreRoomLoginRpc.ts` (fail fast) vs `meshcoreRepeaterLoginRpc.ts` + `meshcoreRepeaterPrefixPushRpc.ts` (wait for LoginSuccess) | -| Renderer hung after wake | `rendererHeartbeatWatchdog.ts`, `useRendererHeartbeat`; visible stall + export `mainLiveness`; [troubleshooting](../troubleshooting.md#macos-sleep--wake-and-auto-reconnect) — quit fully if no `[usePowerRecovery]` after resume watchdog | -| MeshCore TCP mid-init peer FIN | `useMeshcoreRuntime` initConn / `meshcore:tcp-*`; [troubleshooting](../troubleshooting.md#meshcore-tcp-connect-stuck-or-reconnect-loop-on-openhop) | -| Chat hop pills missing | MeshCore: `meshcoreCompanionRxPathLenToHopCount` / `MeshCoreProtocol` / `meshcoreRawPacketCorrelate` / `meshcoreIngest`; Meshtastic: `meshtasticRfHops.ts` (`viaMqtt` / `hopStart===0` omit by design) | -| Meshtastic SDK routing console noise | `meshtasticSdkRoutingErrorConsoleHook.ts`, `meshtasticSdkRoutingErrorLog.ts`; late `Packet does not exist` swallow is **post-teardown only** (`armMeshtasticLateConfigureRetryableSwallow` / `rendererUnhandledRejection.ts`) — do not widen to full session | +| Symptom | Where to check | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Connection fails | `ConnectionDriver`, `useProtocolConnection.ts`, `runtime/useMeshtasticRuntime.ts`, `runtime/useMeshcoreRuntime.ts` | +| Empty chat/nodes offline | `hydrateIdentityStoresFromDb`, connect-time cache in runtimes, `useDbRefresh`; identity split — [troubleshooting](../troubleshooting.md#chat-stuck-new-traffic-in-logsdb-but-messages-do-not-appear) | +| Chat stuck / badge moves, no new rows | `identityByProtocol`, `useActiveMeshIdentity`, `mergeOfflineIdentityStore`; **Export for GitHub** — [troubleshooting](../troubleshooting.md#reporting-bugs-export-for-github-app-tab) | +| BLE timeout | `noble-ble-manager.ts`, `bleConnectErrors` | +| Reticulum sidecar won't start | `reticulum-sidecar-manager.ts`, `ipc/reticulum-handlers.ts`, [troubleshooting](../troubleshooting.md#reticulum-sidecar-wont-start-or-health-poll-times-out) | +| Nomad hosting enabled but not serving | `nomadServingApi.ts`, `reticulum-sidecar/src/stack/nomad_server.rs` / `nomad_content_source.rs`, `[nomad-serving]` logs, `nomadPageErrorHumanize.ts` — [troubleshooting](../troubleshooting.md#nomad-my-pages-hosting-enabled-but-not-serving) | +| Reticulum interface CRUD fails | `ReticulumInterfacesPanel.tsx` / `ReticulumStackPanel.tsx`, `proxyPut`/`proxyDelete` — [troubleshooting](../troubleshooting.md#reticulum-interface-addeditdelete-fails) | +| Reticulum Remote transfer / inbound policy | `RemoteTransferSection.tsx` / `RemoteSettingsSection.tsx`, `rncpTransferStore.ts` / `reticulumInboundPolicyStore.ts`, `pushRncpListenerPolicy.ts` — [troubleshooting](../troubleshooting.md#reticulum-remote-transfer-fails-or-path_constrained) | +| Reticulum LXST voice fails / silent | `reticulumVoiceSession.ts`, `reticulumVoiceStore.ts`, sidecar `voice_session.rs`; [troubleshooting](../troubleshooting.md#reticulum-lxst-voice-call-fails-or-is-silent) | +| Reticulum LXMF hangs with Auto + LAN hub | sidecar `auto_path_policy.rs` / `lxmf_outbound.rs`; [troubleshooting](../troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub) | +| Reticulum public hub TCP fast-flap block | `reticulumStackSessionTracker.ts` (five stack starts in 12h), `ReticulumLocalInterfaceAlertsBlock.tsx` (`tcp_fast_flap` / unreachable hint), `reticulumTcpInterfaceRecovery.ts`; [troubleshooting](../troubleshooting.md#reticulum-public-hub-tcp-blocked-fast-flapping-client) | +| PN Sync PATH_UNKNOWN / stuck retrieve busy | `PROPAGATION_PATH_UNKNOWN` gate in `live.rs`; Cancel → `abort_transfer` overlay; Host silent `/get` latch ownership; [troubleshooting](../troubleshooting.md#reticulum-remote-propagation-sync-fails-or-never-completes) | +| Auto picks a bad Discovered PN | Network → Propagation **Ignore for Auto** (`propagation_auto_blacklist`); filters Auto sync + deposit only | +| Proxy IPC storm after wake | `useReticulumInterfaceSnapshot` must not refresh on `announce.received`; shared proxy rate-limit soft envelope | +| Serial port auto-rediscovery | `serialPortAutoRediscovery.ts` (60 s window, 5 s poll) — [troubleshooting](../troubleshooting.md#serial-port-auto-rediscovery-after-reconnect-exhaustion) | +| Meshtastic MQTT text on wrong channel tab | `mqtt-manager.ts` (`resolveMqttInboundTextChannelIndex`), debug snapshot `meshtastic.channelPills` / `channelConfigsSummary` / `mqttChannelKeyEntryCount` — [troubleshooting](../troubleshooting.md#meshtastic-inbound-messages-on-the-wrong-channel-tab) | +| Chat export fails | `chat:export` handler in `src/main/index.ts` | +| Support export fails | `support:exportBundle` in `src/main/support-bundle.ts`; App tab **Export for GitHub** / **Export for Developer** | +| Draft not restored | `chatPanelProtocolStorage.ts`, `viewKey` logic | +| MeshCore send blocked (message too long) | `chatComposerLimits.ts` (`getMaxChunks` = 1, `splitChatMessage` → `null`, `overMaxSingle`), `ChatComposer.tsx` — single-packet, no outbound `[i/N]` split; also gates room posts (`getMeshcoreRoomPayloadLimit`). See [chat.md](chat.md), [parity](../meshcore-meshtastic-parity.md) | +| MeshCore "sending too fast" advisory | `meshcoreSendRateNotice.ts` (`recordMeshcoreSend` / `isMeshcoreSendTooFast`, `MESHCORE_FAST_SEND_WARN_INTERVAL_MS`), `ChatComposer.tsx`; non-blocking `role="status"`, clock also fed by GIF / share-location / outbox drain (`useChatOutbox.ts`) | +| Mention picker missing | `MentionAutocomplete.tsx`, `buildMentionCandidates` | +| Link preview missing | `fetchLinkPreview.ts`, `chat:fetchLinkPreview` IPC; also check direct-image extension/MIME, YouTube oEmbed, and magic-byte sniff failures; previews always fetch (including while reading history) | +| Duplicate RF+MQTT msg | `meshtasticMessageDedup.ts`, Meshtastic runtime ingest | +| MeshCore duplicate/echo | `meshcoreStoreDedup.ts`, `useMeshcoreRuntime.ts` | +| Room login/post fails | `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, [troubleshooting](../troubleshooting.md#meshcore-room-server-login-posts-and-windows-10) | +| Rooms unread vs Chat | `meshcoreRoomsUnread.ts` — Rooms tab badge only; orphan room SQL filtered by known Room contacts; contact delete cascades room messages (`deleteMeshcoreContactOn`); tombstones in `meshcoreLocallyDeletedContacts.ts` | +| MQTT decrypt / sender | `mqtt-manager.ts`, `meshtasticMqttIdentity.ts` | +| Remote admin fails | `meshtasticRemoteAdmin.ts`, key storage | +| S&F history garbled | `meshtasticBacklogUtils.ts` decode, heartbeat trigger | +| Garbled TEXT_MESSAGE | `meshtasticBacklogUtils.ts` readable-text filter | +| Channel URL apply | `meshtasticChannelApply.ts`, `meshtasticUrlEncoder.ts` | +| Header red on loss | `connectionHeaderStatus.ts`, `mqttDisconnectIntent.ts` | +| Sleep/wake reconnect | `usePowerRecovery`, `systemPowerState`, `bleReconnectHelper`, `rfReconnectHelper`, runtimes; Meshtastic ~4s + MeshCore ~8s stagger + up to 30s dual-Noble settle | +| MeshCore contact prune | `meshcoreContactAgeCutoff.ts`, `database.ts` (`last_advert` seconds); favorited exempt | +| MQTT transient after wake | `src/shared/networkTransientErrors.ts`, `mqtt:powerSuspend` / `mqtt:powerResume` IPC | +| MeshCore ping no route / priming | `meshcoreTraceRoutePrime.ts`, `meshcoreHookPreamble.ts`, `meshcore.errors.pingNoRoute`; [troubleshooting](../troubleshooting.md#meshcore-trace-route-or-ping-trace-times-out) | +| Repeater CLI danger / auto-ping | `meshcoreRepeaterCliDanger.ts`, `RepeatersPanel.tsx` (`ensureCliRoutePrimed`); `repeatersPanel.cliMultiHopHint` | +| Room vs repeater LoginFail | `meshcoreRoomLoginRpc.ts` (fail fast) vs `meshcoreRepeaterLoginRpc.ts` + `meshcoreRepeaterPrefixPushRpc.ts` (wait for LoginSuccess) | +| Renderer hung after wake | `rendererHeartbeatWatchdog.ts`, `useRendererHeartbeat`; visible stall + export `mainLiveness`; [troubleshooting](../troubleshooting.md#macos-sleep--wake-and-auto-reconnect) — quit fully if no `[usePowerRecovery]` after resume watchdog | +| MeshCore TCP mid-init peer FIN | `useMeshcoreRuntime` initConn / `meshcore:tcp-*`; [troubleshooting](../troubleshooting.md#meshcore-tcp-connect-stuck-or-reconnect-loop-on-openhop) | +| Chat hop pills missing | MeshCore: `meshcoreCompanionRxPathLenToHopCount` / `MeshCoreProtocol` / `meshcoreRawPacketCorrelate` / `meshcoreIngest`; Meshtastic: `meshtasticRfHops.ts` (`viaMqtt` / `hopStart===0` omit by design) | +| Meshtastic SDK routing console noise | `meshtasticSdkRoutingErrorConsoleHook.ts`, `meshtasticSdkRoutingErrorLog.ts`; late `Packet does not exist` swallow is **post-teardown only** (`armMeshtasticLateConfigureRetryableSwallow` / `rendererUnhandledRejection.ts`) — do not widen to full session | +| All Meshtastic nodes show online after BLE connect/configure | `nodeStore.ts` configure replay guards, `meshtasticConfigurePhase.ts`, `meshtasticLastHeard.ts`; [meshtastic.md](meshtastic.md#meshtastic-last-heard) | diff --git a/docs/agents/meshtastic.md b/docs/agents/meshtastic.md index 2b91313f2..134eef588 100644 --- a/docs/agents/meshtastic.md +++ b/docs/agents/meshtastic.md @@ -9,5 +9,5 @@ Deep subsystem reference for AI assistants. Open this when a task touches Meshta - **S&F chat history:** `src/renderer/lib/meshtasticBacklogUtils.ts` — `CLIENT_HISTORY` on primary router heartbeat after RF configure (auto: 50-msg cap, 120 min window cap, 15 min per-server cooldown, 5 min offline gate; `storeForwardAutoFetchHistory` opt-out; `storeForwardHistoryProfile: 'conservative' | 'aggressive'` in `defaultAppSettings.ts` tunes offline gate / cooldown / cap aggressiveness; manual catch-up in Chat). Protobuf decode for replayed text, `via_store_forward` on messages; do not await SDK queue for history (async replay). - **MQTT broker clientId:** `src/main/mqtt-broker-client-id.ts` — stable per-install IDs in `app_settings` (`meshtasticMqttClientId`, `meshcoreMqttClientId`); MeshCore LetsMesh `v1_` username unchanged as clientId. - **PKC remote admin (firmware 2.5+):** `meshtasticRemoteAdmin.ts` — PKI-wrapped `AdminMessage` via `MeshDevice.sendRaw()` (`pkiEncrypted: true`, channel omitted on wire); session passkeys (~300s); tab-scoped snapshot routes in `meshtasticRemoteAdminSnapshot.ts` (Channels-first LoRa load). Per-node keys: `meshtasticRemoteAdminKeyStorage.ts` (`meshtasticRemoteAdminKey:` in `app_settings`; base64 / `base64:` / 64-char hex paste). Dest public key: NodeDB hex first, stored admin-key base64 fallback. `useMeshtasticRuntime`: `configureTargetNodeNum`, `remoteConfigSnapshot`, `runRemoteAdminOp` (errors → UI + toast); serialize admin reads with S&F (`remoteAdminReadsActiveCount` in `meshtasticBacklogUtils.ts`). **Requires connected local radio** (MQTT-only cannot admin). UI: `ConfigureNodeSelector.tsx`; NodeDetailModal admin key + **Configure node remotely**; SecurityPanel **Copy** public key. Persist last target in `meshtasticConfigureTargetNodeNum`. Gate with `hasRemoteAdmin`. Legacy admin channel (PSK + `"admin"`) out of scope. -- **Meshtastic last heard:** `meshtasticLastHeard.ts` — bump `last_heard` on live RF packets (not only text); `computeNodeInfoLastHeardMs` merges radio NodeDB timestamps with client-side values (max wins). **Configure replay guard** must apply in `nodeStore` (`upsertNode`, `updatePosition`, `meshtasticLastHeardPatch`) via `meshtasticConfigurePhase.ts`, not only in `meshtasticNodeSideEffects` — PacketRouter updates the store before side effects run. During `device.configure()`, the Meshtastic SDK replays NodeDB as `node_info` frames and may emit synthetic `onUserPacket` / `onPositionPacket` with `rxTime = now`; guards skip those bumps. UserPacket path uses `mergeMeshtasticUserPacketLastHeard` (ms); NodeDB path uses `computeNodeInfoLastHeardMs` (sec). BLE configure uses a **stall watchdog** (`MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS`, reset on each replayed `node_info`) so large NodeDBs are not cut off mid-stream. +- **Meshtastic last heard:** `meshtasticLastHeard.ts` — bump `last_heard` on live RF packets (not only text); `computeNodeInfoLastHeardMs` merges radio NodeDB timestamps with client-side values (max wins). **Configure replay guard** must apply in `nodeStore` (`upsertNode`, `updatePosition`, `meshtasticLastHeardPatch`) via `meshtasticConfigurePhase.ts`, not only in `meshtasticNodeSideEffects` — PacketRouter updates the store before side effects run. During `device.configure()`, the Meshtastic SDK replays NodeDB as `node_info` frames and may emit synthetic `onUserPacket` / `onPositionPacket` with `rxTime = now`; guards skip those bumps. UserPacket path uses `mergeMeshtasticUserPacketLastHeard` (ms); NodeDB path uses `computeNodeInfoLastHeardMs` (sec). SQLite stores `last_heard` as unix seconds; `meshtasticDbCacheHydration.ts` normalizes to ms on hydrate (`normalizeLastHeardMs`); stale/online checks use `effectiveLastHeardMs`. BLE configure uses a **stall watchdog** (`MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS`, reset on each replayed `node_info` / position / telemetry via `touchMeshtasticConfigureProgress`) so large NodeDBs are not cut off mid-stream. Node list JSON export emits `last_heard` as unix seconds with `last_heard_unit: 'unix_sec'`. - **Static GPS:** `src/renderer/lib/gpsSource.ts` — App tab static coordinates sync to self-node, map, and radio `setPosition`. diff --git a/src/renderer/lib/meshtastic/meshtasticConfigurePhase.test.ts b/src/renderer/lib/meshtastic/meshtasticConfigurePhase.test.ts new file mode 100644 index 000000000..8bcf8d742 --- /dev/null +++ b/src/renderer/lib/meshtastic/meshtasticConfigurePhase.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + getMeshtasticConfigurePhase, + resetMeshtasticConfigurePhaseForTests, + setMeshtasticConfigurePhase, + setMeshtasticConfigureProgressHandler, + touchMeshtasticConfigureProgress, +} from './meshtasticConfigurePhase'; + +describe('meshtasticConfigurePhase', () => { + it('keeps progress handler after configure ends so refresh can reset stall timer', () => { + resetMeshtasticConfigurePhaseForTests(); + const onProgress = vi.fn(); + setMeshtasticConfigureProgressHandler(onProgress); + + setMeshtasticConfigurePhase(true); + touchMeshtasticConfigureProgress(); + expect(onProgress).toHaveBeenCalledTimes(1); + + setMeshtasticConfigurePhase(false); + expect(getMeshtasticConfigurePhase()).toBe(false); + + setMeshtasticConfigurePhase(true); + touchMeshtasticConfigureProgress(); + expect(onProgress).toHaveBeenCalledTimes(2); + }); + + it('does not invoke progress handler when configure phase is inactive', () => { + resetMeshtasticConfigurePhaseForTests(); + const onProgress = vi.fn(); + setMeshtasticConfigureProgressHandler(onProgress); + + touchMeshtasticConfigureProgress(); + expect(onProgress).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/lib/meshtastic/meshtasticConfigurePhase.ts b/src/renderer/lib/meshtastic/meshtasticConfigurePhase.ts index 537f4b516..6ca706b69 100644 --- a/src/renderer/lib/meshtastic/meshtasticConfigurePhase.ts +++ b/src/renderer/lib/meshtastic/meshtasticConfigurePhase.ts @@ -5,7 +5,7 @@ let onConfigureProgress: (() => void) | null = null; export function setMeshtasticConfigurePhase(value: boolean): void { configuring = value; - if (!value) onConfigureProgress = null; + // Progress handler lifetime == wire attach lifetime (cleared on detach / test reset only). } export function getMeshtasticConfigurePhase(): boolean { diff --git a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts index 7a846d037..af593622b 100644 --- a/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRawPacketSideEffects.test.ts @@ -142,6 +142,44 @@ describe('attachMeshtasticRawPacketSideEffects', () => { detach(); }); + it('does not bump last_heard during configure replay', () => { + const staleHeard = Date.now() - 7 * 24 * 60 * 60_000; + const nodeMirror = new Map([ + [PEER, { ...emptyNode(PEER), last_heard: staleHeard, snr: 1 }], + ]); + syncNodesMapToIdentityStore(IDENTITY, nodeMirror); + const deps: MeshtasticRawPacketSideEffectsDeps = { + getMyNodeNum: () => MY_NODE, + getIsConfiguring: () => true, + setRawPackets: vi.fn(), + setSignalTelemetry: vi.fn(), + touchLastData: vi.fn(), + }; + const detach = attachMeshtasticRawPacketSideEffects(IDENTITY, deps); + packetRouter.dispatch( + { + type: 'raw_packet', + payload: { + ts: Date.now(), + snr: 12, + rssi: -70, + raw: new Uint8Array([0x01]), + fromNodeId: PEER, + portLabel: 'TEXT_MESSAGE_APP', + viaMqtt: false, + hopsAway: 1, + packetId: 99, + portnum: 1, + }, + }, + IDENTITY, + ); + const node = getIdentityNode(IDENTITY, PEER); + expect(node?.last_heard).toBe(staleHeard); + expect(node?.snr).toBe(12); + detach(); + }); + it('skips sniffer log when the active protocol tab is not meshtastic', () => { localStorage.setItem(MESH_PROTOCOL_STORAGE_KEY, 'meshcore'); const { deps } = makeDeps(); diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts index b177d6feb..50ae9da79 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts @@ -11,6 +11,8 @@ import { attachMeshtasticRuntimeWireEffects } from './meshtasticRuntimeWireEffec /** DeviceConfiguring — see Types.DeviceStatusEnum */ const DEVICE_CONFIGURING = 6; +/** DeviceConfigured — see Types.DeviceStatusEnum */ +const DEVICE_CONFIGURED = 7; function makeDeps(opts?: { isBleReconnectAttemptActive?: () => boolean }) { const touchLastData = vi.fn(); @@ -320,4 +322,24 @@ describe('meshtasticRuntimeWireEffects BLE configure timeout arming', () => { expect(onLost).toHaveBeenCalledTimes(1); expect(configureTimeoutRef.current).toBeNull(); }); + + it('resets stall timer after DeviceConfigured when configure runs again', () => { + const { deps, configureTimeoutRef } = makeDeps({ + isBleReconnectAttemptActive: () => false, + }); + const onLost = vi.mocked(deps.handleConnectionLostRef.current); + const statusSubscribers = attachBleWithStatusSubscribers(deps); + + for (const cb of statusSubscribers) cb(DEVICE_CONFIGURING); + for (const cb of statusSubscribers) cb(DEVICE_CONFIGURED); + for (const cb of statusSubscribers) cb(DEVICE_CONFIGURING); + + vi.advanceTimersByTime(MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS - 5_000); + touchMeshtasticConfigureProgress(); + vi.advanceTimersByTime(MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS - 5_000); + expect(onLost).not.toHaveBeenCalled(); + vi.advanceTimersByTime(10_000); + expect(onLost).toHaveBeenCalledTimes(1); + expect(configureTimeoutRef.current).toBeNull(); + }); }); diff --git a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts index e32072320..15bb9a95e 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts @@ -42,6 +42,7 @@ import type { } from '../types'; import { recordMeshtasticClientNotification } from './meshtasticClientNotification'; import { + getMeshtasticConfigurePhase, setMeshtasticConfigurePhase, setMeshtasticConfigureProgressHandler, } from './meshtasticConfigurePhase'; @@ -287,7 +288,7 @@ export function attachMeshtasticRuntimeWireEffects( }; setMeshtasticConfigureProgressHandler(() => { - if (!isConfiguringRef.current || type !== 'ble' || isBleReconnectAttemptActive()) return; + if (!getMeshtasticConfigurePhase() || type !== 'ble' || isBleReconnectAttemptActive()) return; armBleConfigureStallTimeout(); }); @@ -413,7 +414,7 @@ export function attachMeshtasticRuntimeWireEffects( } if (identityId) { meshtasticIngestSessionRef.current = attachMeshtasticIngest(identityId, { - getIsConfiguring: () => isConfiguringRef.current, + getIsConfiguring: getMeshtasticConfigurePhase, getMyNodeNum: () => myNodeNumRef.current, }); } @@ -650,7 +651,7 @@ export function attachMeshtasticRuntimeWireEffects( opts?: { ignoreDisplayIdentity?: boolean }, ): void => { if (from === 0 || from === myNodeNumRef.current) return; - if (isConfiguringRef.current) return; + if (getMeshtasticConfigurePhase()) return; // Missing-recipient-key recovery must refresh even nodes that already have a // display name (we know who they are, we just lack a usable public key), so it // opts out of the display-identity short-circuit while keeping the rate limit. @@ -702,7 +703,7 @@ export function attachMeshtasticRuntimeWireEffects( }), attachMeshtasticRawPacketSideEffects(identityId, { getMyNodeNum: () => myNodeNumRef.current, - getIsConfiguring: () => isConfiguringRef.current, + getIsConfiguring: getMeshtasticConfigurePhase, setRawPackets, setSignalTelemetry, touchLastData, @@ -745,7 +746,7 @@ export function attachMeshtasticRuntimeWireEffects( attachMeshtasticNodeSideEffects(identityId, { connectionType: type, getMyNodeNum: () => myNodeNumRef.current, - getIsConfiguring: () => isConfiguringRef.current, + getIsConfiguring: getMeshtasticConfigurePhase, getBluetoothDeviceId: () => (device.transport as { __bluetoothDevice?: { id?: string } }).__bluetoothDevice?.id, touchLastData, diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index ad2c55ffb..96fcad664 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -1935,7 +1935,7 @@ export function useMeshtasticRuntime() { clearConfigureTimeout, // isReconnectingRef only — not reconnectConnectInFlightRef. Manual prepareRfConnect clears // reconnecting while a superseded attempt may still hold in-flight; OR-ing would skip the - // 30s configure watchdog on the new connect (which has no 90s reconnect budget). + // 60s configure watchdog on the new connect (which has no 90s reconnect budget). isBleReconnectAttemptActive: () => isReconnectingRef.current, applyMeshtasticForeignLoraFromLog, emptyNode, @@ -2261,6 +2261,9 @@ export function useMeshtasticRuntime() { await lateTransport.cleanup(opened.driverIdentityId); throw new Error('Reconnect superseded before configure'); } + isConfiguringRef.current = true; + setMeshtasticConfigurePhase(true); + meshtasticIngestSessionRef.current?.setConfiguring(true); await configureMeshtasticDeviceWithRetry(opened.device, { logTag: 'useMeshtasticRuntime reconnect', }); @@ -2587,6 +2590,9 @@ export function useMeshtasticRuntime() { } })(); + isConfiguringRef.current = true; + setMeshtasticConfigurePhase(true); + meshtasticIngestSessionRef.current?.setConfiguring(true); await configureMeshtasticDeviceWithRetry(activeDevice, { logTag: 'useMeshtasticRuntime attachRfSession', }); @@ -4105,6 +4111,9 @@ export function useMeshtasticRuntime() { const requestRefresh = useCallback(async () => { if (!deviceRef.current) return; + isConfiguringRef.current = true; + setMeshtasticConfigurePhase(true); + meshtasticIngestSessionRef.current?.setConfiguring(true); await deviceRef.current.configure(); }, []); diff --git a/src/renderer/stores/nodeStore.configureReplay.test.ts b/src/renderer/stores/nodeStore.configureReplay.test.ts index 6ffc31430..7245007e4 100644 --- a/src/renderer/stores/nodeStore.configureReplay.test.ts +++ b/src/renderer/stores/nodeStore.configureReplay.test.ts @@ -121,6 +121,20 @@ describe('nodeStore configure replay last_heard', () => { expect(useNodeStore.getState().nodes[ID_MT][PEER].lastHeardAt).toBe(clientMs); }); + it('NodeDB applies fresher radio last_heard over stale client during configure', () => { + const clientMs = NOW - 3_600_000; + seedPeer(clientMs); + setMeshtasticConfigurePhase(true); + const radioSec = Math.floor((NOW - 1_800_000) / 1000); + upsertNode(ID_MT, { + nodeId: PEER, + fromUserPacket: false, + lastHeardAt: radioSec, + longName: 'Peer', + }); + expect(useNodeStore.getState().nodes[ID_MT][PEER].lastHeardAt).toBe(radioSec * 1000); + }); + it('does not bump position last_heard during configure', () => { const staleMs = NOW - 7 * MS_PER_DAY; seedPeer(staleMs); @@ -169,6 +183,14 @@ describe('nodeStore configure replay last_heard', () => { expect(useNodeStore.getState().nodes[ID_MT][PEER].lastHeardAt).toBe(staleMs); }); + it('bumps traceroute last_heard after configure', () => { + const staleMs = NOW - 7 * MS_PER_DAY; + seedPeer(staleMs); + setMeshtasticConfigurePhase(false); + bumpMeshtasticNodesLastHeardAt(ID_MT, [PEER], NOW); + expect(useNodeStore.getState().nodes[ID_MT][PEER].lastHeardAt).toBe(NOW); + }); + it('self node NodeDB with zero lastHeard still falls back to now during configure', () => { setMeshtasticConfigurePhase(true); upsertNode(ID_MT, { diff --git a/src/renderer/stores/nodeStore.ts b/src/renderer/stores/nodeStore.ts index 5ce9a664c..79ed64c23 100644 --- a/src/renderer/stores/nodeStore.ts +++ b/src/renderer/stores/nodeStore.ts @@ -214,7 +214,7 @@ export function upsertNode(identityId: IdentityId, event: NodeInfoEvent): void { isSelf, ); } - if (getMeshtasticConfigurePhase()) { + if (getMeshtasticConfigurePhase() && !event.fromUserPacket) { touchMeshtasticConfigureProgress(); } } @@ -293,6 +293,12 @@ function meshtasticLastHeardPatch( return merged > 0 ? merged : undefined; } +function maybeTouchMeshtasticNodeDbConfigureProgress(): void { + if (getMeshtasticConfigurePhase()) { + touchMeshtasticConfigureProgress(); + } +} + /** Toggle favorite flag on a node in the identity-scoped store (UI reads this bucket). */ export function patchNodeFavorited( identityId: IdentityId, @@ -378,6 +384,7 @@ export function updatePosition(identityId: IdentityId, event: PositionEvent): vo const { nodeId, latitude, longitude, altitude, timestamp, groundSpeed, groundTrack } = event; const existing = byId[nodeId]; const lastHeardAt = meshtasticLastHeardPatch(identityId, timestamp, existing?.lastHeardAt); + maybeTouchMeshtasticNodeDbConfigureProgress(); return { nodes: { ...s.nodes, @@ -416,6 +423,7 @@ export function updateTelemetry(identityId: IdentityId, event: TelemetryEvent): } = event; const existing = byId[nodeId]; const lastHeardAt = meshtasticLastHeardPatch(identityId, timestamp, existing?.lastHeardAt); + maybeTouchMeshtasticNodeDbConfigureProgress(); return { nodes: { ...s.nodes, From 467f4a0e9b8b3974e95a798dfb73f17c8c678a92 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 16:00:42 -0600 Subject: [PATCH 4/4] fix(meshtastic): reset configure phase on failure and harden progress guards Clear shared configure state when connect, reconnect, or refresh configure fails so live last_heard updates are not suppressed. Gate configure progress touches to Meshtastic identities only. Add regression tests for JSON export units, configure rejection teardown, and MeshCore position during configure. --- .../components/NodeListPanel.test.tsx | 39 ++++++++++++++ ...tasticRuntime.configure-rejection.test.tsx | 52 +++++++++++++++++++ ...htasticRuntime.reconnect-hardening.test.ts | 11 ++++ src/renderer/runtime/useMeshtasticRuntime.ts | 14 ++++- .../stores/nodeStore.configureReplay.test.ts | 30 +++++++++++ src/renderer/stores/nodeStore.ts | 7 +-- 6 files changed, 149 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/NodeListPanel.test.tsx b/src/renderer/components/NodeListPanel.test.tsx index c3277675b..8c21ac578 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -103,6 +103,12 @@ vi.mock('./Toast', () => ({ }), })); +vi.mock('../lib/downloadBlob', () => ({ + downloadBlob: vi.fn(), +})); + +import { downloadBlob } from '../lib/downloadBlob'; + const defaultFilter = { enabled: false, maxDistance: 500, @@ -858,6 +864,39 @@ describe('NodeListPanel meshtastic node id display', () => { }); }); +describe('NodeListPanel JSON export', () => { + beforeEach(() => { + vi.mocked(downloadBlob).mockClear(); + }); + + it('exports millisecond last_heard as unix seconds with last_heard_unit', async () => { + const user = userEvent.setup(); + const lastHeardMs = 1_700_000_000_000; + const nodes = new Map([ + [42, makeNode({ node_id: 42, long_name: 'Export Node', last_heard: lastHeardMs })], + ]); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Export JSON' })); + expect(downloadBlob).toHaveBeenCalledTimes(1); + const [blob] = vi.mocked(downloadBlob).mock.calls[0]; + const text = await blob.text(); + const parsed = JSON.parse(text) as { + nodes: { last_heard: number; last_heard_unit: string }[]; + }; + expect(parsed.nodes[0]?.last_heard).toBe(1_700_000_000); + expect(parsed.nodes[0]?.last_heard_unit).toBe('unix_sec'); + }); +}); + describe('NodeListPanel show on map', () => { beforeEach(() => { positionHistoryStoreState.history = new Map(); diff --git a/src/renderer/hooks/useMeshtasticRuntime.configure-rejection.test.tsx b/src/renderer/hooks/useMeshtasticRuntime.configure-rejection.test.tsx index 0e0b5ffe3..c58032d76 100644 --- a/src/renderer/hooks/useMeshtasticRuntime.configure-rejection.test.tsx +++ b/src/renderer/hooks/useMeshtasticRuntime.configure-rejection.test.tsx @@ -3,7 +3,13 @@ import { renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as connection from '../lib/connection'; +import { getMeshtasticConfigurePhase } from '../lib/meshtastic/meshtasticConfigurePhase'; +import { meshtasticProtocol } from '../lib/protocols/MeshtasticProtocol'; +import { MS_PER_DAY } from '../lib/timeConstants'; import { useMeshtasticRuntime } from '../runtime/useMeshtasticRuntime'; +import { setConnection } from '../stores/connectionStore'; +import { addIdentity } from '../stores/identityStore'; +import { upsertNode, useNodeStore } from '../stores/nodeStore'; vi.mock('../lib/connection', () => ({ createBleConnection: vi.fn(), @@ -50,4 +56,50 @@ describe('useMeshtasticRuntime — configure() rejection', () => { expect(result.current.state.status).toBe('disconnected'); }); }); + + it('clears configure phase after rejected configure so live node updates bump last_heard', async () => { + const ID = 'id-config-reject-live'; + const PEER = 42; + const staleMs = Date.now() - 7 * MS_PER_DAY; + const liveMs = Date.now() - 60_000; + + useNodeStore.setState({ nodes: {}, traceRoutes: {}, waypoints: {}, neighborInfo: {} }); + addIdentity({ + id: ID, + protocol: meshtasticProtocol, + signature: 'meshtastic:config-reject', + transports: [], + createdAt: Date.now(), + lastSeenAt: Date.now(), + }); + setConnection(ID, { myNodeNum: 1, status: 'connected', connectionType: 'http' }); + useNodeStore.setState({ + nodes: { [ID]: { [PEER]: { nodeId: PEER, lastHeardAt: staleMs } } }, + traceRoutes: {}, + waypoints: {}, + neighborInfo: {}, + }); + + const err = new Error('Configure stalled'); + const device = createStubDevice(vi.fn().mockRejectedValue(err)); + vi.mocked(connection.createConnection).mockResolvedValue(device); + + const { result } = renderHook(() => useMeshtasticRuntime()); + + await expect(result.current.connect('http', 'http://127.0.0.1')).rejects.toThrow( + 'Configure stalled', + ); + + await waitFor(() => { + expect(getMeshtasticConfigurePhase()).toBe(false); + }); + + upsertNode(ID, { + nodeId: PEER, + fromUserPacket: true, + lastHeardAt: liveMs, + longName: 'Peer', + }); + expect(useNodeStore.getState().nodes[ID][PEER].lastHeardAt).toBe(liveMs); + }); }); diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index e3b5848c7..ad34c2a7a 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -70,6 +70,17 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { expect(failureBlock.length).toBeGreaterThan(0); expect(failureBlock).toContain('isReconnectingRef.current = false'); expect(failureBlock).toContain('reconnectGenerationRef.current += 1'); + expect(failureBlock).toContain('clearMeshtasticConfigureState()'); + }); + + it('clears configure phase on reconnect attempt error', () => { + const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); + expect(reconnectBody).toMatch(/onAttemptError:[\s\S]*?clearMeshtasticConfigureState\(\)/); + }); + + it('clears configure phase in requestRefresh finally', () => { + const refreshBody = extractUseCallbackBody(SOURCE, 'requestRefresh'); + expect(refreshBody).toMatch(/finally[\s\S]*?clearMeshtasticConfigureState\(\)/); }); it('exports power suspend/resume handlers for usePowerRecovery', () => { diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 96fcad664..a0aea4ff6 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -442,6 +442,12 @@ export function useMeshtasticRuntime() { const isConfiguringRef = useRef(false); const configureTimeoutRef = useRef | null>(null); + const clearMeshtasticConfigureState = (): void => { + isConfiguringRef.current = false; + setMeshtasticConfigurePhase(false); + meshtasticIngestSessionRef.current?.setConfiguring(false); + }; + // ─── GPS tracking ───────────────────────────────────────────── const deviceGpsModeRef = useRef(0); // 0=DISABLED,1=ENABLED,2=NOT_PRESENT const gpsIntervalRef = useRef | null>(null); @@ -2302,6 +2308,7 @@ export function useMeshtasticRuntime() { requestChatOutboxDrain('meshtastic'); }, onAttemptError: async (err, { lateTransport }) => { + clearMeshtasticConfigureState(); const failedDriverIdentity = openedDriverIdentityId ?? meshtasticIdentityIdRef.current ?? @@ -2607,6 +2614,7 @@ export function useMeshtasticRuntime() { const handleRfConnectFailure = useCallback( async (driverIdentityId?: string, reason?: unknown): Promise => { clearConfigureTimeout(); + clearMeshtasticConfigureState(); console.error( '[useMeshtasticRuntime] Connection failed: ' + errLikeToLogString(reason ?? new Error('unknown connection failure')), @@ -4114,7 +4122,11 @@ export function useMeshtasticRuntime() { isConfiguringRef.current = true; setMeshtasticConfigurePhase(true); meshtasticIngestSessionRef.current?.setConfiguring(true); - await deviceRef.current.configure(); + try { + await deviceRef.current.configure(); + } finally { + clearMeshtasticConfigureState(); + } }, []); const sendReaction = useCallback( diff --git a/src/renderer/stores/nodeStore.configureReplay.test.ts b/src/renderer/stores/nodeStore.configureReplay.test.ts index 7245007e4..57361e246 100644 --- a/src/renderer/stores/nodeStore.configureReplay.test.ts +++ b/src/renderer/stores/nodeStore.configureReplay.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as configurePhase from '../lib/meshtastic/meshtasticConfigurePhase'; import { resetMeshtasticConfigurePhaseForTests, setMeshtasticConfigurePhase, } from '../lib/meshtastic/meshtasticConfigurePhase'; import { getNodeStatus } from '../lib/nodeStatus'; +import { meshcoreProtocol } from '../lib/protocols/MeshCoreProtocol'; import { meshtasticProtocol } from '../lib/protocols/MeshtasticProtocol'; import { MS_PER_DAY } from '../lib/timeConstants'; import { setConnection } from './connectionStore'; @@ -18,6 +20,7 @@ import { } from './nodeStore'; const ID_MT = 'id-mt-config-replay'; +const ID_MC = 'id-mc-config-replay'; const PEER = 42; const MY_NODE = 1; const NOW = new Date('2026-08-19T20:48:30.000Z').getTime(); @@ -46,6 +49,14 @@ describe('nodeStore configure replay last_heard', () => { lastSeenAt: NOW, }); setConnection(ID_MT, { myNodeNum: MY_NODE, status: 'connected', connectionType: 'ble' }); + addIdentity({ + id: ID_MC, + protocol: meshcoreProtocol, + signature: 'meshcore:config-replay', + transports: [], + createdAt: NOW, + lastSeenAt: NOW, + }); }); afterEach(() => { @@ -203,4 +214,23 @@ describe('nodeStore configure replay last_heard', () => { expect(lastHeardAt).toBe(NOW); expect(getNodeStatus(lastHeardAt!)).toBe('online'); }); + + it('does not touch configure progress for MeshCore position during configure phase', () => { + const touchSpy = vi.spyOn(configurePhase, 'touchMeshtasticConfigureProgress'); + setMeshtasticConfigurePhase(true); + useNodeStore.setState({ + nodes: { [ID_MC]: { [PEER]: { nodeId: PEER, lastHeardAt: NOW - 7 * MS_PER_DAY } } }, + traceRoutes: {}, + waypoints: {}, + neighborInfo: {}, + }); + updatePosition(ID_MC, { + nodeId: PEER, + latitude: 39.7, + longitude: -105, + timestamp: NOW, + }); + expect(touchSpy).not.toHaveBeenCalled(); + touchSpy.mockRestore(); + }); }); diff --git a/src/renderer/stores/nodeStore.ts b/src/renderer/stores/nodeStore.ts index 79ed64c23..45556de73 100644 --- a/src/renderer/stores/nodeStore.ts +++ b/src/renderer/stores/nodeStore.ts @@ -293,7 +293,8 @@ function meshtasticLastHeardPatch( return merged > 0 ? merged : undefined; } -function maybeTouchMeshtasticNodeDbConfigureProgress(): void { +function maybeTouchMeshtasticNodeDbConfigureProgress(identityId: IdentityId): void { + if (getIdentity(identityId)?.protocol.type !== 'meshtastic') return; if (getMeshtasticConfigurePhase()) { touchMeshtasticConfigureProgress(); } @@ -384,7 +385,7 @@ export function updatePosition(identityId: IdentityId, event: PositionEvent): vo const { nodeId, latitude, longitude, altitude, timestamp, groundSpeed, groundTrack } = event; const existing = byId[nodeId]; const lastHeardAt = meshtasticLastHeardPatch(identityId, timestamp, existing?.lastHeardAt); - maybeTouchMeshtasticNodeDbConfigureProgress(); + maybeTouchMeshtasticNodeDbConfigureProgress(identityId); return { nodes: { ...s.nodes, @@ -423,7 +424,7 @@ export function updateTelemetry(identityId: IdentityId, event: TelemetryEvent): } = event; const existing = byId[nodeId]; const lastHeardAt = meshtasticLastHeardPatch(identityId, timestamp, existing?.lastHeardAt); - maybeTouchMeshtasticNodeDbConfigureProgress(); + maybeTouchMeshtasticNodeDbConfigureProgress(identityId); return { nodes: { ...s.nodes,