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/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 064bdc4be..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` 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). 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/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', ); 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/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/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/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.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 new file mode 100644 index 000000000..6ca706b69 --- /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; + // Progress handler lifetime == wire attach lifetime (cleared on detach / test reset only). +} + +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..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(); @@ -169,6 +207,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..50ae9da79 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.post-reboot.test.ts @@ -3,10 +3,16 @@ 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 */ const DEVICE_CONFIGURING = 6; +/** DeviceConfigured — see Types.DeviceStatusEnum */ +const DEVICE_CONFIGURED = 7; function makeDeps(opts?: { isBleReconnectAttemptActive?: () => boolean }) { const touchLastData = vi.fn(); @@ -171,6 +177,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 +261,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 +290,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 +305,41 @@ 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(); + }); + + 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 46361c8dc..15bb9a95e 100644 --- a/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts +++ b/src/renderer/lib/meshtastic/meshtasticRuntimeWireEffects.ts @@ -41,6 +41,11 @@ import type { TelemetryPoint, } from '../types'; import { recordMeshtasticClientNotification } from './meshtasticClientNotification'; +import { + getMeshtasticConfigurePhase, + setMeshtasticConfigurePhase, + setMeshtasticConfigureProgressHandler, +} from './meshtasticConfigurePhase'; import { meshtasticDeviceStatusForCode } from './meshtasticDeviceStatus'; import { shouldFetchLocalLoraConfigAfterConfigure } from './meshtasticLocalLoraConfig'; import type { ModulePortEvent, PaxCounterPoint } from './meshtasticModuleEvents'; @@ -267,6 +272,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 (!getMeshtasticConfigurePhase() || type !== 'ble' || isBleReconnectAttemptActive()) return; + armBleConfigureStallTimeout(); }); const { @@ -391,7 +414,7 @@ export function attachMeshtasticRuntimeWireEffects( } if (identityId) { meshtasticIngestSessionRef.current = attachMeshtasticIngest(identityId, { - getIsConfiguring: () => isConfiguringRef.current, + getIsConfiguring: getMeshtasticConfigurePhase, getMyNodeNum: () => myNodeNumRef.current, }); } @@ -411,6 +434,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 +446,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 +455,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 +464,7 @@ export function attachMeshtasticRuntimeWireEffects( clearPostCommitRebootRecoveryRef.current(); clearConfigureTimeout(); isConfiguringRef.current = false; + setMeshtasticConfigurePhase(false); meshtasticIngestSessionRef.current?.setConfiguring(false); lastDataReceivedRef.current = Date.now(); startWatchdog(); @@ -497,6 +519,8 @@ export function attachMeshtasticRuntimeWireEffects( lastNodeInfoRequestAtRef.current.clear(); clearConfigureTimeout(); isConfiguringRef.current = false; + setMeshtasticConfigurePhase(false); + meshtasticIngestSessionRef.current?.setConfiguring(false); stopWatchdog(); stopGpsInterval(); cleanupSubscriptions(); @@ -627,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. @@ -679,7 +703,7 @@ export function attachMeshtasticRuntimeWireEffects( }), attachMeshtasticRawPacketSideEffects(identityId, { getMyNodeNum: () => myNodeNumRef.current, - getIsConfiguring: () => isConfiguringRef.current, + getIsConfiguring: getMeshtasticConfigurePhase, setRawPackets, setSignalTelemetry, touchLastData, @@ -722,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/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..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', () => { @@ -290,7 +301,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..a0aea4ff6 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'; @@ -441,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); @@ -976,6 +983,7 @@ export function useMeshtasticRuntime() { postRebootRecoveryScheduledRef.current = true; deviceConfiguredRef.current = false; isConfiguringRef.current = true; + setMeshtasticConfigurePhase(true); meshtasticIngestSessionRef.current?.setConfiguring(true); stopWatchdog(); stopGpsInterval(); @@ -1933,7 +1941,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, @@ -2040,6 +2048,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(); @@ -2258,6 +2267,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', }); @@ -2296,6 +2308,7 @@ export function useMeshtasticRuntime() { requestChatOutboxDrain('meshtastic'); }, onAttemptError: async (err, { lateTransport }) => { + clearMeshtasticConfigureState(); const failedDriverIdentity = openedDriverIdentityId ?? meshtasticIdentityIdRef.current ?? @@ -2584,6 +2597,9 @@ export function useMeshtasticRuntime() { } })(); + isConfiguringRef.current = true; + setMeshtasticConfigurePhase(true); + meshtasticIngestSessionRef.current?.setConfiguring(true); await configureMeshtasticDeviceWithRetry(activeDevice, { logTag: 'useMeshtasticRuntime attachRfSession', }); @@ -2598,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')), @@ -4102,7 +4119,14 @@ export function useMeshtasticRuntime() { const requestRefresh = useCallback(async () => { if (!deviceRef.current) return; - await deviceRef.current.configure(); + isConfiguringRef.current = true; + setMeshtasticConfigurePhase(true); + meshtasticIngestSessionRef.current?.setConfiguring(true); + 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 new file mode 100644 index 000000000..57361e246 --- /dev/null +++ b/src/renderer/stores/nodeStore.configureReplay.test.ts @@ -0,0 +1,236 @@ +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'; +import { addIdentity } from './identityStore'; +import { + bumpMeshtasticNodesLastHeardAt, + updatePosition, + updateTelemetry, + upsertNode, + useNodeStore, +} 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(); + +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' }); + addIdentity({ + id: ID_MC, + protocol: meshcoreProtocol, + signature: 'meshcore:config-replay', + transports: [], + createdAt: NOW, + lastSeenAt: NOW, + }); + }); + + 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('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); + 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('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, { + 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'); + }); + + 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 0702051b9..45556de73 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() && !event.fromUserPacket) { + touchMeshtasticConfigureProgress(); + } } const identityFields = nodeIdentityPatch( existing, @@ -272,11 +288,18 @@ function meshtasticLastHeardPatch( const merged = mergeMeshtasticLivePacketLastHeard( existingLastHeardAt ?? 0, packetTimestampMs, - false, + getMeshtasticConfigurePhase(), ); return merged > 0 ? merged : undefined; } +function maybeTouchMeshtasticNodeDbConfigureProgress(identityId: IdentityId): void { + if (getIdentity(identityId)?.protocol.type !== 'meshtastic') return; + if (getMeshtasticConfigurePhase()) { + touchMeshtasticConfigureProgress(); + } +} + /** Toggle favorite flag on a node in the identity-scoped store (UI reads this bucket). */ export function patchNodeFavorited( identityId: IdentityId, @@ -362,6 +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(identityId); return { nodes: { ...s.nodes, @@ -400,6 +424,7 @@ export function updateTelemetry(identityId: IdentityId, event: TelemetryEvent): } = event; const existing = byId[nodeId]; const lastHeardAt = meshtasticLastHeardPatch(identityId, timestamp, existing?.lastHeardAt); + maybeTouchMeshtasticNodeDbConfigureProgress(identityId); return { nodes: { ...s.nodes,